Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A6
34 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA6 · Part 3 · Advanced

Performance

Making a page feel fast is a different skill than making code run fast.

9 min read9 sections2 exercises
34 of 41

In this chapter

  • The critical rendering path
  • Not blocking the main thread
  • Yielding on purpose: scheduler.yield and postTask
  • Long Animation Frames — what actually blocked the frame
  • The metrics that actually get measured

and 4 more

about 9 min
09On this page
  1. The critical rendering path
  2. Not blocking the main thread
  3. Yielding on purpose: scheduler.yield and postTask
  4. Long Animation Frames — what actually blocked the frame
  5. The metrics that actually get measured
  6. Rendering less, later, or not yet
  7. Resource hints — telling the browser what's coming
  8. WebAssembly, and when it is worth it
  9. Tree shaking and bundle size

The critical rendering path

What actually has to happen before a browser can paint a single pixel: download the HTML, parse it into a DOM, download and parse CSS into a CSSOM, combine the two into a render tree (only the nodes that will actually be visible), compute every element's exact size and position (layout, also called reflow), then finally paint pixels for each one. A <script> with no defer/async blocks this whole pipeline at the HTML-parsing step — the exact mechanism behind the script-vs-module blocking behavior from the very first chapter.

Reflow (layout) Repaint
Triggered byanything that changes size or position — width, font-size, adding/removing an elementanything that changes appearance only — color, background, visibility
Costexpensive — can cascade to the whole subtree, sometimes the whole pagecheaper — no geometry to recompute
Cheapest of alltransform and opacity — these two can often skip layout AND paint entirely, handled straight on the compositor thread
⚠ Reading layout in a loop forces it early, repeatedly el.offsetHeight (or getBoundingClientRect()) forces the browser to run layout right now if anything is pending, instead of waiting for its natural time. Alternating writes and reads of layout properties in a loop — el.style.width = x; console.log(el.offsetHeight);, repeated — forces a full synchronous reflow on every iteration. This pattern has an actual name: layout thrashing. The fix is always the same shape: batch every read first, then batch every write.

Not blocking the main thread

requestAnimationFrame(fn) schedules fn to run right before the browser's next paint — the correct place for any animation logic, because it's synced to the actual screen refresh instead of guessing at a delay like setTimeout would. requestIdleCallback(fn) is the opposite priority: run fn only when the browser is otherwise idle, with time to spare before the next frame — for genuinely low-priority work (analytics batching, prefetching) that should never compete with anything the user is actually looking at.

function animate() {
  el.style.transform = "translateX(" + x + "px)";
  x += 2;
  if (x < 300) requestAnimationFrame(animate);   // re-schedule for the NEXT frame
}
requestAnimationFrame(animate);

requestIdleCallback(() => {
  sendAnalyticsBatch();   // only runs if the browser has spare time before the next frame
});

Debounce and throttle solve a different problem — how often a handler runs at all — and compose naturally with this: throttle a scroll handler down to a sane rate, then do the actual DOM write inside requestAnimationFrame so it lands at the right moment in the render pipeline.

Yielding on purpose: scheduler.yield and postTask

async function processLargeList(items) {
  for (const item of items) {
    doWork(item);
    if (needsToYield()) {
      await scheduler.yield();   // give the browser a turn, then resume right where you left off
    }
  }
}

scheduler.postTask(() => renderChart(), { priority: "user-visible" });
scheduler.postTask(() => sendAnalytics(), { priority: "background" });

scheduler.yield() is the modern replacement for the old setTimeout(fn, 0) trick people used to hand control back to the browser mid-loop — it returns a promise that resolves on the next turn, and unlike a plain setTimeout, the resumed work keeps its place in the priority queue rather than dropping to the back of it. scheduler.postTask is the explicit version of what requestIdleCallback only approximated: a real priority ("user-blocking", "user-visible", "background") instead of just "whenever there's spare time," so a chart the user is looking at can jump ahead of an analytics call that can wait.

Long Animation Frames — what actually blocked the frame

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.duration, "ms frame, blocked by:", entry.scripts.map((s) => s.sourceURL));
  }
}).observe({ type: "long-animation-frame", buffered: true });

A long task (below) only knows that the main thread was busy past 50ms. A Long Animation Frame (LoAF) entry knows why: which specific script, which function, and how much of the frame went to style/layout versus the script itself. It's the metric that finally answers "which of my 40 third-party scripts is actually causing the janky scroll," instead of a long-task list with no names attached.

The metrics that actually get measured

Metric Measures
LCP — Largest Contentful Painthow long until the biggest visible element (usually a hero image or heading) renders
INP — Interaction to Next Painthow long the page takes to visibly respond to a click, tap, or keypress — replaced the older FID metric for exactly this reason: FID only measured the delay before a handler started running, INP measures the whole thing including how long the handler itself takes
CLS — Cumulative Layout Shifthow much visible content jumps around unexpectedly — an image with no reserved width/height popping in and shoving everything below it down is the classic cause

A long task is any single chunk of main-thread JS running longer than 50ms without yielding — the main thread can't paint, or respond to input, until it's done, so a long task directly hurts both LCP and INP at once. Lighthouse is the tool that turns all of this into one number and a prioritized list of fixes; the metrics above are what it's actually measuring underneath that score.

Rendering less, later, or not yet

// Virtual list — render only the ~20 rows actually visible, not all 50,000
function mountVirtualList(viewport, items, rowHeight) {
  const spacer = document.createElement("div");
  spacer.style.height = items.length * rowHeight + "px";   // full scroll height, no rows yet
  spacer.style.position = "relative";
  viewport.append(spacer);

  function render() {
    const start = Math.floor(viewport.scrollTop / rowHeight);
    const count = Math.ceil(viewport.clientHeight / rowHeight) + 1;
    spacer.replaceChildren(...items.slice(start, start + count).map((item, i) => {
      const row = document.createElement("div");
      row.textContent = item;
      row.style.cssText = "position:absolute;left:0;right:0;height:" + rowHeight + "px;top:" + (start + i) * rowHeight + "px";
      return row;
    }));
  }
  viewport.addEventListener("scroll", () => requestAnimationFrame(render));
  render();
}

A virtual list keeps DOM node count roughly constant regardless of data size — 50,000 rows and 50 rows cost the same, because only what's actually in the viewport (plus a small buffer) is ever mounted. loading="lazy" on an <img> is the built-in, no-JS version of the same idea for images below the fold. Prefetching is the opposite bet — load something before it's needed, on a strong signal it's about to be (hovering a link, an IntersectionObserver from Browser APIs, in depth firing near the bottom of the page) — trading a little wasted bandwidth on guesses that don't pan out for a page that already has the next thing ready.

Dry run: what mountVirtualList actually renders at three scroll positions

viewport.scrollTopstart = floor(scrollTop / rowHeight)count = ceil(clientHeight / rowHeight) + 1Rows actually mounted
007rows 0–6
12527rows 2–8
1000207rows 20–26

rowHeight = 50px, clientHeight = 300px, 50,000 total items — and count never moves off 7 no matter where scrollTop lands or how many items exist in total. That's the entire performance win in one column: mounted DOM node count tracks the viewport size, not the size of the data.

Resource hints — telling the browser what's coming

<link rel="preconnect" href="https://api.example.com">
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
<img src="hero.jpg" fetchpriority="high">
<img src="footer-logo.png" fetchpriority="low" loading="lazy">
HintTells the browser
preconnectopen the connection (DNS, TCP, TLS) to this origin now, before anything actually needs it
preloadfetch this exact resource now — it will be needed soon, don't wait to discover it
fetchpriorityoverride the browser's own guess at how urgent this particular resource is

preload is for something the browser wouldn't otherwise find early — a font referenced only inside CSS, or an image set by JavaScript rather than a plain <img> tag the parser can see immediately. fetchpriority="high" is the direct fix for the classic LCP problem: the actual hero image is often not the highest-priority request the browser guesses on its own, and telling it explicitly can shave real time off the largest paint.

⚠ Preloading too much is its own performance bug Every preload competes for the same limited bandwidth at the start of a page load. Preloading everything "just in case" can push back the request that actually mattered — reserve it for the one or two resources profiling has shown to be on the critical path, not as a default habit.

WebAssembly, and when it is worth it

WebAssembly is a binary format that runs at near-native speed in the same sandbox as your JavaScript. It is not a faster JavaScript — it is a target for languages like Rust, C++ and Go, and it earns its place only for a specific shape of work.

Worth itNot worth it
Image, video and audio processingDOM work — every call crosses back into JavaScript
Compression, encryption, hashingAnything dominated by network time
Physics, simulation, 3D mathsOrdinary application logic
Porting a large existing C or Rust libraryCode you would otherwise write once in JS

The boundary is the cost

const { instance } = await WebAssembly.instantiateStreaming(fetch("/hash.wasm"));

// ✗ crossing 100,000 times — the boundary dominates
for (const n of numbers) total += instance.exports.add(n, 1);

// ✓ cross once, work in bulk inside linear memory
const mem = new Float64Array(instance.exports.memory.buffer, ptr, numbers.length);
mem.set(numbers);
instance.exports.sumAll(ptr, numbers.length);

Numbers pass across cheaply. Anything else does not — strings, arrays and objects have to be copied into the module's linear memory and back, and that copy is frequently more expensive than the computation you moved. The rule is to cross the boundary rarely and carry a lot each time.

It also has no direct DOM access. A WASM module that needs to touch the page calls back into JavaScript to do it, which is another boundary crossing — which is why "rewrite the UI in Rust" does not make a page faster.

What it costs before it runs

A module is a download, a compile and an instantiation. instantiateStreaming compiles while it downloads, which is the version to use. But a 2 MB module that saves 30ms of computation has lost before it started — measure the total, including fetch and compile, not just the function call.

Tree shaking and bundle size

Already covered: tree shaking only works because ESM imports are static and analyzable — a bundler can see the entire dependency graph and delete anything provably unused. "Provably" is the load-bearing word: a module with side effects at its top level (code that runs just from being imported — registering something globally, patching a prototype) can't be safely deleted even if nothing imports a name from it, because deleting it would change behavior. package.json's "sideEffects": false field is a library author's explicit promise that none of their files do this, which is what lets a bundler tree-shake it aggressively instead of playing it safe.

A bundle analyzer (a treemap of what's actually inside the shipped JS, sized by byte) is how "why is this bundle 400kb" stops being a guess — it routinely surfaces one unexpectedly heavy dependency, or an entire library imported for one small utility function that could have been hand-written in ten lines instead.

Interview answer, one sentence

"Perceived speed comes from the critical rendering path and the main thread: ship less, defer what is not needed yet, break up long tasks so input stays responsive, and measure with field metrics such as LCP, INP and CLS instead of guessing."

Before you move on
  • Explain the difference between reflow and repaint, and why transform/opacity can skip both.
  • Say what layout thrashing is and the batch-reads-then-writes fix for it.
  • Explain why a virtual list's DOM node count doesn't grow with the data, using the mountVirtualList dry run.
  • State what INP replaced FID for measuring, and why a long task hurts both LCP and INP at once.
  • Explain why crossing the WebAssembly/JS boundary rarely matters more than the computation itself.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsA cache that can't grow foreveradvanced
  • 9 testsProcess a big array in chunksadvanced
All 31 for this level →
A6

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A5Previous · A5Patterns & architecture
  2. A6you are here
  3. Next · A7SecurityThe mistakes that turn into a real incident, not just a bug.14 min read

01On this page

9 sections · 0%

  1. 01The critical rendering path
  2. 02Not blocking the main thread
  3. 03Yielding on purpose: scheduler.yield and postTask
  4. 04Long Animation Frames — what actually blocked the frame
  5. 05The metrics that actually get measured
  6. 06Rendering less, later, or not yet
  7. 07Resource hints — telling the browser what's coming
  8. 08WebAssembly, and when it is worth it
  9. 09Tree shaking and bundle size

[] chapters · t top