Rendering Performance
Nearly every performance mistake comes from not knowing which of four stages a change forces the browser to run, and on whose hardware. Learn the pipeline and the rest is arithmetic.
1. The pixel pipeline
Every visual update passes through some suffix of: style (match selectors, compute final values) → layout (compute geometry) → paint (fill pixels into layers) → composite (assemble layers on the GPU). Stages run in order, and running a stage implies running every stage after it.
Which stage a property enters at is the whole game. width, height, top,
margin, font-size enter at layout, so a change costs all four stages and the layout
cost scales with the number of affected descendants. background-color,
box-shadow, border-radius, color enter at paint — no geometry recomputation, but
still rasterisation. transform, opacity and filter on a composited layer skip
straight to composite and can run on the compositor thread, which is why they survive a
busy main thread and left does not.
The reflex when animating anything: express it as a transform. Animating left from
0 to 300px and transform: translateX(300px) look identical and differ by three pipeline
stages per frame.
2. The frame budget is not 16.7ms
At 60Hz a frame is 16.7ms, but that is the total for your work plus style, layout, paint, composite, garbage collection and browser bookkeeping. The usable main-thread share is closer to 8-10ms. On a 120Hz display the frame is 8.3ms and your share is around 4ms.
A long task is any main-thread task over 50ms — long enough that an input arriving at its start waits at least that long. Long tasks are the unit of unresponsiveness, and one 120ms task is far worse than four 30ms tasks doing the same work, because only the former can hold an input hostage.
3. Core Web Vitals now
Three metrics, each assessed at the 75th percentile of real users, segmented by device:
| Metric | Good | Poor |
|---|---|---|
| LCP (loading) | ≤ 2.5s | > 4.0s |
| INP (responsiveness) | ≤ 200ms | > 500ms |
| CLS (visual stability) | ≤ 0.1 | > 0.25 |
INP became stable in 2024, replacing First Input Delay. FID measured only the delay before the first interaction's handler started, so it ignored handler duration and every later interaction; almost everything passed it. INP takes roughly the worst interaction of the visit and measures input delay plus processing plus the next paint. Sites that passed FID comfortably routinely fail INP, and that is FID's fault, not a regression.
4. LCP
LCP is the render time of the largest image or text block in the initial viewport — usually a hero image, or a heading that cannot paint until a webfont arrives.
Identify the element before optimising; teams routinely optimise the wrong one. Then attack the dominant phase: time to first byte, resource load delay (late discovery), load time, or render delay (blocked by CSS, fonts, or hydration).
Discovery is the usual culprit. An <img> in the initial HTML is found by the preload
scanner immediately; one rendered by client JavaScript, set as a CSS background-image,
or chosen after a media query is not. Fix with a real <img> plus
fetchpriority="high", or <link rel="preload" as="image" fetchpriority="high">.
Never apply loading="lazy" to the LCP element. Blanket lazy-loading is the single
commonest self-inflicted LCP regression: the browser defers the one image it should fetch
first. Lazy-load below the fold only.
For text LCP, use font-display: swap or optional with a preloaded, subsetted WOFF2,
so the heading paints in the fallback rather than waiting.
5. CLS
CLS sums layout shift scores over the worst 5-second session window (shifts separated by more than 1s starting a new window). Every shift traces to content arriving after layout was already computed for something else.
Reserve space unconditionally. Give every <img> and <video> width and
height attributes — browsers derive an aspect-ratio from them and hold the box
before the bytes arrive. For containers with unknown content, set min-height.
Fonts shift text when the fallback's metrics differ from the webfont's. Correct with
size-adjust, ascent-override and descent-override on an @font-face fallback
so the two occupy the same space.
Late-injected banners, consent dialogs and ad slots are the remaining cause: render them in a pre-reserved slot, or fixed, outside flow entirely. Shifts within 500ms of a user interaction are excluded — which is why an accordion opening is fine and a banner appearing on its own is not.
6. INP
INP has three parts. Input delay is time spent waiting for a busy main thread — caused by third-party scripts, hydration, and long tasks unrelated to the interaction. Processing is your handlers. Presentation delay is the render that follows.
The classic failure is a heavy re-render on every keystroke: an input drives state that re-renders a large filtered list, so each character costs 150ms and the field visibly lags. The fix is not debouncing the field — that makes the caret feel broken — but separating the urgent update (the input's own value) from the non-urgent one (the list).
Break up long tasks by yielding. await scheduler.yield() resumes at the front of the
queue and is the right primitive where available, but it is not yet Baseline, so feature-
detect and fall back to await new Promise(r => setTimeout(r, 0)). Yield after painting
the visible acknowledgement, not before.
7. React
The React Compiler reached 1.0 in October 2025 and memoises automatically at build time. In
a compiled codebase, hand-written useMemo and useCallback are mostly noise, and each
one still costs a dependency-array comparison and retains its captured values. Without the
compiler, memoise only what profiling shows: memo on a component whose parent re-renders
often with unchanged props, useMemo for genuinely expensive computation or for an object
identity that a memoised child or an effect depends on. Wrapping a string concatenation in
useMemo is a net loss.
Context is the common cascade: every consumer re-renders when the provider's value changes,
so a context holding both a rarely-changing theme and a per-keystroke value re-renders the
whole tree on every keystroke. Split providers by change frequency; never pass a fresh
object literal as value.
useTransition and useDeferredValue mark an update as interruptible so React can
paint the urgent one first — the correct tool for the typeahead case above.
Virtualise a list when rendered rows exceed roughly 100, or sooner if rows are heavy. Below that, virtualisation adds scroll complexity, breaks in-page find, and harms accessibility for no measurable gain.
8. Layout thrash, containment, and images
Reading a geometry property (offsetHeight, getBoundingClientRect,
scrollTop, getComputedStyle) forces a synchronous layout if styles are dirty. In a
loop that alternates read and write, you force one layout per iteration — quadratic-feeling
cost from linear-looking code. Batch all reads, then all writes.
content-visibility: auto skips style, layout and paint for off-screen subtrees; pair it
with contain-intrinsic-size or the scrollbar will jump. contain: layout paint scopes
work to a subtree.
Serve AVIF or WebP, size with srcset/sizes, and never ship a 2000px image into a
400px slot.
9. Measure honestly
A development laptop on a fast connection is not a measuring instrument. Lab tools
diagnose; only field data (CrUX, or web-vitals reported to your own endpoint) tells you
what users experience. Throttle CPU 4-6x and use a slow network profile before
believing anything is fast, and check the p75, not the median.
Rules
MUST NOT — Do not apply loading="lazy" or client-side lazy rendering to the element that is or may be the LCP element.
Why: Lazy loading defers the request until layout proves the element is near the viewport, which is precisely the discovery delay LCP measures. Applying it to the hero image removes the one resource that should be fetched first from the preload scanner’s reach, typically adding several hundred milliseconds on a mobile connection.
Incorrect:
<img src="/hero.avif" loading="lazy" width="1200" height="630" alt="">
Correct:
<img src="/hero.avif" fetchpriority="high" width="1200" height="630" alt="">
MUST NOT — Do not insert banners, consent dialogs, notification bars, or ad slots into the document flow after first paint without pre-reserved space.
Why: Content inserted above existing content displaces everything below it, and because the insertion is not attributable to a user interaction it falls outside the 500ms exclusion window and counts in full toward CLS. A single top-of-page banner appearing at 1.5s can exceed the entire 0.1 budget on its own.
Exceptions:
- Overlays positioned fixed or absolute, which are outside normal flow and displace nothing.
MUST — Animate only transform, opacity, and filter; never animate width, height, top, left, or margin.
Why: Geometric properties enter the pixel pipeline at the layout stage, so every frame forces layout, paint and composite for the element and its affected descendants. Transform and opacity are handled at the composite stage on an existing layer, so they can run on the compositor thread and continue at full frame rate while the main thread is busy.
Incorrect:
.panel { transition: left 200ms, height 200ms; }
Correct:
.panel { transition: transform 200ms, opacity 200ms; will-change: transform; }
MUST — Identify the actual LCP element and the phase that dominates its timing before making any change intended to improve LCP.
Why: LCP decomposes into time to first byte, resource load delay, resource load time, and render delay, and each phase has a disjoint set of fixes. Optimising image bytes when the dominant phase is render delay caused by client-side rendering produces no measurable change, which is the most common way LCP work is wasted.
MUST — Give every image, video, iframe, and embed explicit width and height attributes or an aspect-ratio, and reserve a min-height for any container whose content arrives asynchronously.
Why: Layout is computed before a resource’s intrinsic dimensions are known, so an unsized element occupies zero height and everything below it moves when the bytes arrive. Dimension attributes let the browser derive an aspect-ratio and hold the correct box from the first layout pass.
MUST — Batch all geometry reads before all style writes; never interleave reading offsetHeight or getBoundingClientRect with mutating styles inside a loop.
Why: Reading a geometry property while style changes are pending forces the browser to run layout synchronously so it can return a correct value. Alternating reads and writes therefore forces one full layout per iteration instead of one for the whole batch, turning linear-looking code into repeated whole-document layout.
Incorrect:
for (const el of items) {
el.style.height = el.offsetHeight * 2 + 'px'
}
Correct:
const heights = items.map((el) => el.offsetHeight)
items.forEach((el, i) => { el.style.height = heights[i] * 2 + 'px' })
MUST — Split React context providers by change frequency, and never pass a freshly-created object literal as a provider value.
Why: Context propagation is triggered by reference inequality of the provider value, and every consumer re-renders regardless of which part of the value it reads. A single provider mixing a stable theme with a per-keystroke value therefore re-renders every themed component on every keystroke, which is the most common cause of a failing INP in React applications.
Incorrect:
<AppCtx.Provider value={{ theme, user, query }}>{children}</AppCtx.Provider>
Correct:
<ThemeCtx.Provider value={theme}>
<QueryCtx.Provider value={query}>{children}</QueryCtx.Provider>
</ThemeCtx.Provider>
MUST — Always pair content-visibility: auto with contain-intrinsic-size.
Why: content-visibility: auto skips layout for off-screen subtrees, so without a declared placeholder size those subtrees measure zero height. The scroll container then reports the wrong total height, the scrollbar jumps as content enters and leaves the viewport, and scroll anchoring fights the user.
Incorrect:
.row { content-visibility: auto; }
Correct:
.row { content-visibility: auto; contain-intrinsic-size: auto 72px; }
MUST — Serve images in AVIF or WebP with srcset and sizes, and never transfer an image whose intrinsic width exceeds twice its largest rendered width.
Why: Image bytes usually dominate the transferred weight of a page and are on the critical path for LCP. AVIF typically encodes at a third to a half of an equivalent-quality JPEG, and correct srcset selection avoids sending desktop-resolution files to devices that will downscale them, wasting both bandwidth and decode time.
MUST — Measure with CPU throttled at least 4x and a constrained network profile, and judge against the 75th percentile rather than the median.
Why: A development machine executes JavaScript several times faster than the median device in most real audiences, so main-thread problems that dominate field INP are simply invisible locally. Core Web Vitals are assessed at p75, so a median that passes tells you nothing about whether the site passes.
SHOULD NOT — Do not add memo, useMemo, or useCallback speculatively; add them only where a profile shows the prevented work exceeds the comparison cost.
Why: Every memoisation hook allocates a dependency array, compares it on each render, and retains its captured values for the component’s lifetime. Around cheap expressions this is a net cost, and around a component whose props change every render it is pure overhead because the comparison never hits.
Exceptions:
- Memoising for referential stability that a memoised child, an effect dependency, or a context value depends on, which is a correctness concern rather than an optimisation.
SHOULD — Declare a fallback @font-face with size-adjust, ascent-override, and descent-override matched to the webfont, rather than relying on font-display alone.
Why: font-display: swap eliminates invisible text but not the reflow, because the fallback and the webfont occupy different amounts of space. Overriding the fallback’s metrics makes the two typefaces occupy identical boxes, so the swap changes glyph shapes without moving a single line.
Exceptions:
- font-display: optional, which never swaps within the same page view and therefore cannot shift.
SHOULD — Break any main-thread work exceeding roughly 50ms into chunks that yield, using scheduler.yield() where available with a setTimeout fallback.
Why: The browser cannot interrupt a running task to dispatch an input event, so a task of duration D adds up to D milliseconds of input delay to anything the user does during it. Yielding returns control to the event loop at each boundary, capping the worst-case wait at the chunk size rather than the total.
Incorrect:
for (const row of rows) process(row) // 900ms, uninterruptible
Correct:
for (const [i, row] of rows.entries()) {
process(row)
if (i % 50 === 49) await (globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0)))
}
SHOULD — Separate the urgent part of an interaction from the expensive part using useTransition or useDeferredValue rather than debouncing the input itself.
Why: Debouncing the control delays the feedback the user is directly watching, so the caret or toggle appears to lag even though total work fell. Marking only the derived update as non-urgent lets React paint the control immediately and interrupt the expensive render when the next keystroke arrives.
Exceptions:
- Work with an external cost per invocation, such as a network request, which should still be debounced or throttled.
SHOULD — In a codebase compiled with React Compiler, remove hand-written memoisation rather than adding to it, and treat compiler bailouts as defects to fix.
Why: React Compiler 1.0 inserts memoisation at build time with finer granularity than hooks allow, so manual hooks are redundant while still costing comparisons. It silently skips components that break the Rules of React, so a bailed-out component receives no optimisation at all while appearing to be covered.
Source: React Compiler v1.0
SHOULD — Virtualise a list only once rendered rows exceed roughly 100, or sooner if rows contain images or charts; below that use stable keys and memoised rows instead.
Why: Virtualisation removes off-screen nodes from the DOM, which also removes them from browser find-in-page, from anchor targets, and from the accessibility tree unless setsize and posinset are managed manually. Under about a hundred rows the layout and paint cost it saves is smaller than the scroll bookkeeping it adds.
SHOULD — Use lab tooling to diagnose causes but field data (CrUX or your own web-vitals reporting) to decide whether a change worked.
Why: Lab runs sample one synthetic device, connection, and cache state, so they cannot represent the distribution that p75 is drawn from. A lab score can improve while the field p75 worsens, most commonly when a change helps warm-cache repeat visits and hurts cold first loads.
SHOULD — Set and enforce a per-route JavaScript budget in CI, measured as compressed transfer size and main-thread execution time, not as total bundle size.
Why: Parse, compile and execution cost scales with the JavaScript actually delivered to a route, and on mid-range mobile hardware executing a script costs several times longer than downloading it. A whole-application bundle figure hides per-route regressions, and without a CI gate the size ratchets upward one feature at a time.
Before reporting completion
Run these checks against your own output. Answer each question explicitly rather than assuming the answer, because the point of the exercise is to notice what you did not notice while building.
Confirm no change forces avoidable pipeline stages. (blocking)
- List every animated or transitioned property. Does any of them enter the pipeline at layout or paint rather than composite?
- Does any loop read a geometry property (offsetHeight, getBoundingClientRect, scrollTop, getComputedStyle) after writing a style in the same iteration?
- Is will-change applied to a small number of elements that actually animate, rather than left on permanently?
Confirm the LCP element is discoverable and prioritised. (blocking)
- Which element is the LCP element, and did you confirm that rather than assume it?
- Is that element present in the server-rendered HTML, so the preload scanner can find it?
- Does it carry loading="lazy", or is it rendered only after client JavaScript runs?
- Which of the four LCP phases dominates, and does the change you made address that phase?
Confirm nothing shifts after first paint. (blocking)
- Does every image, video, iframe, and embed have explicit dimensions or an aspect-ratio?
- Does every loading skeleton occupy exactly the height of the content that replaces it?
- Is anything inserted into document flow after first paint — a banner, consent dialog, or ad slot — without reserved space?
- Do webfonts have a metric-matched fallback, or will the swap reflow text?
Confirm interactions stay responsive under load. (blocking)
- For the heaviest interaction on this screen, what work happens synchronously in the handler, and does it exceed 50ms under 4x CPU throttling?
- Does any keystroke trigger a re-render of a large list or tree without a transition or deferred value?
- Does any context provider mix a per-interaction value with values consumed by a wide subtree?
- Is visible feedback painted before the expensive work begins, rather than after it?
Confirm the measurement supports the claim. (blocking)
- Was the measurement taken with CPU throttling and a constrained network profile, or on an unthrottled development machine?
- Are you reporting a p75 across real sessions, or a single lab run?
- Was the same scenario measured before and after the change, with the same cache state?
- Did any metric other than the one you targeted get worse?
Confirm the route stays within its resource budget.
- What is the compressed JavaScript transfer size for this route, and what is its budget?
- Does the route ship any dependency it uses only on an interaction that could be dynamically imported?
- What is the largest image transferred, and how does its intrinsic width compare with its rendered width?
Further reference
These are not loaded by default. Read one only when its question is the question you currently have.
references/diagnosing-web-vitals.md— A specific Core Web Vital is failing. What is the exact procedure to find the cause rather than guessing at fixes?references/react-render-patterns.md— How do I find and fix expensive re-renders in a React application, and when is memoisation, virtualisation, or a transition actually the right tool?