Performance, Measured
Every earlier part explained a mechanism; this one is how to know which mechanism is costing you, for which users, and whether a fix held. The metrics and what each stands for, LCP in four phases and INP in three, where layout shift comes from, field versus lab and the APIs under both, how to read a Performance trace lane by lane, budgets that stop a win decaying, and one investigation worked end to end.
What to measure: the metrics and what each one stands for
"The page feels slow. Which number do we look at, and what is it actually measuring?"
Each performance metric is a proxy for one user experience: is anything happening, is the main thing visible, can I use it, did it jump around. The Core Web Vitals are three of these, chosen because they are measurable in the field across all pages; the rest are diagnostics that explain them.
| Metric | Measures | Good / poor (p75) | Field? | Stands for |
|---|---|---|---|---|
| TTFB | Navigation start to first byte of the response | < 800 ms / > 1800 ms | Yes | Server and network; the floor under everything else |
| FCP | First text or image painted | < 1.8 s / > 3 s | Yes | "Something is happening" |
| LCP | Render time of the largest image or text block in the viewport | < 2.5 s / > 4 s | Yes (CWV) | "The main thing is here" |
| CLS | Sum of unexpected layout shift scores in the worst 5 s window | < 0.1 / > 0.25 | Yes (CWV) | "It did not jump under me" |
| INP | Worst interaction latency, input to next paint | < 200 ms / > 500 ms | Yes (CWV) | "It responds when I touch it" |
| TBT | Sum of long-task time over 50 ms between FCP and TTI | < 200 ms / > 600 ms | Lab only | A proxy for input delay |
| Long Animation Frames | Frames over 50 ms with script attribution | Diagnostic | Yes | Why INP is bad: which script |
| Frame rate / dropped frames | Compositor frames missed during scroll and animation | 60 fps target | Lab mostly | Smoothness |
- LCP's candidate changes as the page loads: text, then a hero image, then a bigger image. Only the final candidate counts, and it stops being updated at first input. An LCP element that is a background image counts; a video poster counts; a low-entropy image (a huge near-blank placeholder) is excluded since 2023.
- CLS counts only unexpected shifts: a shift within 500 ms of a user input is excused. It is a windowed sum (sessions of up to 5 s with 1 s gaps), so a slow drip of small shifts can score worse than one early jump.
- INP is a percentile of interactions, not the average: the worst one, or p98 for pages with over 50. Hover and scroll are not interactions; clicks, taps and key presses are. A page with one slow autocomplete has a poor INP regardless of everything else.
- p75: Google judges a page by the 75th percentile of its field distribution. A quarter of users can be worse and the page still passes; the median is not the target.
LCP in four phases
A slow LCP is one of four different problems, and the fix for each lives in a different place. The web-vitals attribution build and the Performance panel both break LCP into these phases; reading them is the first step of every LCP investigation.
- Time to first byte. Owned by infrastructure: DNS, connection, CDN, origin compute. Fixes are caching the HTML at the edge, streaming the response early (flush the head before the data query finishes), preconnect hints, HTTP/3, a faster origin. Nothing in the page's JavaScript affects this phase.
- Resource load delay. The gap between TTFB and the start of the LCP resource's fetch. In a healthy page this is near zero because the preload scanner found the image in the HTML. It is large when the image is a CSS background, inserted by JavaScript, marked lazy, or rendered by a client-side component after hydration. The fix is discoverability: an
<img>in the HTML withfetchpriority="high", or a preload link. - Resource load time. The fetch itself. Owned by bytes and priority: right-sized via srcset, modern format, served from a warm CDN on a preconnected origin, and high priority so it is not queued behind a dozen scripts.
- Element render delay. The resource arrived but the element was not painted: the main thread was busy (a long task from the bundle), render-blocking CSS was still loading, or the element was hidden until a framework mounted it. The fix is less blocking work before paint: defer scripts, inline critical CSS, server-render the hero.
- When the LCP element is a heading or paragraph, phases 2 and 3 are about the web font:
font-display: swaporoptionalso the text paints in a fallback, with a metric-matched fallback so the swap does not shift; preload the font file. - Render delay for text is render-blocking CSS and script before first paint.
onLCP(cb, { reportAllChanges }) from web-vitals/attribution gives attribution.timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelay, and the element selector. Send those to analytics, not just the LCP number.INP in three phases
Interaction to Next Paint replaced First Input Delay because FID only measured the delay before a handler ran, and most slow interactions are slow inside and after the handler. INP measures to the next frame that shows the result, which is what the user perceives.
- Input delay. From the input event to the handler starting. Caused by whatever is already on the main thread: a long task, a previous interaction's work, a third-party script's timer. Fix: break up long tasks (
scheduler.yield(),setTimeoutchunks,isInputPending), move work to workers, defer third parties, avoid timers that fire during interaction. - Processing duration. The handlers themselves, all of them for this event, plus any synchronous rendering they trigger (a framework's synchronous re-render) and forced layouts they cause. Fix: do less; update the visible state and yield before the rest; avoid layout reads between DOM writes; batch state updates; use a framework's transition or deferred-value APIs for the non-urgent part.
- Presentation delay. After the last handler returns: style recalc, layout, paint, compositing of the frame. Caused by large invalidation scopes (changing a class on the root), many nodes, expensive paint. Fix: contain and content-visibility, smaller DOM, cheaper styles on the changed path, compositor-only animations.
the shape of a fast handler:
on input:
1. change what the user must see this frame (pressed state, count, the typed character) < 16 ms
2. await scheduler.yield() (or setTimeout 0; or requestAnimationFrame + setTimeout for the frame after paint)
3. do the rest: filter the list, recompute, send analytics, re-render the table
the user sees step 1 immediately. INP measures to the end of step 1's frame. step 3 still happens.
in React: startTransition / useDeferredValue do step 2 for you. in a keystroke filter over 10k rows, that is the whole fix.- Field:
onINP(cb)from web-vitals/attribution reportsinteractionTarget(a selector),interactionType, and the three phase durations, plus the Long Animation Frame that contained it with its script attribution. Aggregate by target: the top three targets are the work. - Lab: Performance panel, Interactions track: each interaction as a bar, colour-coded, with the phases on hover; click it and the main thread below shows exactly what ran.
- Common culprits: keystroke handlers that filter synchronously; click handlers that re-render whole lists; third-party scripts with 50 ms timers; forced layout in a scroll or resize handler; hydration finishing during the first tap.
CLS: finding the shift
- Images and embeds without dimensions. The box is zero height until bytes arrive, then everything below moves. Fix: width and height attributes, or aspect-ratio, on every img, video, iframe and ad slot.
- Content injected above existing content. Banners, cookie notices, "app install" bars, late-loading headers. Fix: reserve the space, or overlay instead of insert, or insert below the fold.
- Web fonts swapping. A fallback font with different metrics renders, then the web font arrives and every line reflows. Fix: metric-matched fallback via
size-adjust/ascent-override; preload the font;font-display: optionalfor non-critical fonts. - Dynamic content without placeholders. A recommendations widget that pops in; a comment count; a price that arrives after the product. Fix: skeletons with the final dimensions.
- Animations of layout properties. Animating height, top, margin moves neighbours every frame; each frame is a shift. Fix: transform.
- Late-arriving CSS. Content renders unstyled, then styled. Fix: critical CSS inline, no render-blocking stylesheet arriving late.
- Field:
onCLS(cb)with attribution giveslargestShiftTarget,largestShiftTime, and the sources. Group by target. - Lab: Performance panel → Layout Shifts track; click a shift to see the elements that moved, their before and after rects, and the score. Rendering → Layout Shift Regions paints them blue live.
- The 500 ms rule: shifts within half a second of a click or tap are excluded. An accordion opening is fine. A shift 600 ms after a click, because the content it opened loaded slowly, is counted.
Field versus lab, and the Performance APIs underneath
Two sources of truth that disagree by design. Lab tools answer "why is this slow" for one scenario. Field data answers "how slow is it, for whom" across every scenario. The job is to use each for its question.
- CrUX. Chrome's public dataset: per origin and per URL, 28-day rolling p75 for CWV, split by device and connection. Through PageSpeed Insights, the CrUX API, BigQuery, and Search Console. Chrome users only, opted-in, no logged-in-only pages with low traffic.
- Your own RUM. The
web-vitalslibrary (attribution build) sending to your analytics with the page, the element, the phases, device memory, connection type, and whether the view was a bfcache restore or a prerender activation. The only way to see your own segments: logged-in users, a specific flow, a specific country. - Sampling and percentiles. Report distributions, not averages: p50, p75, p95. Averages hide the tail that the metric is about.
- Lighthouse. One run, simulated throttling by default (fast run, modelled slowdown), a score, and audits with pointers. Reproducible enough for CI with several runs averaged.
- The Performance panel. The trace: every task, every frame, every request, with the Web Vitals lane, Interactions lane, Layout Shifts lane, and an insights sidebar that reads the trace for you (LCP phases, render-blocking requests, third parties, forced reflows).
- WebPageTest. Real devices and real networks, filmstrips, waterfalls, repeat view, scripting for logged-in flows. The best lab tool for a specific question about a specific condition.
- PerformanceObserver with entry types:
navigation(the full navigation timing),resource(every fetch with timing and size),paint(FP, FCP),largest-contentful-paint,layout-shift,eventandfirst-input(interaction timing),longtask,long-animation-frame(with script attribution: which function, from which URL, how long),element(opt-in per element). - User Timing:
performance.markandmeasurefor your own milestones ("search results rendered"), visible in the Performance panel and reportable to the field. The way to measure the thing your product actually cares about. - Server Timing: a response header the server adds (
Server-Timing: db;dur=53, cache;desc="miss") that surfaces in the Network panel and in resource entries. Backend timing in the browser's view. performance.now(): monotonic, sub-millisecond (coarsened unless cross-origin isolated).
Reading a Performance trace
The Performance panel is the one tool that shows everything this course has described, in time order, for one page load or interaction. Reading it is a skill: knowing which lane answers which question and what the colours mean.
- Overview: CPU usage over time, screenshots filmstrip, network bars. Find the region to zoom into.
- Timings: the vital markers (FCP, LCP, DCL, L) and your User Timing marks. Click LCP for the element and phases.
- Interactions: each interaction as a bar; hover for phases; whisker lines show input delay and presentation delay.
- Layout Shifts: each shift; click for the moved elements.
- Network: every request as a bar: light part is waiting, dark is download; left line is queued time. Render-blocking resources are marked. Priority on hover.
- Frames: each frame with its duration; red for dropped; partially presented in yellow.
- Main: the flame chart of the main thread. Yellow is script (Evaluate Script, function calls by name), purple is rendering (Recalculate Style, Layout, Pre-Paint), green is painting (Paint, Composite Layers), grey is tasks, blue is parse HTML. Red triangles mark long tasks and forced reflows.
- Other threads: compositor, raster, workers, the network thread. A worker's work appears in its own lane.
- Insights sidebar: the panel's own reading: LCP by phase, LCP request discovery, render-blocking requests, forced reflows with stacks, third-party summary, DOM size, font display, CLS culprits.
- Why is LCP late? Timings → LCP marker → phases. Then Network for the resource, Main for what blocked rendering around that time.
- Why is this click slow? Interactions → the bar → Main underneath it: the handler's frames, then any purple and green after it.
- What is this long task? Click it; the Bottom-Up tab groups time by function; Call Tree shows the stack top-down; the source link opens the file. Source maps make this readable for production bundles.
- Is there a forced reflow? Red triangle on a Layout inside a script frame; the Summary tab shows the stack that forced it.
- Is the animation on the compositor? Frames lane steady at 60 with the Main lane idle means yes. Purple and green every frame means no.
- Who is this third party? Insights → Third parties, or the Bottom-Up tab grouped by domain.
Budgets, regressions, and keeping a win
Performance work decays. Every feature adds bytes and tasks, and no single addition is the one that made it slow. A budget turns the aggregate into a line that a pull request cannot cross without someone deciding to cross it.
- Bytes on the critical path: JavaScript and CSS needed before first paint and first interaction, compressed. Checked by the bundler's output (size-limit, bundlesize, webpack-bundle-analyzer in CI).
- Total page weight per route, by resource type.
- Lab metrics under a fixed device and network profile: LCP, TBT, CLS, via Lighthouse CI with assertions, several runs, median.
- Request counts and render-blocking requests: a new blocking stylesheet or sync script is a regression even at 2 KB.
- Custom marks: "time to first search result" measured via User Timing in an end-to-end test against the staging build.
- Fail the PR, with the number, the delta, and the trace or bundle diff attached. A failure a developer can act on in five minutes gets fixed; one that needs a meeting gets overridden.
- Allow raising the budget in the same PR with a reason in the diff. The point is visibility, not prohibition.
- Track history (Lighthouse CI server, or a dashboard from the CI artefacts) so a slow drift is visible even when no single PR failed.
- Close the loop with field data monthly: the budget guards the lab proxy; the field number is the outcome. If they diverge, the lab profile is wrong and needs updating to match the p75 user.
- Own third parties: every tag goes through a review that records its cost, its loading strategy (after interaction, in a worker via Partytown, or not at all), and an owner who can remove it.
a starting budget for a content or commerce site, mid-range mobile profile: critical JS ≤ 150 KB compressed critical CSS ≤ 50 KB compressed (critical inline ≤ 14 KB) page weight ≤ 1 MB on first view LCP ≤ 2.5 s TBT ≤ 200 ms CLS ≤ 0.1 render-blocking no new ones third-party JS ≤ 100 KB, none before interaction-ready adjust to your field p75 device. the numbers matter less than the existence of a line.
An investigation, end to end
A worked example that uses everything above, in the order a real one goes.
- Search Console says the product detail page template has poor INP on mobile (p75: 410 ms) and needs-improvement LCP (2.9 s). Desktop is fine. The RUM dashboard agrees and adds: INP is worst on Android mid-range devices; the top interaction target is
button.add-to-cart; the top LCP element isimg.hero; LCP load delay averages 900 ms.
- Performance panel, CPU 4×, network Fast 3G, a real product URL, logged in as a user with a cart. Record load, then tap add-to-cart, then stop.
- LCP marker: element is the hero img; phases: TTFB 600 ms, load delay 950 ms, load time 700 ms, render delay 200 ms. Load delay confirmed. Network lane: the hero image request starts 950 ms after the HTML arrives, with Low priority. The image is inserted by the product gallery component after hydration.
- Interactions lane: add-to-cart 430 ms: input delay 120 ms (a long task from
recommendations.jsrunning at the moment of the tap), processing 260 ms (the handler dispatches to the store; the whole page re-renders synchronously; a forced reflow in a sticky-header hook that readsgetBoundingClientRectafter the DOM update), presentation 50 ms.
- LCP: server-render the hero img with srcset and sizes,
fetchpriority="high", width and height; the gallery component hydrates around it. Load delay goes to ~0; expected LCP ≈ 1.6 s in the same profile. - INP input delay: load recommendations after the first interaction or on idle (
requestIdleCallbackwith a timeout), and chunk its processing withscheduler.yield(). - INP processing: the handler updates the cart badge and button state, then
startTransitionfor the store update that re-renders the rest; the sticky-header hook moves its layout read into a ResizeObserver so it no longer forces a reflow inside the update. - Verify in lab: re-record; INP 90 ms, LCP 1.5 s in the same profile.
- Guard: add a budget assertion for the template (TBT ≤ 200 ms, LCP ≤ 2.5 s under the profile) and a size-limit on the critical bundle.
- Verify in field: after 28 days, CrUX p75 INP 180 ms, LCP 2.2 s. The RUM dashboard shows the change the day it shipped.
- Route
/perf/lcp-phases: four variants of a hero with the LCP phases dominated by each cause. Record each; match the phase to the cause before reading the source. - Route
/perf/inp-phases: a product page with an add-to-cart that has all three problems. Fix them one at a time with the provided toggles; watch the Interactions bar shrink. - Route
/perf/cls-hunt: five shift sources. Use the Layout Shifts lane to attribute each. - Route
/perf/rum: a page wired to web-vitals attribution that logs to a table. Interact, navigate back (bfcache), and read what was reported for each.