gap·map

Loading performance

[ middle depth ]

Core Web Vitals thresholds and how they are measured

LCP ≤ 2.5s (main content painted), INP ≤ 200ms (worst interaction latency), CLS ≤ 0.1 (unexpected movement), each measured at the 75th percentile of real page visits. That last clause is the part people miss. Core Web Vitals is a field verdict from CrUX (real Chrome users, ~28 days). Lighthouse is one synthetic run on one throttling profile.

❌ "Lighthouse is green, so we pass". A lab run can't represent p75 of mid-range phones on 4G. Lab is your controlled experiment for reproducing and fixing; field is the assessment. When they disagree, and they routinely do, believe the field number.

What moves each metric. LCP: deliver the hero resource sooner (everything below). CLS: reserve space (dimensions, aspect-ratio, min-height for embeds, font fallback metrics); speed doesn't fix it. INP: less main-thread work at interaction time, which is where code splitting connects; the deep dive lives in the rendering-performance topic.

How to diagnose a slow LCP

LCP decomposes into TTFB → resource load delay → resource load duration → element render delay. Healthy shares: ~40% / <10% / ~40% / <10%. Every fix moves exactly one segment, so find the segment first:

  • TTFB. Redirects, CDN misses, slow HTML. (Caching as a lever: see http-caching.)
  • Load delay. The browser knew about the page but not the image yet: hero absent from the initial HTML, or worse, loading="lazy" on it.
  • Load duration. Bytes: oversized, wrong format, no image CDN.
  • Render delay. The resource arrived but couldn't paint: blocking CSS/fonts/scripts (mechanics live in critical-rendering-path).

❌ Proposing fixes before knowing the segment ("add a CDN", "shrink the bundle"). Interviewers listen for whether you diagnose before you prescribe.

How to speed up the LCP image

Images start at low network priority; the browser boosts one only after layout proves it is in the viewport. A well-set-up hero image:

<img src="hero-800.avif"
     srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w"
     sizes="(max-width: 800px) 100vw, 800px"
     width="800" height="450" fetchpriority="high" alt="…" />
  • Never lazy-load the LCP image. A lazy image isn't requested until layout confirms it is visible, pure load delay on your most important resource. loading="lazy" is for below-the-fold images only.
  • fetchpriority="high" skips the wait-for-layout boost. Google Flights took LCP from 2.6s to 1.9s with essentially this attribute.
  • srcset + sizes: the browser picks a candidate for the slot width. ❌ Omit sizes and it assumes 100vw; phones download desktop images and your srcset did nothing.
  • width/height (or aspect-ratio) on every image covers the CLS half.
  • Preload is for what the scanner can't see: CSS background heroes, fonts. Preloading discoverable <img>s taxes the critical path.

Which font-display value to pick

font-display chooses between invisible text and a visible swap: block (hide ~3s), swap (fallback immediately, swap whenever), fallback (~100ms block, ~3s swap window), optional (~100ms, then this pageview keeps the fallback). ❌ "swap is free". The swap is a layout shift, because fonts differ in metrics. Either metric-match the fallback:

@font-face { font-family: "Inter Fallback"; src: local("Arial"); size-adjust: 107%; }
body { font-family: Inter, "Inter Fallback", sans-serif; }

…or use optional and accept system fonts on first visit. Fonts hide inside CSS where the preload scanner can't see them, so preload the WOFF2 (with crossorigin, even same-origin) or preconnect the font host. Ship WOFF2 only; subset to the scripts you use.

Code splitting with React.lazy

Every import() becomes a chunk your bundler loads on demand; the natural boundary is the route, since nobody needs the settings page to render the dashboard:

const Settings = lazy(() => import("./Settings")); // module top level; default export
<Suspense fallback={<Skeleton />}><Settings /></Suspense>

Two mechanics that bite. lazy() inside a component recreates the component every render: state resets, Suspense refires. And import() resolves to the module object, so named-export components need a .then(m => ({ default: m.Settings })) shim.

❌ "Split everything". Tiny chunks pay per-request overhead, compress worse, and chain into waterfalls (chunk A starts loading, discovers it needs chunk B…). Split routes and genuinely deferred features (modals, editors, charts); leave the shared core alone. What splitting buys is less startup JS to parse and execute, which shows up in INP and time to interactive, not in LCP.

[ senior depth ]

Performance budgets and how to enforce them

A one-off performance sprint decays in a quarter; a budget enforced in CI doesn't. Three budget types exist, use all of them: quantity (KB of JS, request count, image weight), milestone (LCP, TTFB), rule-based (Lighthouse category score as a smoke alarm). Budget per page type, because a product page and a checkout have different jobs, and wire the gates where regressions happen:

  • a bundle-size check on every PR (bundlesize, size-limit, webpack performance hints) catches the accidental 300 KB dependency at review time;
  • Lighthouse CI against preview deploys, with explicit assertions instead of eyeballed scores;
  • RUM alerting for what CI can't see: third-party drift, CDN config, real devices.

A budget is a decision rule. When a feature busts it, someone must optimize, cut something else, or say no. A budget nobody has ever said no with is a dashboard.

Deciding what ships first, later, or never

Point optimizations matter less than the ordering decision: what ships first, what ships later, what ships never.

  • First. The critical path for the current view: HTML, critical CSS, hero media, the JS that makes visible things work. A useful reference floor: ~170 KB compressed of critical-path resources still describes the median phone honestly.
  • Later. Everything reachable but not visible: routes behind navigation, below-fold media, analytics. import(), loading="lazy", deferred init.
  • Never (until interaction). The heaviest things on most pages are embeds. A YouTube iframe is megabytes; a facade (static poster + click-to-load) is ~99% cheaper, and most users never click. Same pattern for maps, chat widgets, video players.

Deleting a resource beats optimizing it, and caching (http-caching) beats both on repeat views.

Prefetching and prerendering with Speculation Rules

Prefetching spends bandwidth and server load on navigations that may never happen, in exchange for latency when they do. Speculation rules do this properly, unlike legacy <link rel="prefetch"> (which only warmed the HTTP cache and choked on uncacheable documents):

<script type="speculationrules">
{ "prerender": [{ "where": { "href_matches": "/product/*" }, "eagerness": "moderate" }] }
</script>

prefetch fetches the document; prerender fully renders it in a hidden tab, so activation is near-instant. eagerness prices the bet: moderate (hover/pointerdown) is the sane default, while immediate on a list of links is a bet you'll usually lose. Chrome caps speculations and backs off on Save-Data and low battery. Two costs people forget: analytics fire in prerendered pages unless you gate on prerenderingchange, and your servers see traffic for pages nobody visits.

Third-party scripts are a governance problem

Tag managers make script addition a marketing action with engineering costs. Treat it organizationally. Keep an inventory with an owner and a purpose per script. Review weight and impact before anything enters the tag manager, and re-audit quarterly, because scripts outlive their campaigns. Contain what survives: async by default, facades for embeds, self-host what you can. The honest argument to the business is A/B data: measure conversion with and without the script, and let the owner defend the delta.

Measuring real users with RUM

CrUX tells you that p75 is bad; it is Chrome-only, windowed over ~28 days, and often origin-level, too coarse to debug. RUM with the web-vitals attribution build tells you why: which element was LCP and which sub-part dominated, what shifted for CLS, which script blocked the INP interaction. Report percentiles, never averages (an average LCP describes no real user). Segment by device, geo, and connection, since a global p75 hides "fine on desktop, broken on Android/3G". Send via sendBeacon on visibilitychange. For before/after claims, version your deploys in the RUM payload; for causal claims, A/B with server-assigned groups.

Making performance stick

Performance survives prioritization meetings only as a product requirement. Put thresholds in the definition of done next to accessibility. Give the RUM dashboard to the whole team, because a dashboard owned by one enthusiast dies when they leave. File regressions as bugs with severity; backlog wishes never get scheduled. And pair the numbers with business data: rerun the retailer's "each 100ms costs X% conversion" study for your own funnel, so the case for performance work is stated in revenue.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Web Performance