Skip to content

6.7 — Frontend Performance

A team ships a redesign. The audit tool gives it 98 out of 100. Three weeks later, support tickets say the site "feels slow on my phone", and the analytics show checkout completion down four per cent.

Both facts are true at the same time. The audit ran on a fast machine, on a fast network, with an empty cache, no browser extensions and no third-party consent banner. Real users are on a four-year-old Android on a train, with a cache that may or may not help and an advertising script the marketing team added last week.

Performance work starts by measuring the second thing, not the first. This page is about what to measure, what each number is actually made of, and the fixes in the order of how much they typically buy.

1. The three metrics that are scored

Three Core Web Vitals are what search engines report and what most teams are held to. Each one is deliberately about something a user can perceive.

MetricQuestion it asksGoodPoor above
LCPDid the main content appear?≤ 2.5 s4.0 s
INPDid the page respond when I touched it?≤ 200 ms500 ms
CLSDid things move while I was reading?≤ 0.10.25

The thresholds are measured at the 75th percentile of real page loads. That detail matters more than the numbers. It means three quarters of your users must be under the threshold, so the slow quarter — old phones, poor connections, the users you never see — decides your score. Optimising the median and ignoring the tail moves nothing.

Largest Contentful Paint

LCP is the time until the largest image or text block in the viewport has rendered. It stands in for "did the page look ready".

The useful part is that it decomposes into four pieces, and every real fix targets one of them:

Time to first byte. The server's thinking time plus the network round trips (Chapters 5.4 and 5.6). If this is 800 ms, LCP cannot be under 800 ms whatever you do to the front end.

Resource load delay. The gap between the response arriving and the browser starting to fetch the LCP resource. This is the piece people miss, and it is usually self-inflicted: the image is discovered late because it was inserted by JavaScript, or it is behind a lazy-loading attribute, or it is fourth in a queue of preloads.

Resource load time. Actually downloading it. Fixed by making it smaller and serving it closer.

Element render delay. It arrived but is not painted yet — usually because the main thread is busy running JavaScript, or a font has not resolved.

time to first byteresource load delayresource load timerender delayCDN, cache,faster server querydiscovered late:lazy-loaded, set by JS,or low priorityAVIF/WebP, correctsize, compressionbusy main threador unresolved fontLCP — the whole barthe piece people forget is the second one, and it is usually the biggest
Four separate problems wearing one number. Measure the split before choosing a fix.

Find out which piece dominates before doing anything. A team that spends a week compressing images when 70% of their LCP is time to first byte has optimised the wrong thing, and this happens constantly.

Interaction to Next Paint

INP replaced First Input Delay in 2024, and the change was a real improvement. First Input Delay measured only how long the first interaction waited before its handler started — so a page could score perfectly while every subsequent click took a second to do anything.

INP measures the whole interaction, and reports roughly the worst one of the visit. Three parts:

Input delay — the main thread was busy, so your handler could not start. This is Chapter 6.1.1's rule showing up as a number.

Processing time — your handler running.

Presentation delay — the time from the handler finishing to the next frame actually appearing on screen, which includes style, layout, paint and composite (Chapter 6.1.2).

The third part is the one that surprises people. A handler that takes 5 ms but triggers a full-page re-layout produces a bad INP, and the profiler shows almost nothing in your code.

A hydration gap shows up here directly — the Chapter 6.5.1 case where the page is visible but not interactive, so an early tap sits in input delay for a second.

Cumulative Layout Shift

CLS measures content moving unexpectedly. Each shift scores as impact fraction (how much of the viewport moved) × distance fraction (how far), and the reported value is the worst 5-second window of shifts, not the total for the page.

Shifts within 500 ms of a user interaction are excluded, which is the rule that makes it usable: expanding an accordion moves things, and that is fine because the user asked for it. Content jumping while they are reading is not.

The causes are a short and repeatable list:

  • Images and videos with no dimensions (Chapter 6.2.1).
  • Advertisements, embeds and iframes with no reserved space.
  • A web font swapping in at a different size to the fallback.
  • Anything injected above existing content — a cookie banner, a promotional bar, an error message.
  • A skeleton that is a different size to what replaces it (Chapter 6.4.4).

2. Measuring: field first, lab second

Field data (also called real user monitoring) is collected from actual visits. It is the truth, and it is the only thing scored.

js
// The web-vitals library reports each metric when it is final.
import { onLCP, onINP, onCLS } from 'web-vitals';

const send = (metric) => {
  navigator.sendBeacon('/api/vitals', JSON.stringify({   // (1)
    name: metric.name,
    value: metric.value,
    rating: metric.rating,                                // (2) good | needs-improvement | poor
    id: metric.id,
    attribution: metric.attribution,                      // (3) WHICH element
    connection: navigator.connection?.effectiveType,      // (4)
  }));
};

onLCP(send); onINP(send); onCLS(send);

Line (1) uses sendBeacon (Chapter 6.3.3) so the report survives the user navigating away — important because INP and CLS are only final when the page is unloaded.

Line (3) is the field that turns a number into an action. Attribution tells you which element was the largest paint, which interaction was the slowest, and which node shifted. Without it you have a bad score and nowhere to look.

Line (4) is why you should segment. An aggregate INP of 240 ms might be 120 ms on desktop and 700 ms on mobile, and only the second one is a problem worth a sprint.

Underneath, the library uses PerformanceObserver, the same observer family as Chapter 6.3.2:

js
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) console.log('long task', entry.duration, entry.attribution);
  }
}).observe({ type: 'longtask', buffered: true });

A long task is anything occupying the main thread for over 50 ms. During one, nothing responds. Counting and attributing long tasks is the most direct diagnostic for INP there is.

Lab data — an audit tool run on demand — is still valuable, for two things field data cannot do: catching a regression before it ships, and giving a repeatable number to compare two builds. Run it in continuous integration with a performance budget so a pull request that adds 80 KB to the entry bundle fails, rather than being discovered three months later.

Lab's proxy for INP is Total Blocking Time — the sum of everything over 50 ms in each long task during load. It correlates well enough to be a useful gate.

3. The ladder, in order of payoff

Fixes are worth roughly what they cost the user, and the order below is close to universal.

Rung 1: do not send it

The cheapest byte is the one you never send, and this rung routinely beats everything below it combined.

Run the bundle analyser first (Chapter 6.6). The usual finds: a date library shipped with every locale (400 KB when you use one), an icon set imported as a whole, a charting library on a page with no chart, moment where Intl.DateTimeFormat would do, a polyfill bundle for browsers you no longer support.

Audit third-party scripts hard, because they are usually the largest single cost and the least examined. A tag manager, three analytics tools, a chat widget, a heat-map recorder and a consent banner can easily exceed your entire application. Each one runs on your main thread and each one competes for bandwidth with your content. The question for each is: what does this earn, and can it be loaded after the page is interactive?

Tighten browserslist. One line, and every transpiled polyfill for a browser nobody uses disappears.

Rung 2: send it later

Route-based code splitting first, then heavy components behind an interaction (Chapter 6.6).

Defer third-party scripts until after the page is usable — on idle, on first interaction, or when the widget scrolls into view. A chat widget that loads when the user scrolls to the footer serves the same purpose and costs nothing during load.

Lazy-load below-the-fold images with loading="lazy" — and never on the LCP image, which is the single most common self-inflicted LCP problem there is.

Rung 3: send it sooner

Fix time to first byte. Cache pages at a CDN (Chapters 5.6.2 and 6.5.1), and if the page is server-rendered, look at the server work — a slow database query is a frontend performance problem when it is between the user and the first byte.

Make the LCP element discoverable early. The preload scanner (Chapter 6.1.2) finds URLs in the HTML; it cannot find one your JavaScript will construct.

html
<!-- Highest priority, explicitly, for the hero image. -->
<img src="/hero.avif" fetchpriority="high" width="1600" height="900" alt="…">

<!-- Or preload it if it is set from CSS or JS. -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">

fetchpriority="high" tells the browser this is the one that matters, moving it ahead of the twelve other images it discovered at the same time.

Preconnect to origins you will definitely use (Chapter 6.3.2), and no more than a few.

Rung 4: make it cheaper

Images are usually the largest bytes on a page. Three changes, in order:

Format. AVIF is roughly 50% smaller than JPEG at equal quality, WebP about 30%. Serve with <picture> fallbacks (Chapter 6.2.5) or let an image CDN negotiate from the Accept header.

Dimensions. Serving a 3000-pixel image into a 400-pixel slot wastes 90% of the bytes. srcset and sizes, with sizes correct — a wrong sizes silently downloads the wrong file.

Quality. Most photographs are indistinguishable at quality 75–80 and half the size of quality 95.

Fonts are the second-largest and the most commonly mishandled.

css
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');   /* (1) */
  font-display: swap;                                              /* (2) */
  unicode-range: U+0000-00FF;                                      /* (3) */
  size-adjust: 107%;                                               /* (4) */
  ascent-override: 90%;
}

Line (1): WOFF2 only. Every browser in use supports it, and shipping WOFF or TTF alongside is dead weight. A variable font replaces four or five weight files with one.

Line (2) font-display decides what happens during the load, and the three behaviours have names worth knowing. The default, auto, usually behaves like block: invisible text for up to three seconds — a flash of invisible text, and the user reads nothing. swap shows the fallback immediately and swaps when the font arrives — a flash of unstyled text, which is visible but at least readable, and it causes layout shift. optional gives a very short window and otherwise keeps the fallback for this page load entirely, which produces no shift at all.

swap for body text where readability wins; optional when CLS matters more than exact typography.

Line (3) unicode-range subsets the font. Most sites need Latin, and shipping every Cyrillic and Greek glyph is a large avoidable download.

Line (4) is the fix for swap's layout shift, and it is underused. size-adjust, ascent-override and descent-override scale the fallback font so it occupies the same space as the real one. The swap then happens with no reflow, and you get swap's readability with optional's stability. Tools exist to compute the numbers for a given pair.

Also: self-host your fonts. A third-party font host means an extra DNS lookup, connection and TLS handshake (Chapter 5.7) before the first byte of the font, and browsers no longer share font caches between sites, so the old argument for a shared CDN copy is gone.

Critical CSS. The stylesheet is render-blocking (Chapter 6.1.2). Inlining the rules needed for the visible part of the page and loading the rest asynchronously removes a round trip from the critical path. The honest caveat: the extracted CSS must be regenerated whenever the design changes, or you ship stale inline rules that fight the real stylesheet — so automate it in the build or do not do it.

Compression is Chapter 5.6.2: Brotli for text, static compression at the highest level at build time since you pay the cost once.

Rung 5: make the main thread cheaper

This rung is where INP lives, and it is Chapter 6.1.1's sentence again: one thread, one thing at a time.

Break up long tasks. A 400 ms function blocks every interaction for 400 ms. Split the work and yield between pieces:

ts
async function processAll(items: Item[]) {
  for (const [i, item] of items.entries()) {
    process(item);
    // (1) Every 50 items, give the browser a chance to respond.
    if (i % 50 === 0) await yieldToMain();
  }
}

function yieldToMain(): Promise<void> {
  // (2) scheduler.yield resumes at the FRONT of the queue where available.
  if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
    return (globalThis as any).scheduler.yield();
  }
  // (3) Fallback: a task boundary. Your work goes to the BACK of the queue.
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Line (2) is the modern API and it is better than the fallback for a specific reason. setTimeout puts your continuation at the back of the task queue, behind everything else that has arrived — so on a busy page, yielding repeatedly can make the total work take much longer. scheduler.yield lets urgent work in and then resumes your task with priority, so you get responsiveness without the starvation.

Give feedback before doing the work. If an interaction is genuinely expensive, paint the pending state first, yield, then compute. The measured INP improves, and — more to the point — the user knows the tap registered:

ts
button.addEventListener('click', async () => {
  setBusy(true);                    // (1) cheap state change
  await yieldToMain();              // (2) let the browser paint it
  const result = expensiveWork();   // (3) now do the work
  render(result);
});

Debounce and throttle, and know which is which. Debounce waits until the input stops — right for a search box, where you only want the final query. Throttle runs at most once per interval — right for scroll or resize, where you want regular updates but not hundreds per second. Using debounce for scroll means nothing happens until scrolling stops, which is a bug that looks like a freeze.

Move real computation to a worker (Chapter 6.3.3).

Avoid layout thrashing (Chapter 6.1.2) — the read-write loop that turns one layout into two hundred.

Animate only transform and opacity so the compositor handles it without the main thread.

Reduce hydration work (Chapter 6.5.1) — fewer client components, more static content.

And the CLS fixes, which are almost all prevention

Dimensions on every image and video, or an aspect-ratio in CSS.

Reserve space for anything that arrives late — advertisements, embeds, banners — with a min-height matching the eventual content.

Never insert content above existing content once the page is visible. A cookie banner belongs in a fixed overlay, or its space must be reserved from the first paint.

Match font metrics with the override properties above.

Make skeletons the same size as their content.

Animate with transform — a layout-property animation registers as continuous layout shift.

4. The discipline

Three habits, and they matter more than any individual fix.

Measure first, and measure the right thing. Field data at the 75th percentile, segmented by device and connection. A lab score is a regression check, not a report on your users.

Fix the biggest contributor, then re-measure. Performance work has a strong tendency to become a list of well-known tips applied in a fixed order. The decomposition of LCP and INP exists so you can find where your time is going, which is frequently not where the tips assume.

Put a budget in continuous integration so improvements stay. Bundle size in kilobytes, a Total Blocking Time ceiling, and a Lighthouse threshold, all failing the build. Every performance project that was not defended by a budget has been quietly undone within a year.

What the interviewer will push on

"What are the Core Web Vitals and what does each measure?" LCP for perceived load, INP for responsiveness, CLS for visual stability. The tell is knowing they are 75th-percentile field measurements — the tail decides your score, so optimising the median moves nothing.

"Why did INP replace First Input Delay?" FID only measured the delay before the first handler started, so a page could score perfectly while every later interaction took a second. INP measures input delay plus processing plus presentation delay, across the visit. Naming presentation delay is what shows you have looked at real traces.

"Our LCP is 4 seconds. What do you look at first?" Break it into the four parts — time to first byte, resource load delay, load time, render delay — and find which dominates. Only then choose a fix. Jumping straight to "compress the images" is the answer that has cost teams weeks.

"Give me three causes of layout shift." Missing image dimensions, unreserved space for late content, font swap without metric overrides, content injected above the fold. Volunteer that shifts within 500 ms of an interaction are excluded, which is why an accordion is fine.

"How do you fix a 400 ms task?" Split it and yield, preferring scheduler.yield because setTimeout puts your continuation at the back of the queue and can make everything take longer. Or move it to a worker. And paint the pending state before yielding so the user sees the tap register.

"Debounce or throttle for scroll?" Throttle. Debounce means nothing happens until scrolling stops, which reads as a freeze. Getting this backwards is a common tell.

"font-display: swap or optional?" swap for readable body text, accepting the shift; optional when stability matters more. Then volunteer size-adjust and the ascent/descent overrides, which give you both — that is the answer most candidates do not have.

One thing to volunteer: point out that third-party scripts are usually the largest single performance cost and the least audited, and that the useful question is not "can we make it faster" but "what does this earn, and can it load after the page is interactive". It moves the conversation from micro-optimisation to the thing that actually decides the number.

Recall

  • LCP ≤ 2.5 s · INP ≤ 200 ms · CLS ≤ 0.1, all at the 75th percentile of real page loads — the slow tail decides your score.
  • LCP decomposes into four parts: time to first byte, resource load delay, load time, render delay. Find which dominates before fixing anything.
  • INP replaced FID because FID measured only the first interaction's delay. INP = input delay + processing + presentation delay, so a cheap handler that triggers a full relayout still scores badly.
  • CLS is the worst 5-second window, and shifts within 500 ms of an interaction are excluded. Causes: missing dimensions, unreserved late content, font swap, content injected above the fold.
  • Field data is the truth; lab data is a regression gate. Report with sendBeacon, keep the attribution so you know which element, and segment by device and connection. A long task is over 50 ms and is the direct diagnostic for INP.
  • The ladder: do not send it (analyse the bundle; audit third-party scripts, usually the biggest cost) → send it later (route splitting, defer third parties, lazy-load — never the LCP image) → send it sooner (CDN, fetchpriority="high", preload, preconnect) → make it cheaper (AVIF/WebP, correct sizes, WOFF2-only variable fonts, subsetting, Brotli, critical CSS) → make the main thread cheaper.
  • font-display: block gives invisible text, swap gives readable text plus a shift, optional keeps the fallback and shifts nothing. size-adjust and the ascent/descent overrides give you swap with no shift. Self-host fonts — there is no shared cross-site font cache any more.
  • Break long tasks and prefer scheduler.yield: setTimeout puts your continuation at the back of the queue. Paint the pending state before yielding so the tap registers.
  • Debounce for input, throttle for scroll. Debouncing scroll reads as a freeze.
  • Defend the work with a budget in CI — bundle size, Total Blocking Time, a Lighthouse floor — or it gets undone within a year.

Self-test: Why can a page score 98 in the lab and be slow for users? · Which four things make up LCP? · What does INP include that FID did not? · Why is loading="lazy" on the hero image a bug? · Why is scheduler.yield better than setTimeout(0)? · How do you get readable text and no font-swap shift?

Next: 6.8.1 moves from measurement to construction — how a design system is actually built so that a hundred developers produce a consistent interface, and the handful of layout shapes that almost every application turns out to be.