Lesson 26 of 28
Performance and Rendering
The pixel pipeline, layout thrashing, the 16ms frame budget and what LCP, CLS and INP actually measure — plus how to find the real cost before you optimise.
A virtualised table scrolled at a locked 60fps on the developer's laptop and juddered on a three-year-old Android in the same office. Same code, same data. The laptop had enough headroom to absorb a 12ms layout pass every frame; the phone did not. The bug was not on the phone. It had been in the code the whole time, hidden by a fast machine.
The pipeline every frame runs
Five stages, in order.
JavaScript runs and mutates state. Style works out which CSS rules apply and computes final values. Layout calculates geometry — where each box sits and how large it is. Paint fills in pixels into layers. Composite assembles those layers into the frame the screen shows, on the GPU.
Each stage feeds the next, so triggering an early one costs you everything after it. Which stage you trigger depends entirely on what you changed:
width,height,top,left,margin,padding,font-size→ layout, paint, composite.color,background-color,box-shadow,border-radius→ paint, composite.transform,opacity,filter→ composite only.
That last line is the whole reason "animate transform and opacity" is repeated so often. It is not that they are blessed. It is that moving or fading an already-painted layer requires no recalculation of anything, and the compositor can do it without the main thread at all — so it keeps animating even while your JavaScript is busy.
Two related tools: will-change: transform promotes an element to its own
layer ahead of time, and is easy to overuse — every layer costs memory, and
hundreds of them are slower than none. content-visibility: auto lets the
browser skip layout and paint for off-screen sections entirely, which is the
cheapest win available on a long page.
Layout thrashing
The browser batches your DOM writes and settles them once per frame. Reading a geometry property breaks the batch, because the answer has to be current.
// Slow: forces layout 200 times
for (const row of rows) {
row.style.height = row.offsetHeight * 1.5 + "px";
}Every iteration writes, invalidating layout, then reads, forcing it to be recomputed synchronously. This is "layout thrashing", and in the Performance panel it shows as a wall of purple with Forced reflow warnings.
Separate the phases:
// Fast: one layout for all the reads, one for all the writes
const heights = rows.map((row) => row.offsetHeight);
for (const [i, row] of rows.entries()) {
row.style.height = heights[i] * 1.5 + "px";
}Same result, two layout passes instead of two hundred. The properties that
force this include offsetTop/offsetHeight, clientWidth,
getBoundingClientRect(), scrollTop, and getComputedStyle(). When reads and
writes cannot be separated in one function, do the reads in the current task and
the writes inside requestAnimationFrame.
The budget
At 60Hz you have 16.7ms per frame, and the browser needs several of those for style, layout, paint and composite. Aim for 10ms of your own work per frame, and remember that 120Hz displays halve the number. Miss the deadline and the previous frame is shown again — one dropped frame is invisible, a run of them is what users call lag.
Anything occupying the main thread for more than 50ms is a long task.
The threshold is not arbitrary: a user who clicks during a 50ms task still sees
a response inside the 100ms window that feels instantaneous. Long tasks are
what INP measures the damage from, and the fix is almost always to break the
work up — scheduler.yield() where available, scheduler.postTask() for
priority, a worker for anything genuinely heavy.
Core Web Vitals, as they stand
LCP — Largest Contentful Paint. When the largest visible element finished
rendering. Good is 2.5s or under at the 75th percentile. The usual causes of a
bad score are a slow server response, a hero image discovered late by the
preload scanner, or render-blocking CSS and fonts. The fixes are mundane and
effective: fetchpriority="high" on the hero image, preconnect to the image
origin, and never lazy-load what is above the fold.
CLS — Cumulative Layout Shift. How much visible content moves without a
user action. Good is 0.1 or under. Caused by images without width and
height, ads and embeds injected into flow, and fonts swapping to a metrically
different face. Reserve the space: dimensions on every image, aspect-ratio on
every container, size-adjust on font faces.
INP — Interaction to Next Paint. Replaced FID as a Core Web Vital in 2024, and it is a much harder test. FID measured only the delay before a handler started. INP measures from the interaction to the next painted frame, handler included, and reports roughly the worst interaction on the page. Good is 200ms or under; over 500ms is poor. A bad INP is almost always a long task blocking the main thread, or a handler that does a synchronous render of a large tree.
If you are still optimising for FID, you are optimising for a metric that no longer exists.
Debounce and throttle
Both limit how often a function runs. They are not interchangeable.
Debounce waits for silence: the function runs once, after events stop for the delay. Throttle enforces a rate: the function runs at most once per interval while events keep arriving.
Search input is a debounce — you want the final value. Scroll position, pointer move and resize handlers are throttles — you want updates during the stream. Debouncing a scroll handler means nothing happens until the user stops scrolling, which is usually the opposite of the intent.
Five keystrokes, one debounced call with the final value, and a throttled call roughly every 100ms along the way. Real listeners would drive both; the timers stand in because the playground has no DOM.
Long lists
Rendering ten thousand rows costs you ten thousand elements of style, layout,
paint and memory, to show fifteen. Virtualisation renders only the visible
window plus a small overscan, and translates a spacer to keep the scrollbar
honest. TanStack Virtual and react-window are the usual choices; the pattern
matters more than the library.
Before reaching for it, try content-visibility: auto with
contain-intrinsic-size. It is one CSS declaration and no state to keep in
sync, and it is enough surprisingly often.
Measure first
The rule is not negotiable, because intuition about performance is
unreliable — the thrashing loop above looks harmless and the "obviously slow"
map chain usually is not.
performance.now()gives a monotonic, sub-millisecond clock.Date.now()can go backwards when the system clock adjusts.performance.mark()andmeasure()put your own spans on the Performance panel timeline.- The Performance panel is where you find the flame chart, forced reflow warnings and dropped frames.
PerformanceObserveris how you collect the same data from real users:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) report("long-task", entry.duration);
}
}).observe({ type: "longtask", buffered: true });Lab numbers on your machine are a hypothesis. Field data from real devices is the evidence. Throttle the CPU 4x in DevTools before you believe anything.
What you shipped
Two categories dominate real-world numbers, and neither is your algorithm.
Bundle cost. JavaScript is expensive twice over: downloading it, then
parsing and compiling it on a slow CPU. Split by route, lazy-load anything
below the fold or behind an interaction with import(), and check what a date
library or icon set is actually costing you before assuming it is fine.
Images and fonts. Images are usually the LCP element and usually oversized.
Serve AVIF or WebP, use srcset with real breakpoints, set explicit
dimensions, and mark the hero fetchpriority="high". Fonts block text: use
font-display: swap or optional, preload the one face used above the fold,
and subset it. Getting these two right beats most of the JavaScript work you
were planning.
What to remember
- JavaScript, style, layout, paint, composite. Whatever you trigger, you pay for everything after it.
transformandopacityskip layout and paint. That is why they animate smoothly.- Interleaved reads and writes force synchronous layout. Batch reads, then writes.
- About 10ms of your own work per frame; anything over 50ms is a long task.
- LCP 2.5s, CLS 0.1, INP 200ms. INP replaced FID, and it is stricter.
- Debounce for a final value, throttle for a live stream. Measure before you change anything.
Check yourself
4 questions · pass 3/4 to unlock Web Workers
1.Animating
topjanks where animatingtransformdoes not. What doestoptrigger thattransformavoids?2.A loop over 200 rows reads
row.offsetHeightand then writesrow.style.heighton each iteration. Why is it far slower than 200 reads followed by 200 writes?3.What does INP measure?
4.You want a search request sent only after the user stops typing. Which tool, and why?
4 left to answer