A6 · Part 3 · Advanced
Performance
Making a page feel fast is a different skill than making code run fast.
09On this page
- 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
- Rendering less, later, or not yet
- Resource hints — telling the browser what's coming
- WebAssembly, and when it is worth it
- 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 by | anything that changes size or position — width, font-size, adding/removing an element | anything that changes appearance only — color, background, visibility |
| Cost | expensive — can cascade to the whole subtree, sometimes the whole page | cheaper — no geometry to recompute |
| Cheapest of all | transform 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 Paint | how long until the biggest visible element (usually a hero image or heading) renders |
| INP — Interaction to Next Paint | how 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 Shift | how 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.scrollTop | start = floor(scrollTop / rowHeight) | count = ceil(clientHeight / rowHeight) + 1 | Rows actually mounted |
|---|---|---|---|
| 0 | 0 | 7 | rows 0–6 |
| 125 | 2 | 7 | rows 2–8 |
| 1000 | 20 | 7 | rows 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">
| Hint | Tells the browser |
|---|---|
preconnect | open the connection (DNS, TCP, TLS) to this origin now, before anything actually needs it |
preload | fetch this exact resource now — it will be needed soon, don't wait to discover it |
fetchpriority | override 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 it | Not worth it |
|---|---|
| Image, video and audio processing | DOM work — every call crosses back into JavaScript |
| Compression, encryption, hashing | Anything dominated by network time |
| Physics, simulation, 3D maths | Ordinary application logic |
| Porting a large existing C or Rust library | Code 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/opacitycan 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
mountVirtualListdry 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.
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series