cf.completefrontend
My progress
SYSTEM DESIGN · #131

Design a plan to make a slow e-commerce site fast

A quarter-long plan to make slow product pages fast: measure real users first, take one slow page apart metric by metric, rank the fixes by impact, effort and risk, prove the gain with an experiment, and build the guardrails that stop the site from getting slow again.

The question

Our product pages are slow and conversion is dropping. You have a quarter. What do you do?

We are a mid-sized online store: about 2 million product pages, most traffic from phones, a React app with server rendering, a tag manager full of marketing scripts, and no real performance monitoring yet.

Real-life examples: Vodafone product pages, Zalando, Walmart, Amazon product detail page, Shopify storefronts

Requirements: turning "the site is slow" into a problem you can solve

This question is different from most system design questions. Nobody asks you to build a new component. You get a running site, a business problem (conversion is dropping), a vague symptom (pages are slow), and a fixed amount of time (one quarter, about 13 weeks). The interviewer wants to see a plan and an architecture: how you find out what is slow, how you decide what to fix first, what the product page looks like after the work, and how you keep it fast.

A weak answer is a list of tips: compress images, lazy load, use a CDN, split the bundle. Each tip may be right, but the list does not say which one matters for this site, in which order, or how you would know it worked.

The performance lessons (Core Web Vitals, Loading strategies, Images and media, Web fonts, Code splitting and bundling, Runtime performance, Performance budgets and monitoring, Real-user monitoring, Compression and minification) teach each technique, and this guide links to them instead of teaching them again. It spends its time on diagnosis, prioritization and the plan, and follows one slow product page from first measurement to the last guardrail.

The questions to ask first

Each question below changes the plan. If the interviewer has no answer, state an assumption and move on.

Seven questions, each with the assumption this guide uses.
QuestionAssumed answerWhy it changes the plan
How do we know it is slow? Field data, a Lighthouse score, or complaints?A Lighthouse score of 31 and a feeling. No field data.No field data means week 1 is setting up measurement. You cannot rank fixes without it.
Which pages make the money?Product pages: 60% of landing traffic, the step before add to cart.Focus the quarter on one page type instead of spreading thin.
Who are the users? Devices, countries, networks?About 70% phones, many mid-range Android, strong in markets with slower 4G.Fixes that help a slow CPU (less JavaScript) matter more than for a desktop audience.
What is the stack?React with server rendering on Node, a CDN that caches only static files, a tag manager.Uncached HTML is a large, cheap win. The tag manager is a likely INP problem.
How personalized is the product page?Logged-in users see their price tier, cart count, and recommendations.Personalization is what stops the HTML from being cached. It has to move.
Can we run A/B tests?Yes, there is an experiment platform for product changes.We can prove that speed moved conversion, not just guess.
How many engineers and how many teams touch the page?Our team of 4, plus 5 other teams who own parts of the page and marketing who owns tags.The plan needs ownership and a process for tags, not only code changes.

What the plan has to deliver

  • Measurement: Core Web Vitals from real users, split by page type, device class, country and connection, visible on a dashboard within a day.
  • Diagnosis: for the worst page type, an explanation of each slow metric down to its parts (for example, how much of LCP is server time and how much is image download).
  • A ranked list of fixes: each with expected impact on a metric, effort in engineer weeks, and risk.
  • A shipped set of fixes: the top of that list, rolled out behind feature flags (switches that turn a change on for a chosen share of users) so each can be measured and rolled back.
  • Proof: a controlled experiment that links the speed change to conversion.
  • Guardrails: budgets in CI, alerts on field regressions, and named owners, so the site stays fast after the quarter ends.

Targets with numbers

Core Web Vitals give us the targets. A page passes when the 75th percentile (p75) of real page loads meets all three thresholds. p75 means three out of four visits are at least this fast. The thresholds below are the ones Google publishes for the Core Web Vitals.

The three Core Web Vitals and their thresholds. The Core Web Vitals lesson explains each metric in depth.
MetricWhat it measuresGood (p75)Poor (p75)Our goal for phones on product pages
LCP (Largest Contentful Paint)When the biggest image or text block in the viewport is painted2.5 s or lessover 4 s2.5 s or less
INP (Interaction to Next Paint)How long the page takes to paint after a tap, click or key press, near the worst interaction of the visit200 ms or lessover 500 ms200 ms or less
CLS (Cumulative Layout Shift)How much visible content jumps around unexpectedly0.1 or lessover 0.250.1 or less

There are also targets that are not metrics. The plan must not break SEO (product pages get search traffic), must not hurt accessibility, and must not stop marketing from running campaigns. A plan that removes every marketing tag would make the page fast and get cancelled in week 3.

Out of scope

  • A full rewrite or a framework migration. A quarter is too short, and a rewrite ships nothing measurable until the end. We change the existing app step by step.
  • Checkout and search pages. They matter, but the plan focuses on one page type and applies the same method to others next quarter.
  • Backend database tuning. We treat the origin as a black box and measure its time to first byte (TTFB). If server time is the problem, we fix it by caching in front of it, and hand deeper work to the backend team.
  • The native apps. Same business, different tools.

Check your understanding · Exercise 1

Which of these answers would change the first two weeks of the plan the most? Select all that apply.

What makes a performance overhaul hard

Making one page fast on your own laptop is easy. Making a real store fast for real users, and keeping it fast, is hard for five reasons. Each one shapes a later chapter.

  1. The slow users are not you. Developers test on fast laptops on office Wi-Fi. The users who are slow are on a 200 dollar Android phone on a busy 4G cell in another country. A phone like that runs JavaScript several times slower than a recent laptop (Lighthouse's mobile test simulates a CPU 4 times slower than the machine it runs on, for this reason). If you only test locally, you fix the wrong things. So we measure the field first.
  2. Speed is a distribution, not a number. One visit is fast (cached, near the server), another is slow (first visit, far away, old phone). The goal is p75, and p75 can hide a terrible p95 in one country. So we segment every number.
  3. Each metric has several causes, in a chain. A slow LCP can come from the server, from the image being found late, from the image being too big, or from CSS that blocks painting. Fixing the wrong link in the chain gives nothing. So we break each metric into its parts before choosing fixes.
  4. Many people make the page slow, and only some of them are engineers. Five teams own parts of the product page. Marketing adds tags through a tag manager without a deploy. A fast page in week 13 can be slow again in week 20. So the plan includes ownership and process, not only code.
  5. Business people want proof in money. "LCP went from 4.6 s to 2.2 s" is not the same as "conversion went up". Seasonality, campaigns and product changes move conversion too. So we prove impact with a controlled experiment, not a before and after chart.

There is also a time problem. Field data such as Chrome's public dataset uses a rolling 28-day window, so a fix shipped today shows fully in that data only four weeks later. Our own monitoring can show it the next day. That is one more reason to set up our own measurement before shipping any fix.

Think first

Your manager says: "Just get the Lighthouse score above 90 and we are done." What is wrong with that goal?

Step 1: measure real users before fixing anything

The naive first move is to open the product page in Chrome DevTools, run Lighthouse, and start fixing whatever it lists. It feels productive, and it goes wrong in a predictable way. Lighthouse tests one load, on one simulated device, with an empty cache and no logged-in user.

Our case shows the trap. Lighthouse flags about 150 KB of unused CSS as the top opportunity, the team spends two weeks on it, and real users see no change. For real users the biggest delays were the uncached HTML and a hero image that JavaScript inserts late. Lighthouse never saw the HTML cost of a logged-in user, and a page-load test cannot measure INP at all.

So the first week goes to measurement. There are three sources of data, and a good plan uses each for what it is good at.

Decision Where do our performance numbers come from?

Lab data (Lighthouse, WebPageTest, DevTools)

A tool loads the page in a controlled setup: a chosen device, network throttling, empty cache.

It records a full trace: every request, every long task, a filmstrip of what painted when.

Pros

  • Repeatable: the same page gives similar numbers, so you can compare before and after a change.
  • Deep detail for debugging: waterfalls, main-thread flame charts.
  • Works before code ships, so it fits CI.

Cons

  • Not real users: one device, one network, no personalization, no real interactions.
  • No INP in a page-load run (INP needs interactions); Total Blocking Time is only a rough lab stand-in.
  • Easy to optimize for the score instead of for users.

Public field data (CrUX: the Chrome UX Report)

Chrome collects Core Web Vitals from users who opted in (usage statistics on, browsing history synced), on desktop and Android, and Google publishes the p75 per origin and per popular URL.

You read it in PageSpeed Insights, Search Console, the CrUX API, or the monthly BigQuery dataset.

The CrUX API and PageSpeed Insights show a rolling 28-day window; BigQuery has one dataset per calendar month. Both split by form factor (phone, desktop, tablet).

Pros

  • Free and already there, even if we never added any code.
  • It is the data Google uses for the page experience signals in search.
  • Good for comparing with competitors, since every public site has it.

Cons

  • Chrome only (not Chrome on iOS), and only users who opted in.
  • Slow: 28 days to fully reflect a fix.
  • Coarse: no page types, no logged-in state, no reason why a metric is slow, and low-traffic URLs fall back to origin-level data.

Our own RUM (real-user monitoring)

A small script on every page measures LCP, INP, CLS and TTFB with the web-vitals library and sends them to our backend.

The attribution build adds the reason: the LCP element and its subparts, the element tapped for INP, the element that shifted for CLS.

We add our own dimensions: page type, device class, country, connection, logged-in, experiment group.

Pros

  • Every page view, segmented any way we need, the next day or faster.
  • Explains why a metric is slow, from real visits.
  • Joins with business data: we can compare conversion for fast and slow visits, and per experiment group.

Cons

  • Must be built or bought, and it costs a few KB of script plus a beacon per page view.
  • Needs care with sampling, privacy and bot traffic.
  • Some APIs are still Chromium-only (CLS and Long Animation Frames), so Safari and Firefox users are partly invisible.

What we pick: Use all three, each for its job. RUM is the source of truth for goals, prioritization and proof, so we set it up in week 1. CrUX is the outside check and the search view, and it gives us a baseline on day one while our own RUM collects its first week. Lab tools are for debugging one cause at a time and for guarding CI. Never set a goal in lab numbers.

Setting up RUM when there is none

The web-vitals library from the Chrome team wraps the browser's performance APIs and handles the tricky parts: reporting when the page is hidden, back and forward cache restores, and prerendered pages. Its attribution build is a few KB larger and adds the debugging fields we need. The Real-user monitoring lesson covers the library in depth, and the analytics-sdk guide covers batching and sending beacons reliably. Here is the part that matters for this plan: what we record.

ts
import { onLCP, onINP, onCLS, onTTFB, type MetricWithAttribution } from "web-vitals/attribution";

const page = document.body.dataset; // data attributes written by the server
const context = {
  pageType: page.pageType,           // "product", "category", ...
  build: page.build,                 // which release is live
  loggedIn: page.loggedIn === "true",
  experiments: JSON.parse(page.experiments ?? "{}"), // { "perf-q3": "treatment" }
  deviceMemory: (navigator as any).deviceMemory ?? null,              // Chromium only
  cores: navigator.hardwareConcurrency ?? null,
  connection: (navigator as any).connection?.effectiveType ?? null, // Chromium only
};

const queue: object[] = [];
const add = (m: MetricWithAttribution, attribution: Record<string, unknown>) =>
  queue.push({ name: m.name, value: m.value, rating: m.rating, navType: m.navigationType, attribution });

onLCP((m) => add(m, { target: m.attribution.target, url: m.attribution.url,
  ttfb: m.attribution.timeToFirstByte, loadDelay: m.attribution.resourceLoadDelay,
  loadTime: m.attribution.resourceLoadDuration, renderDelay: m.attribution.elementRenderDelay }));
onINP((m) => add(m, { target: m.attribution.interactionTarget, loadState: m.attribution.loadState,
  inputDelay: m.attribution.inputDelay, processing: m.attribution.processingDuration,
  presentation: m.attribution.presentationDelay, script: m.attribution.longestScript?.entry.sourceURL }));
onCLS((m) => add(m, { target: m.attribution.largestShiftTarget }));
onTTFB((m) => add(m, {}));

// Usually one beacon per page view: flush the queue whenever the page is hidden.
addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden" || queue.length === 0) return;
  navigator.sendBeacon("/rum", JSON.stringify({ ...context, metrics: queue.splice(0) }));
});
Collecting Core Web Vitals with reasons and our own dimensions (web-vitals 6, attribution build), sent as one batch when the page is hidden.

Three details make this data useful later. First, the page type comes from the server, so we can compare product pages with category pages without parsing URLs. Second, we record the experiment groups the visitor is in, which is how we will prove impact in a later chapter. Third, we keep the subparts, not just the total, because the total tells you that LCP is slow and the subparts tell you why.

The measurement pipeline. In the browser of real users, the web-vitals attribution build measures LCP, INP and CLS with their reasons and sends them in one beacon, when the page is hidden, to a RUM collector at the edge, which writes events to an event store. A daily p75 job computes percentiles per page type and device and feeds a dashboard, which drives alerts. Below, outside sources: CrUX with 28 days of Chrome users, and Lighthouse lab runs with one simulated load. They are for checks and debugging only.Browser of real usersweb-vitalsLCP, INP, CLS + reasonsbeaconRUM collectorPOST /rumeventsp75 jobpage type x devicedashboardsegmentsalertsp75 regressionsOutside sourcesCrUXChrome users, 28 daysLighthouseone simulated loadchecks anddebugging only
Our own RUM is the source of truth for goals and proof, because it covers every page view and can be cut by page type, device and experiment group. CrUX and lab tools sit to the side: an outside check and a debugger.

p75 per page type and device, then segment

A single site-wide p75 hides everything. A fast home page with lots of traffic can make the site look fine while product pages are poor. So the first dashboard has one row per page type and device class, and the p75 of each metric. Then we cut the worst row further.

An example first dashboard for the case in this guide. These are realistic made-up numbers, not data from a real store.
Segment (product pages, phones)Share of viewsLCP p75INP p75CLS p75
All100%4.6 s380 ms0.22
Logged in35%5.3 s410 ms0.24
Logged out65%4.2 s360 ms0.21
Low memory devices (2 GB or less)30%5.4 s560 ms0.23
Connection reported as 3g or slower12%7.1 s400 ms0.22
Visitors in a far region (no nearby origin)18%5.6 s390 ms0.22

Even before any profiling, this table says a lot. Logged-in users are 1.1 s slower on LCP: their HTML is personalized, so it is never cached and is rendered on every request. Far regions are slower because of more network time to the origin. Low memory phones have a much worse INP: too much JavaScript for a weak CPU.

CLS is poor everywhere, so it is not about devices: something on the page itself moves. Each line is a hypothesis to check in the next chapters.

Check your understanding · Exercise 2

Your RUM has run for a week. Which of these are good uses of it? Select all that apply.

The case: one product page on a mid-range Android phone

From here on we follow one page through the whole plan. The numbers are a realistic example, built from patterns common on large stores, not measurements of a real site. They are consistent with each other so you can follow the arithmetic.

The page and the visitor

  • The page: a product detail page for a running shoe. A header with search and a cart count, an image gallery with a large hero photo, the title, price and size picker, an "Add to cart" button, reviews, and a row of recommendations. A cookie consent banner on the first visit, and a promo bar for free shipping.
  • The stack: React, rendered on a Node server on each request. The CDN caches JavaScript, CSS and images, but not HTML. The hero gallery is a client-side carousel component, so the server sends a placeholder and the first photo appears only after JavaScript runs.
  • The visitor: a mid-range Android phone (4 GB of memory, a CPU several times slower than a laptop), on 4G with about 100 ms of round-trip time and a few megabits per second, landing from a search result. First visit, nothing cached.
Wireframe of the product page on a phone. From the top: a promo bar injected late by a tag, which pushes the page down; the header with search and the cart count; the hero image, which is the LCP element on 81% of slow visits; the title and price, which rewrap when the web font swaps; the size picker, where taps wait behind hydration; and the Add to cart button. Reviews and recommendations sit below the fold.product pagepromo barheader, cart counthero imagetitle, pricesize pickerAdd to cartCLS: late,pushes pageLCP elementon 81% ofslow visitsCLS: fontswap rewrapsINP: tapswait herebelow the fold: reviews, recommendations
Three of the four main problems sit above the fold: the late promo bar (CLS), the hero image (LCP), and the size picker and button (INP). The parts below the fold matter for JavaScript cost, not for the first paint.

Starting numbers (field data, p75, phones, product pages)

The example case. Only 38% of phone visits to product pages pass all three Core Web Vitals.
Metricp75 nowRatingGoal
LCP4.6 spoor (over 4 s)2.5 s or less
INP380 msneeds improvement200 ms or less
CLS0.22needs improvement0.1 or less
TTFB (not a Core Web Vital, but part of LCP)1.4 sneeds improvement (web.dev's rough guide: good is 0.8 s or less, poor is over 1.8 s)about 0.9 s
JavaScript shipped (compressed)620 KBheavy for a mid-range phoneunder 300 KB on this page
Third-party tags23no owner for mostonly those with an owner and a reason

Now a trace. We run WebPageTest on a real mid-range Android device with a 4G profile, logged in as a test user, so the lab run looks like the field p75. The waterfall shows the story of the 4.6 seconds.

The case page's LCP of 4.6 seconds as four bars on a time axis from 0 to 4.6 seconds. Time to first byte, 1.4 seconds: the origin renders and the HTML is not cached. Resource load delay, 1.5 seconds: the image tag is inserted by JavaScript. Resource load duration, 1.3 seconds: a 480 KB JPEG, 1600 pixels wide. Element render delay, 0.4 seconds. A note says 1.9 of the 4.6 seconds is pure waiting, and LCP paints at 4.6 seconds at p75 on phones.TTFB1.4 sorigin renders,HTML not cachedload delay1.5 simg insertedby JavaScriptdownload1.3 s480 KB JPEG,1600 px widerender0.40 s1 s2 s3 s4 s1.9 s of 4.6 s is waiting: load delay + render delaythe hero paints at 4.6 s (p75, phones, product pages)
The red parts are waiting: the browser could already have been loading the image, or painting it. The hatched parts are real work (the server, the download). Waiting is usually the cheapest time to remove.
  1. 0 to 1.4 s: the HTML request goes all the way to the origin. The server calls the product, price, inventory, recommendations and cart services, then renders React to a string. Nothing can start until the first byte arrives.
  2. 1.4 to 2.9 s: the browser parses the HTML, downloads 180 KB of CSS (which blocks rendering) and 620 KB of JavaScript, and runs it. The hero image is not in the HTML: the carousel component inserts the img tag only when it hydrates (hydration is React running in the browser and attaching state and event handlers to the server-rendered HTML). So the image request starts at 2.9 s.
  3. 2.9 to 4.2 s: the hero downloads: a 1600 pixel wide JPEG of 480 KB, sent to a phone screen that is 412 CSS pixels wide.
  4. 4.2 to 4.6 s: the image is ready, but the main thread is busy with hydration and tag scripts, and a web font is still loading. The hero paints at 4.6 s.

Note how the problems chain. Even a perfect image (step 3) would save at most 1.3 seconds, because steps 1 and 2 still take 2.9. That is why the next chapter takes LCP apart before choosing fixes.

Think first

Before reading on: which single fix do you expect to help LCP the most for this page, and why?

Diagnosing LCP: take it apart into four subparts

LCP is one number, but for an image it is made of four parts in a row. The web.dev guide on optimizing LCP names them, and the web-vitals attribution build reports them for each real visit:

The four LCP subparts with the example case's values (they add up to the 4.6 s p75). If the LCP element is text, the two middle parts are zero.
SubpartFromToOur caseWhat makes it big
Time to first byte (TTFB)The user starts the navigationThe first byte of HTML arrives1,400 msRedirects, slow DNS or connection, an origin far away, server rendering on every request, HTML not cached
Resource load delayTTFBThe LCP image request starts1,500 msThe image is found late: inserted by JavaScript, a CSS background, lazy-loaded, or queued behind other requests at low priority
Resource load durationThe image request startsThe image finishes downloading1,300 msToo many bytes: wrong size, old format, low compression; a slow image host; bandwidth shared with other downloads
Element render delayThe image finishes downloadingThe LCP element is painted400 msRender-blocking CSS or scripts, a busy main thread, the element waiting for JavaScript to render it, a font or an A/B test script hiding the page

web.dev suggests a rough healthy shape: most of the time in TTFB and in the image download (about 40% each), and under 10% each in load delay and render delay, because those two are pure waiting. Our case is the opposite: 1.9 s of the 4.6 s is waiting. That is good news. Waiting is usually cheaper to remove than bytes or server work.

Step 1: find the LCP element

Before fixing, confirm what the LCP element is for real users, because it is not always the obvious one. On a phone, the hero image may be below a large title, and the title becomes the LCP; or the cookie banner text may be the largest element on first visits. The RUM target field gives a CSS selector per visit, so group slow visits by it.

Grouping slow visits by LCP element in the example case.
LCP element (slow product page visits on phones)Share
The hero image in the gallery81%
The product title (when the image is still a placeholder)11%
The consent banner text (first visits only)6%
Other2%

The hero image dominates, so the four subparts above are the right lens. The consent banner line is a small, separate issue: a banner that is larger than anything else on first paint can become the LCP element. Making it smaller (a bar, not a full-screen panel) fixes it.

Step 2: match each big subpart to its cause

  • TTFB 1,400 ms: the HTML is rendered on every request and never cached. Server timing headers show 850 ms in the origin (five backend calls plus React rendering), the rest is network to a single origin region. Fixes: cache the HTML at the CDN edge (needs personalization moved out), stream the HTML so the head with the image hint goes out first, and look at 103 Early Hints, which lets the CDN send preload hints before the HTML is ready. The CDN caching lesson covers cache keys and purging.
  • Load delay 1,500 ms: the hero is client-rendered. The browser's preload scanner, which reads ahead in the HTML to find images and scripts early, cannot see an img tag that does not exist yet. Three other common versions of the same bug: the hero has loading="lazy" (lazy loading waits for layout, so it delays the most important image), the hero is a CSS background-image (found only after CSS is parsed and styles match), or the image is fetched at low priority because the browser does not know it is important yet.
  • Load duration 1,300 ms: the image is too big. A 1600 pixel wide JPEG for a 412 pixel wide screen with a device pixel ratio of about 2.6 means about 1,070 device pixels would be enough. With srcset and sizes, the phone picks a 1080 pixel AVIF of about 90 KB instead of 480 KB. The Images and media lesson covers formats and responsive images.
  • Render delay 400 ms: something blocks the paint. 180 KB of CSS for the whole site blocks the first render; a web font for the product title; and a synchronous A/B testing script in the head that hides the page until it runs. The Loading strategies and Web fonts lessons cover critical CSS and font loading.

Decision How do we make the browser start loading the hero image early?

Preload link in the head

Add link rel=preload as=image with imagesrcset, imagesizes and fetchpriority=high in the head.

The browser starts the request while it parses the head, before it reaches the gallery.

Pros

  • Works even if the img tag is still rendered by JavaScript.
  • One line in the server template; quick to ship.
  • fetchpriority works in all current major browsers (Chrome 101, Safari 17.2 and Firefox 132 or later).

Cons

  • If the element itself is still client-rendered, the image arrives early but cannot paint until JavaScript runs: the time moves from load delay into render delay.
  • The srcset must match the img exactly, or the phone downloads two images.

Render the first hero image in the server HTML

The gallery renders its first slide as a plain img tag on the server, with fetchpriority=high and real width and height; the carousel takes over after hydration.

Pros

  • The preload scanner finds it, and it paints as soon as the bytes and CSS are ready, no JavaScript needed.
  • Also fixes the placeholder that makes the title the LCP on some visits.

Cons

  • Needs a change to a shared gallery component owned by another team.
  • Hydration must not replace the img and restart the download.

103 Early Hints from the CDN

The CDN sends a 103 response with Link: rel=preload headers right away, while the origin is still rendering the page.

Pros

  • Uses the TTFB wait itself: the image can start loading before the HTML arrives.

Cons

  • Needs CDN support and a stable image URL known before rendering.
  • Helps less once the HTML is cached at the edge, because TTFB is then short anyway.

What we pick: Do the first two together. Ship the preload with fetchpriority=high in week 2 because it is one line, then server-render the first slide so the image can paint without waiting for JavaScript. Keep Early Hints as a later option for cache misses. The lab below shows why the preload alone only gets you halfway.

html
<link rel="preload" as="image" fetchpriority="high"
  imagesrcset="/img/shoe-640.avif 640w, /img/shoe-1080.avif 1080w, /img/shoe-1600.avif 1600w"
  imagesizes="(max-width: 768px) 100vw, 50vw" type="image/avif">

<picture>
  <source type="image/avif"
    srcset="/img/shoe-640.avif 640w, /img/shoe-1080.avif 1080w, /img/shoe-1600.avif 1600w"
    sizes="(max-width: 768px) 100vw, 50vw">
  <img src="/img/shoe-1080.jpg" alt="Trail runner shoe, side view"
    width="1080" height="1080" fetchpriority="high" decoding="async">
</picture>
The hero after the fix: in the server HTML, high priority, sized for the phone, with reserved space.

Notice what is missing: no loading="lazy" on this image. Lazy loading is right for the gallery's other slides and for review photos further down, and wrong for the one image most likely to be the LCP.

LCP of a typical slow visit to the case page: 4.6 s, rated poor. Time to first byte 1.4 s, resource load delay 1.5 s, resource load duration 1.3 s, element render delay 0.40 s. Dashed lines mark the 2.5 second good threshold and the 4 second poor threshold.2.5 s4 s1.41.51.30.4LCP 4.6 s: poorTTFB 1.4 sload delay 1.5 sdownload 1.3 srender delay 0.40 s
The bars are the four LCP subparts of a visit near the p75, laid end to end. Red parts are waiting, hatched parts are work. Try the preload alone, then add the server-rendered hero.

This is the case page today. Turn on fixes one at a time and watch which subpart shrinks, and which one grows.

Check your understanding · Exercise 3

RUM shows an LCP p75 of 3.9 s with these subparts near the p75: TTFB 600 ms, load delay 200 ms, load duration 500 ms, render delay 2,600 ms. Which causes fit this shape? Select all that apply.

Diagnosing INP: who is holding the main thread?

INP measures how long it takes from a user's tap, click or key press until the next frame is painted. Like LCP, it splits into parts (three this time), and the attribution build reports each one:

  • Input delay: the tap happened, but the main thread was busy with something else (a long task), so our event handler could not start.
  • Processing duration: our event handlers running.
  • Presentation delay: after the handlers, the browser recalculates styles, lays out, and paints. Large DOM updates make this long.

A long task is any piece of main-thread work over 50 ms. While it runs, the page cannot respond. The Runtime performance lesson covers the event loop and long tasks in depth. Here we focus on finding which long tasks hurt our users.

What RUM says about our case

The example case's INP attribution. Most of the time is spent waiting, and most of it early in the visit.
QuestionAnswer from INP attribution (slow visits, phones)
Which element was tapped?Size picker buttons 44%, "Add to cart" 31%, gallery arrows 15%, other 10%
Which part was largest?Input delay. For visits near the 380 ms p75, the median split is about 210 ms input delay, 110 ms processing and 60 ms presentation.
When did it happen?62% while the page was still loading (loadState of dom-interactive or dom-content-loaded)
Which scripts ran in the long frames?Our app bundle (hydration) 41%, tag manager and its tags 33%, a reviews widget 14%, a chat widget 9%

The picture: users see the size picker early (the HTML is server-rendered), tap it within the first few seconds, and the tap waits behind hydration and tag scripts. The page looks ready but is not. Then, once the tap is handled, the "Add to cart" handler itself is slow: it updates the cart, re-renders the whole product page tree because the cart state lives at the top, and fires four analytics tags synchronously.

Sequence diagram of tapping Add to cart after the fix, with five columns: user, handler, cart store, API and tags. The user taps Add to cart. The handler calls addOptimistic on the cart store, and the cart badge repaints, which is where INP ends. The handler then yields and sends POST /cart to the API, which replies 200 OK. The handler queues the analytics tags after another yield. On the failure path, the API replies 409 because the size is sold out, and the handler rolls back the cart store with the error, which shows a message.UserHandlerCart storeAPITagstap Add to cartaddOptimisticbadge paintsINP endsyieldPOST /cart200 OKyieldqueue tagsif it fails:409 sold outrollback(error)
The tap is answered by the first paint (the badge), long before the network reply. Everything slow (the request, the tags) runs after scheduler.yield(), so it no longer counts toward INP. On failure the store rolls back and the user sees why.

Finding the script: the Long Animation Frames API

The older Long Tasks API tells you that a long task happened, but not which script caused it. The Long Animation Frames API (LoAF) reports frames whose rendering was delayed past 50 ms. For each one it lists the scripts that ran in it: the source URL, the function, how it was invoked (an event listener, a timer or other callback, a promise), and how long each took, including time spent forcing style and layout.

LoAF shipped in Chrome 123 and is available in Chromium browsers only. The web-vitals attribution build attaches the LoAF entries that overlap the INP interaction, which is how our table above knows the tag manager's share.

ts
const byOrigin = new Map<string, number>();

new PerformanceObserver((list) => {
  for (const frame of list.getEntries() as any[]) {
    if (frame.duration < 100) continue; // only frames that really hurt
    for (const s of frame.scripts) {
      const origin = s.sourceURL ? new URL(s.sourceURL).origin : "inline";
      byOrigin.set(origin, (byOrigin.get(origin) ?? 0) + s.duration);
    }
  }
}).observe({ type: "long-animation-frame", buffered: true });

addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden" && byOrigin.size > 0) {
    navigator.sendBeacon("/rum/loaf", JSON.stringify([...byOrigin]));
    byOrigin.clear();
  }
});
Collecting the worst script in each long animation frame, grouped by script origin.

Grouping by origin is the step that turns profiling into a conversation. "The tag manager origin spent 9.4 seconds of long frames per 100 product page views" is something marketing can act on. "The page has long tasks" is not.

The three causes and what each needs

  • Hydration: React walks the whole server-rendered tree, attaches handlers, and runs effects, in large chunks. On a slow phone, hydrating a big product page can take several hundred milliseconds of main thread. Fixes: ship less JavaScript (code-splitting, removing dead code and old polyfills), hydrate less of the page (reviews and recommendations do not need to be interactive at once), and let the framework hydrate in parts (React 18 and later hydrate Suspense boundaries separately and give priority to the part the user taps).
  • Third-party tags: each tag is small, but 23 of them run on load, and some re-run on every click. Fixes: remove the unused ones, load the rest after the page is interactive, move some to the server side, and put a process in front of new ones. The Third-party scripts lesson covers loading patterns.
  • Heavy event handlers: our own "Add to cart" handler does everything at once. Fix: do the visible update first, paint, and then do the rest.

Decision How should a heavy handler give the main thread back so the next paint can happen?

setTimeout(0) between steps

Split the work and schedule the rest with setTimeout.

Pros

  • Works in every browser.

Cons

  • The rest goes to the back of the task queue, behind every other queued task, including third-party ones. Browsers may also clamp nested timers.

await scheduler.yield()

Call await scheduler.yield() after the urgent part. The browser can paint and handle input, then resumes your function ahead of other queued tasks.

Pros

  • Made for exactly this. Your continuation keeps its place in line, so yielding does not mean losing to a tag script.

Cons

  • Chromium (since 129) and Firefox (since 142) only; Safari needs a fallback to setTimeout.

Move the work to a Web Worker

Send data to a worker thread, which runs the heavy computation off the main thread.

Pros

  • Great for pure computation: parsing, filtering large data, crypto.

Cons

  • Workers cannot touch the DOM, React state, or most tag scripts. Our slow parts are DOM and third-party work, so this does not fit.

What we pick: Use scheduler.yield() with a setTimeout fallback for our handlers. It lets the button react at once and keeps the rest of the work ahead of the queue. Workers are the right tool only when the slow part is pure computation.

ts
const yieldToMain = () =>
  "scheduler" in globalThis && "yield" in (globalThis as any).scheduler
    ? (globalThis as any).scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));

async function onAddToCart(item: CartItem) {
  cartStore.addOptimistic(item);      // small state update: only the cart badge re-renders
  await yieldToMain();                // the browser can paint the new badge now

  const result = await api.addToCart(item);
  if (!result.ok) cartStore.rollback(item, result.error);

  await yieldToMain();
  analytics.queue("add_to_cart", { sku: item.sku }); // tags run later, in idle time
}
The "Add to cart" handler after the fix: show the result first, then yield, then do the rest.
Two main-thread timelines. Before: one long task of 420 milliseconds for hydration and tags. A tap at 120 milliseconds waits 300 milliseconds, then the 45 millisecond handler runs, so INP is about 370 milliseconds. After: the same work split into 50 millisecond chunks with yields between them. The tap at 120 milliseconds waits only for the rest of the current chunk, 30 milliseconds, then the handler runs, so INP is about 100 milliseconds.before: one long tasktaphydration + tags: 420 ms45input delay 300 ms, INP about 370 msafter: yield every 50 mstap505050455050505050input delay 30 ms, INP about 100 ms
The total work is the same. Splitting it does not make the page do less; it lets the tap cut in line between chunks, which is what INP measures.

Check your understanding · Exercise 4

Most slow interactions on the product page have a large input delay and happen in the first 5 seconds. Which fixes target that? Select all that apply.

Diagnosing CLS: what moves, and who put it there

CLS adds up unexpected layout shifts: content that moves without the user asking. The score of one shift is roughly how much of the viewport moved times how far it moved. CLS takes the worst burst of shifts in the visit (a session window of up to 5 seconds), and shifts within 500 ms after a tap or key press do not count, because the user expects a change then. The Core Web Vitals lesson covers the formula.

CLS is poor in every segment of our case, which already suggested it is caused by the page, not by devices. The attribution build's largestShiftTarget names the element that moved most in the largest shift. Grouped over slow visits:

The example case's CLS sources on phones.
Largest shift sourceShareWhat actually happens
The product info block (title, price, size picker)38%The free shipping promo bar is injected above the header by a tag, 1.5 s after first paint, and pushes the whole page down 48 px.
The gallery and everything below it24%Gallery thumbnails and review photos have no width and height, so they take 0 px until they load.
The product title and price17%The brand font arrives, has wider letters than the fallback, and the title wraps onto a third line.
The recommendations row and footer12%Recommendations are fetched on the client and inserted without reserved space.
Other9%Consent banner on some locales, a size chart link that loads late.
Two phone screens. Before, CLS 0.22: a promo bar arrives late at the top and pushes the page down, the title rewraps when the web font swaps, and a review photo has no size, so it is 0 pixels tall until it loads. After, CLS 0.05: a 48 pixel slot is reserved for the promo bar, the hero and photo have width and height set, and the fallback font is sized to match the web font.beforepromo bar (late)headerherotitle rewrapsphoto: 0 px tallafterreserved 48 px slotheaderhero, size setfallback matchedphoto box 4:3CLS 0.22CLS 0.05
Every fix on the right reserves space before the content arrives: a fixed slot for the banner, width and height on images, and a fallback font with size-adjust so the swap does not rewrap lines.

Fixes, one per cause

  • Late banners: render the promo bar on the server with its final height, or reserve its space with a fixed min-height and fill it later. If marketing must inject it, inject it into a reserved slot, never above existing content. Consent banners should overlay the bottom of the screen (fixed position) instead of pushing content.
  • Images without dimensions: add width and height attributes (or CSS aspect-ratio) to every image. The browser then reserves the right box before the bytes arrive. Make it a lint rule for the image component, so the fix stays fixed.
  • Font swaps: with font-display: swap, text first shows in a fallback font and then swaps. If the two fonts have different widths, lines rewrap. Adjust the fallback with size-adjust, ascent-override and descent-override in an @font-face rule so it takes the same space, and preload the one font used above the fold. The Web fonts lesson covers this.
  • Late content blocks (recommendations, ads): reserve a box of the expected height (a skeleton). If the response is empty, keep the space or collapse it only below the viewport.

CLS fixes are usually small and safe, which makes them great early wins. In our case the promo bar and image dimensions alone take CLS from 0.22 to about 0.09, and the font fallback adjustment takes it to about 0.05.

Check your understanding · Exercise 5

Which of these will cause layout shifts that count toward CLS? Select all that apply.

The fixes, ranked by impact, effort and risk

Now we have causes with numbers. The naive plan is to start at the top of the diagnosis and work down. That fails in two ways: the biggest fix (caching HTML) takes weeks of work across teams, so nothing ships for a month; and some cheap fixes are skipped because they look small. Instead, score every candidate fix on three things and sort.

  • Impact: the expected change in p75 for the target segment, in the metric's own unit (seconds of LCP, ms of INP, CLS points). Use the subpart numbers: a fix can never save more than the subpart it removes.
  • Effort: engineer weeks, including the other teams involved.
  • Risk: what can break. SEO, personalization showing the wrong user's data, marketing losing data, visual bugs. High risk means it needs a flag, an experiment and a rollback plan.
The example case's candidate fixes. Gains are estimates from subparts and lab runs; they do not add up exactly, because fixes overlap.
FixMetric and expected gainEffortRisk
Hero: preload and fetchpriority=high, remove lazy loadingLCP: about -1.3 s (part of it moves into render delay)2 daysLow
Responsive hero in AVIF with srcset and sizesLCP: about -0.6 s, but only once the hero is server-rendered1 week (image CDN config)Low
Reserve space: image dimensions, promo bar slot, recommendations skeletonCLS: about -0.131 weekLow
Font fallback metrics and preload of one fontCLS: about -0.04, LCP: about -0.05 s3 daysLow
Server-render the first gallery slideLCP: about -0.15 s alone, and it unlocks the AVIF gain2 weeks (gallery team)Medium
Critical CSS inlined, rest loaded without blockingLCP: about -0.15 s2 weeksMedium (unstyled flashes)
Tag audit: remove unused tags, delay the rest, governanceINP: about -100 ms3 weeks, mostly processMedium (marketing data)
Cut JavaScript: split by route and interaction, drop old polyfills, remove dead codeINP: about -50 ms4 weeksMedium
Cache product HTML at the edge; personalization moved to the clientLCP: about -0.5 s (TTFB 1.4 s to about 0.9 s at p75), more in far regions6 weeks (3 teams)High (wrong data shown)
Partial hydration of static parts (reviews, description)INP: about -50 ms6 weeksHigh

Sorting by gain per week of effort, with high-risk items discounted, gives a clear shape. The four low-risk fixes plus the server-rendered first slide take about a month and cover most of CLS and about 2 s of LCP. Note the dependency: once the preload has shipped, the smaller AVIF image saves almost nothing while JavaScript still inserts the hero, because the image then waits for the script anyway.

The two big, risky items (edge HTML caching and partial hydration) have to start early, because they take longest, but they ship last and behind experiments. The lab below lets you change the ranking, the risk appetite and the number of parallel tracks, and see the quarter fill up. With three tracks (our four engineers plus help from the gallery and platform teams) everything fits; with two, partial hydration falls out of the quarter and INP stays at 230 ms.

Rank by
Risk
The quarter plan, ranked by impact per week with a balanced risk appetite and 3 parallel tracks. Hero preload: weeks 1 to 1. Reserve space: weeks 1 to 1. SSR hero: weeks 1 to 2. AVIF hero: weeks 3 to 3. Font fallback: weeks 2 to 2. Tag cleanup: weeks 3 to 5. Critical CSS: weeks 3 to 4. Cut JavaScript: weeks 4 to 7. Edge HTML: weeks 5 to 10. Islands: weeks 6 to 11. Week 13: LCP 1.9 s, INP 180 ms, CLS 0.05.w1w4w7w10w13Hero preloadReserve spaceSSR heroAVIF heroFont fallbackTag cleanupCritical CSSCut JavaScriptEdge HTMLIslandsWeek 13: LCP 1.9 s, INP 180 ms, CLS 0.05: passes
Rows are in rank order. Green bars are low risk, hatched are medium, red are high. The AVIF hero pulls the server-rendered hero in ahead of it, because a small image does not help while JavaScript still inserts it.

Everything fits in the quarter.

The biggest decision: caching product HTML at the edge

TTFB is 1.4 s because every request renders on the origin. Product pages are mostly the same for everyone: title, photos, description, reviews. Only a few parts are personal: the cart count, the logged-in user's price tier, and recommendations. Those few parts stop the whole page from being cached.

One tempting shortcut does not work: caching per user, by adding the session cookie to the cache key or sending Vary: Cookie. Every user then gets a separate cache entry, so the hit rate is near zero, and one wrong key shows a user another user's data. The real options are below.

Decision How do we serve product HTML fast when parts of it are personal?

Keep rendering per request, but make the origin faster

Cache backend responses in the server, parallelize the five service calls, stream the HTML.

Pros

  • No change to what the user sees; no risk of showing one user another user's data.

Cons

  • Still pays the network trip to one origin region (the far-region users stay slow).
  • Saves a few hundred ms at best; the origin work never goes to zero.

Cache one public HTML per product; load personal parts on the client

The HTML has no user data. It is cached at the edge for 5 minutes with stale-while-revalidate, keyed by URL, locale and currency, and purged when the product changes.

After load, a small GET /api/me/product-context call returns the cart count, price tier and recommendations, and fills reserved slots.

Pros

  • Popular products are served from the edge on most requests; TTFB drops to about the network round trip.
  • The page and its LCP no longer depend on the slowest backend.

Cons

  • Personal parts appear a moment later; they need reserved space (or CLS goes up) and must not be the LCP element.
  • Prices that differ per user need a careful design so the cached price is never wrong for anyone.

Cache HTML for guests only; render per request for logged-in users

The edge checks only whether a session cookie exists. No session: serve the cached public HTML. Session: bypass the cache and render on the origin, as today.

Pros

  • No client fetch and no change to the page for logged-in users.
  • Quick to ship, and logged-out visits are 65% of product page views in our case.

Cons

  • Logged-in users, our slowest segment (5.3 s LCP) and the most likely buyers, keep the full origin TTFB.
  • Two render paths to test, and a routing bug is a privacy bug.

Cache public HTML, stitch personal parts at the edge

An edge function fetches the cached page and a personal fragment, and combines them before sending (edge includes or streaming).

Pros

  • Personal parts are in the first HTML: no client fetch, no shift.

Cons

  • Edge compute cost on every request, and the personal fragment call is on the critical path again.
  • More moving parts and harder debugging.

What we pick: Cache one public HTML per product and load the personal parts on the client into reserved slots. It removes the origin from the critical path for most visits at the lowest running cost. For logged-in users with a different price tier, the cached page shows the public price with a reserved price area that updates within a few hundred ms; the price shown at checkout is always computed on the server. Caching for guests only is the fallback if moving personalization slips, and edge stitching is a fair next step if the client fetch proves too slow. The CDN caching lesson compares these patterns, and the linkedin guide shows the guest and member split in detail.

http
GET /p/trail-runner-x-4821 HTTP/2
Host: shop.example

HTTP/2 200
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=0, must-revalidate
CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400
Cache-Tag: product-4821, category-trail

# when price, stock state or content changes for product 4821:
POST /cdn/purge   { "tags": ["product-4821"] }
The cached product page response and the purge on change.

Cache-Control speaks to browsers: max-age=0, must-revalidate means they must check before reusing the page. CDN-Cache-Control (a standard header for CDNs only, from RFC 9213) lets the edge serve it for 5 minutes. For one more minute after that, the edge still answers at once with the old copy while it fetches a fresh one, and it keeps serving the last copy for a day if the origin fails.

Tag-based purging means a price change reaches the edge in seconds instead of waiting for the 5 minutes. Exact header names for tags and purges differ by CDN, and the CDN caching lesson covers them.

Cutting JavaScript and hydration

620 KB of compressed JavaScript is about 2 MB to parse and run on a slow phone. The plan attacks it in three layers, from cheapest to most invasive:

  1. Measure what ships: a bundle analyzer shows a date library with every locale (90 KB), two carousel libraries, and polyfills for browsers we no longer support. Removing these is low risk. The Code splitting and bundling and the Compression and minification lessons cover the tools.
  2. Load on interaction: the size chart, the reviews filter, the chat widget and the zoom viewer load only when used, through dynamic import().
  3. Hydrate less: the description, specs and reviews list are static HTML that never change after load. With islands (only interactive parts hydrate) or React Server Components (static parts never ship as client JavaScript), the page sends code only for the gallery, size picker, add to cart and header. The Choosing a rendering strategy and Islands and partial hydration lessons compare the options. This is the most invasive change, so it comes last and behind a flag.

Third-party tags: a process, not a one-time cleanup

A one-time cleanup of the tag manager works for about a month. Then campaigns add new tags. So the fix is a process with four rules: every tag has an owner and an expiry date; new tags go through a short review with a cost in KB and main-thread ms measured in the lab; tags load after the page is interactive unless there is a written reason; and RUM reports long-frame time by script origin, so each tag's cost is visible.

Moving conversion tracking to server-side tagging (the browser sends one event to our server, which forwards it to vendors) removes several scripts at once.

Check your understanding · Exercise 6

Why does the plan start the edge HTML caching work in week 2, even though it ships last? Select all that apply.

The target architecture of the product page

The fixes add up to a different shape for the product page. It helps to draw it, because the interviewer will ask "so what does the page look like at the end of the quarter?" The main idea: split the page into a public, cacheable shell that carries everything needed for the first paint, and personal, interactive islands that arrive after it and never block it.

The target architecture. In the browser: the page shell from server HTML with the hero and reserved slots; the islands for the gallery and cart, which update the cart store; the tag loader, which starts after the page is interactive; the context fetch, which runs after load and fills the cart store; and web-vitals, which sends a RUM beacon when the page is hidden. At the edge: the image CDN, which serves AVIF by width, and the edge cache, which holds product HTML for 300 seconds. At the origin: the renderer, which makes public HTML on a cache miss, the context API, and RUM ingest.Browserpage shellserver HTML, hero, slotsislandsgallery, cartcart storebadge, itemstag loaderafter interactivecontext fetchafter loadweb-vitalsRUM beacon on hideEdge (CDN)image CDNAVIF by widthedge cachepublic HTML, 300 sOriginrenderercontext APIRUM ingest
The line that matters is between the shell and everything else. The shell is public and cached at the edge, so the first paint never waits for the origin or for a user-specific call; personal data and third parties arrive later, into reserved space.
The parts of the target product page and why each boundary is where it is.
PartResponsibilityWhy the boundary is here
CDN edge cacheServes product HTML, images, JS and CSS from a nearby location; sends preload hintsRemoves the origin from the critical path for most visits
Origin renderer (Node)Renders public product HTML on a cache miss; no user dataWithout user data, one render serves thousands of visitors
Image CDNResizes and converts images to AVIF and WebP on request, by widthTeams upload one master photo; the right size is a URL parameter
Product context APIReturns cart count, price tier, recommendations for the current userThe only per-user call; small, uncached, and off the LCP path
Page shell (server HTML)Header, hero image, title, price, size picker markup, reserved slotsEverything needed for LCP, with no JavaScript required to paint it
Islands (client JavaScript)Gallery carousel, size picker, add to cart, cart badge, recommendationsOnly interactive parts ship and hydrate code
Tag loaderLoads approved tags after the page is interactive; forwards events to server-side taggingKeeps third parties off the load path and visible in one place
RUM collectorMeasures vitals with attribution; queues them and sends one beacon when the page is hiddenThe feedback loop for every decision and every regression

How data moves on a product page view

Data flow for a product page view. The origin sends public HTML to the edge cache and purges it by tag when a product changes. The edge cache sends HTML with a preload hint to the browser. The image CDN sends the AVIF hero. The browser sends GET context to the context API, which replies with cart, price and recommendations. Writes such as POST /cart go to the origin. When the page is hidden, the browser sends a vitals beacon to RUM ingest.originrendereredge cachepublic HTMLbrowsershell, islands, storeimage CDNcontext APIper userRUM ingestpublic HTML; purge by tagHTML + hintAVIFPOST /cartGET contextcart, price, recsvitals beacon
Only two arrows carry per-user data: the context reply and the cart write. Neither is on the path to the first paint, which needs just the cached HTML and the hero image.

Follow a first visit. The HTML request hits the edge, and on a hit (most visits) the cached HTML starts arriving with no origin work, carrying a preload hint for the hero. The browser fetches the hero from the image CDN at high priority while it applies the inline critical CSS. The hero paints, and that paint is the LCP.

Next, the island scripts load and hydrate the gallery, size picker and cart button. One call to the product context API fills the cart badge, the price tier and the recommendations into their reserved slots. After the page is interactive, the tag loader starts approved tags. When the page is hidden, the RUM script sends the queued metrics and their reasons in one beacon.

Sequence diagram of loading the product page after the change, with five columns: browser, edge, origin, image CDN and context. The browser requests the product page; the edge answers from cache with HTML and a preload hint. The browser requests the hero at high priority; the image CDN replies with a 90 KB AVIF, which paints as the LCP. The browser then calls the context API, which replies with the user context: cart, price and recommendations. On the slow paths: on a cache miss the edge asks the origin to render public HTML, and if the context call times out after 1.5 seconds the page keeps its defaults.BrowserEdgeOriginImage CDNContextGET /p/4821hit: HTML+hintGET heroAVIF: LCPGET contextuser contextslow paths:miss: rendertimeout 1.5 sdefaults
On a cache hit the origin is not involved at all. The context call starts after the hero is on its way, so even a slow or failed call cannot delay LCP; it only fills slots that already have space.

The data the plan runs on

Two shapes matter: the personal context the page fetches after load, and the RUM event that every decision in this plan is based on.

ts
// GET /api/me/product-context?sku=4821  (Cache-Control: private, no-store)
type ProductContext = {
  cartCount: number;
  priceTier: { tier: "member" | "public"; price: Money } | null;
  recommendations: { sku: string; title: string; image: string; price: Money }[];
};

// POST /rum: the browser batches a page view's metrics into one beacon.
// The collector stores one RumEvent row per metric and adds the country.
type RumEvent = {
  name: "LCP" | "INP" | "CLS" | "TTFB";
  value: number;                 // ms, or a unitless score for CLS
  rating: "good" | "needs-improvement" | "poor";
  pageType: "product" | "category" | "search" | "home" | "checkout";
  deviceClass: "low" | "mid" | "high";   // derived from device memory and cores
  country: string;               // from the request, on the server
  connection: string | null;     // effectiveType, Chromium only
  loggedIn: boolean;
  experiments: Record<string, string>;   // e.g. { "perf-q3": "treatment" }
  build: string;                 // which release was live, for regressions
  attribution: Record<string, string | number>;  // subparts, target, script
};
The personal context response and the RUM event, as TypeScript types.

Two fields in the RUM event are there for the later chapters. experiments lets us compare speed and conversion between control and treatment. build lets an alert say "INP p75 got worse after release 2026.10.14", which turns a vague regression into a diff to read.

Check your understanding · Exercise 7

After the change, which of these can safely be in the cached product HTML? Select all that apply.

Proving impact: did speed move conversion?

The question came from a business problem: conversion is dropping. At the end of the quarter someone will ask whether the work paid off.

The naive answer is a chart: LCP went down in August, conversion went up in September. That proves nothing, because September has a sale, a new marketing channel started, and two product teams shipped features. Any of them can move conversion more than speed did.

Decision How do we show that the speed work changed business numbers?

Before and after comparison

Compare conversion in the month before and the month after the fixes.

Pros

  • Free; no setup.

Cons

  • Seasonality, campaigns and other launches are mixed in. Cannot separate cause from coincidence.

Correlation in RUM data

Group visits by LCP bucket (under 2 s, 2 to 3 s, ...) and compare conversion per bucket.

Pros

  • Available from week 2; good for sizing the opportunity and convincing people to fund the work.

Cons

  • Slow visits come from cheaper phones, slower countries and different traffic sources, which convert differently anyway. It overstates or understates the effect.

A/B test the speed change

Split users at random: control gets the current page, treatment gets the faster one, with no visible differences.

Compare both speed (from RUM, by experiment group) and conversion.

Pros

  • Random assignment cancels out devices, seasons and campaigns. This is the only way to claim cause.

Cons

  • Needs enough traffic and 2 to 4 weeks per test.
  • Some fixes (edge caching) are hard to split per user; the variant has to be part of the cache key.

Slowdown experiment

Add an artificial delay (for example 300 ms) for a small random group, and measure the drop.

Pros

  • Easy to build, and gives a clean price per 100 ms for your own site.

Cons

  • Deliberately makes the experience worse for real customers, and costs real revenue while it runs. Needs leadership approval and a short duration.

What we pick: Use correlation early to size the opportunity and get support, then A/B test the main changes to claim cause. Bundle the low-risk fixes into one treatment so the effect is large enough to detect. Keep a slowdown test as an option only if leadership wants a price per 100 ms for future planning.

Running a performance A/B test well

  • Assign at the edge, by user. A cookie set at the CDN decides the group, so the first HTML request is already in the right variant. Client-side assignment would load both versions' code and slow down the treatment.
  • Put the variant in the cache key. Otherwise the control's HTML is served to treatment users. Two variants means two cache entries per product, which is fine.
  • Check the speed difference first. Use RUM split by group to confirm treatment is actually faster at p75. If it is not, the conversion result means nothing.
  • Pick one primary metric, such as add to cart rate or orders per session, decided before the test, plus guardrails (bounce rate, revenue per session, errors).
  • Size it. Small effects need a lot of traffic. With a 3% base conversion rate, detecting a 2% relative lift takes more than a million sessions per group; run a sample size calculator before you start. If traffic is low, test bigger bundles of fixes.

What published case studies show, and what they do not

Use published numbers to get support, and your own experiment to make the claim.
SourceWhat they reportHow strong
Vodafone (web.dev case study, 2021)An A/B test on a landing page where the optimized version had a 31% better LCP: 8% more sales, 15% better lead to visit rate, 11% better cart to visit rate.Strong: a randomized test with no visual differences between versions.
Zalando (engineering blog, 2018)Correlation analysis across the user journey, then an A/B test: a 100 ms faster load time gave a 0.7% increase in revenue per session.Fairly strong: an A/B test on their own site, though the post gives few details of the method.
Deloitte for Google, "Milliseconds Make Millions" (2020)Across 37 brands in Europe and the US, a 0.1 s faster mobile site went with higher conversion (8.4% for retail sites).Medium: an observational study across companies, not an experiment on one site.
"Every 100 ms costs Amazon 1% of sales"A line repeated in many blog posts. It traces back to a 2006 talk by Greg Linden, a former Amazon engineer, about an internal experiment whose method and data were never published.Weak: do not use it as evidence in a business case.

The pattern in the strong studies is the same: they measured their own site, with a field metric, in a controlled test. That is what our plan does. In our example, the bundled early fixes take LCP p75 for phones from 4.6 s to 2.5 s in treatment, and the experiment shows a small, statistically clear lift in add to cart rate (an invented number for the example; your result will differ). The edge caching change gets its own test later.

Check your understanding · Exercise 8

Your A/B test shows no conversion change. Which of these should you check before concluding that speed does not matter for this store? Select all that apply.

Keeping it fast: budgets, alerts and owners

Most performance projects end the same way: a big push, a good quarter, and a slow slide back over the next six months. A new tag here, a bigger library there, a hero image uploaded at 3 MB: no single change looks bad, so none is stopped. The last part of the plan is the system that stops the slide. The Performance budgets and monitoring lesson covers budget types in depth; here is how they fit this plan.

Decision Where do we catch regressions?

Lab budgets in CI

Every pull request builds the app and checks bundle sizes per route (for example with size-limit) and runs Lighthouse CI on a few key URLs with assertions.

Pros

  • Catches problems before they ship, and points to the exact change.
  • Bundle size checks are fast and fully repeatable.

Cons

  • Lab timing is noisy: run several times and use the median, or the check fails at random.
  • Cannot see tags added in the tag manager, real devices, or INP.

Field alerts from RUM

A daily job computes p75 per page type and device; alert when a metric gets worse than a threshold for two days in a row, and say which release and segment changed.

Pros

  • Sees everything real users see, including tags and content changes that never go through CI.

Cons

  • Finds the problem after it shipped; a day or more of users are affected.

Synthetic monitoring in production

A scheduled lab test (for example every hour) on the live product page from a few regions.

Pros

  • Faster than RUM for big breaks; catches tag manager changes within an hour.

Cons

  • Same lab limits: one device, one network, no INP.

What we pick: Use all three in layers. Bundle budgets in CI block most regressions in our own code. Hourly synthetic tests catch big breaks from tags and content. RUM alerts on p75 are the final, true check. Each layer catches what the one before it cannot see.

json
{
  "ci": {
    "collect": {
      "url": ["https://staging.shop.example/p/trail-runner-x-4821"],
      "numberOfRuns": 5
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500, "aggregationMethod": "median-run" }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["warn", { "maxNumericValue": 300 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 307200 }],
        "resource-summary:third-party:count": ["warn", { "maxNumericValue": 8 }]
      }
    }
  }
}
A Lighthouse CI config with budgets for the product page (lighthouserc.json).

The script size budget (300 KB) is in bytes as transferred. Total Blocking Time is a warning, not an error: it is the lab's rough stand-in for INP and too noisy to block merges. Budgets should start at today's value after the fixes, a little looser than the best run, and get tighter over time. A budget that fails every day gets turned off.

What happens when a field metric regresses

An alert with no owner is noise. So every regression follows the same small state machine, with a named owner at each step.

State machine for a field regression. Healthy: p75 within budget. After one day over budget it moves to Watching, which may be noise; back under budget returns it to Healthy. After two days over, or a jump of more than 20 percent, it moves to Regressed: a ticket with the segment, and the owner is paged. Then Mitigating: roll back or turn the flag off. After three days back under budget it moves to Fixed, and closing it with a note and a new CI check returns it to Healthy.healthyp75 in budgetwatchingmaybe noiseregressedticket, segmentmitigatingrollback firstfixed3 days in budget1 day over2 days or +20%back underowner pagedback under budgetclose, add CI check
Every arrow has a rule and every state has an owner. Two days and a 20% jump filter out noise from campaigns; rolling back comes before investigating.
  • Healthy: p75 within budget. Nothing to do.
  • Watching: p75 crossed the budget for one day. It may be noise (a campaign brought slower traffic). No page yet.
  • Regressed: two days in a row, or a jump of more than 20%. The alert opens a ticket with the segment, the metric's subparts, and the releases and tag changes in the window. The page owner team is assigned.
  • Mitigating: the owner rolls back the release or disables the tag behind its flag. Roll back first, investigate second.
  • Fixed: p75 back under budget for three days. The ticket closes with a short note of the cause, which feeds new CI checks.

Owners and a performance champion

Speed is a shared resource, so it needs owners like any other. Each page type has an owning team that gets its alerts. Each team that ships code on the product page has a budget share (the gallery team owns the hero and its JavaScript; the reviews team owns the reviews island).

A performance champion, one senior engineer with part of their time on it, runs a monthly review of the dashboard, keeps the tag review process going, and helps teams with diagnosis. The champion does not fix everything; they make sure the right team knows and has the tools.

Check your understanding · Exercise 9

Two months after the quarter, INP p75 on product pages jumps from 180 ms to 280 ms. No code release happened that week. What would have caught or explained it? Select all that apply.

The plan for the quarter, week by week

Now put it together. The plan has three parallel tracks that start at different times: quick wins that ship early, deep changes that start early and ship late, and the guardrails that grow from week 1. Every change ships behind a flag, to a small share of traffic first, and is read in RUM by experiment group before it goes to everyone.

The quarter as three tracks over 13 weeks. Quick wins: hero fixes in weeks 1 and 2, images and layout shifts in weeks 3 and 4, tags and JavaScript in weeks 5 to 7, critical CSS in weeks 8 to 10. Deep changes: design in weeks 1 to 4, moving personalization out of the HTML in weeks 5 to 10, edge HTML caching in weeks 11 to 13. Guardrails: RUM in weeks 1 and 2, an A/B test in weeks 5 to 7, CI budgets in weeks 8 to 10, alerts and owners in weeks 11 to 13. Expected LCP p75: 3.3 seconds after week 2, 2.5 after week 4, 2.4 after week 10, 1.9 after week 13. Expected INP p75: 230 milliseconds after week 7, 180 after week 13.w1w4w7w10w13quick winsheroimagestags, JSCSSdeep changesdesignpersonalization outedgeguardrailsRUMA/B testbudgetsalertsLCP p753.3 s2.5 s2.4 s1.9 sINP p75230 ms180 ms
Measurement ships first, quick wins ship every two weeks, and the deep, risky change runs the whole quarter in the background and lands last.
The example case's quarter. Every number is an expected p75, checked against RUM after each step; the plan adjusts if a step does less than expected.
WeeksShipStart or continueExpected p75 on phones after
1 to 2RUM with attribution; dashboard per page type and device; hero preload and fetchpriority, lazy loading removed from the heroServer-rendered first slide with the gallery team; personalization design with three teams; tag inventory with marketingLCP 3.3 s, INP 380 ms, CLS 0.22
3 to 4Server-rendered first gallery slide; responsive AVIF hero; image dimensions everywhere; promo bar slot; font fallback metricsCritical CSS; product context APILCP 2.5 s, INP 380 ms, CLS 0.05
5 to 7Tag cleanup (23 tags to 9, the rest after interactive); bundle cleanup; size chart and chat on interaction; A/B test of the early fixesCache keys and purge by tag; islands for static partsLCP 2.5 s, INP 230 ms, CLS 0.05
8 to 10Critical CSS; personalization moved to the client behind a flag; CI budgetsEdge HTML caching experimentLCP 2.4 s, INP 230 ms, CLS 0.05
11 to 13Edge HTML caching at 100% after the experiment; reviews and description no longer hydrated; alerts and owners liveNext quarter: category and search pages with the same methodLCP 1.9 s, INP 180 ms, CLS 0.05

Two things make this plan believable to the people funding it. First, something measurable ships every two weeks, so there is visible progress long before the big changes land. Second, each step has an expected number, so if the hero preload brings LCP to 4.1 s instead of 3.3 s, we notice at once and revisit the diagnosis instead of finding out in week 13.

Think first

In week 6, the team that owns recommendations says moving them to a client fetch will take until week 12, not week 8. What do you change in the plan?

Running it in the interview

This question is open on purpose. The interviewer is checking whether you work like a senior engineer on a real problem: measure, then reason, then act, then make it last. Here is a way to spend 45 minutes.

One way to use 45 minutes.
MinutesStepWhat to say or draw
0 to 5ClarifyWhich pages make money, who the users are (devices, countries), what data exists, the stack, how personal the page is, whether A/B tests are possible. State your assumptions.
5 to 12MeasureField first: RUM with attribution plus CrUX; p75 per page type and device; segments. Lab for debugging. Draw the RUM pipeline.
12 to 25HypothesizePick the worst segment. Break LCP into four subparts, INP into three parts, list CLS sources. Name the likely cause for each with numbers.
25 to 33PrioritizeA table of fixes with impact, effort and risk. Quick wins first, long work started early. Draw the target architecture: cached public HTML, personal islands.
33 to 39Roll out and proveFlags, staged rollout, an A/B test with the variant in the cache key, conversion as the primary metric.
39 to 45GuardCI budgets, synthetic tests, RUM alerts by release, owners, tag governance, a champion.

Draw two things on the whiteboard, in this order. First the LCP bar for the worst segment, split into its four subparts with numbers: it shows that you diagnose before you fix, and every later decision points back at it. Then the target architecture: the cached public shell at the edge, the personal islands and context call after it, and the RUM loop. If time is short, skip the CLS details; they are the easiest part to describe in one sentence.

Follow-up questions to expect

  • "We cannot cache the HTML because prices differ per user." Cache the public price and load the member price into a reserved slot. The checkout price is always computed on the server. If the business needs the personal price in the first paint, use edge stitching for logged-in users only.
  • "Marketing refuses to remove tags." Do not argue in general; show the cost of each tag in long-frame ms per 100 page views from RUM, and offer alternatives: load after interactive, server-side tagging, or an A/B test of tag removal on conversion. Make the decision with data and with their owner.
  • "Why not rewrite in a faster framework?" A rewrite ships nothing for months, risks SEO and features, and our diagnosis says the biggest causes are caching, image discovery and tags, which a new framework does not fix by itself. Partial hydration inside the current stack gets most of the JavaScript gain.
  • "LCP improved in RUM but conversion did not." Check the experiment (was treatment faster at p75?), its power, and whether the gain reached the users who buy. Then accept the result: speed is one factor, and the plan still removed a known risk.
  • "How do you handle Safari users?" Recent Safari versions report LCP and INP, but not CLS or Long Animation Frames. Profile on real iPhones in the lab (Safari's Web Inspector has a timeline), and check that fixes for causes (image discovery, caching) help every browser even when the metric is only visible in some.

Check your understanding · Exercise 10

Which of these would an interviewer see as strong signals in this question? Select all that apply.

Summary

  • Measure real users first. Goals, priorities and proof come from field p75 per page type and device; lab tools are for debugging and CI.
  • Segment before you guess: logged-in versus logged-out, device memory, connection and region each point at a different cause.
  • Take each metric apart. LCP is TTFB, resource load delay, resource load duration and element render delay; INP is input delay, processing and presentation. Fix the biggest part, and prefer removing waiting over removing bytes.
  • Rank fixes by impact, effort and risk. Ship cheap, safe wins in the first weeks; start long, risky work (edge HTML caching, partial hydration) early and ship it behind flags and experiments.
  • Split the product page into a public, cacheable shell that carries the LCP, and personal, interactive islands that load after it into reserved space.
  • Third-party tags need a process: owners, expiry dates, a cost review, and RUM attribution by script origin.
  • Prove impact with a randomized test with the variant in the cache key, not a before and after chart.
  • Keep it fast with layers: bundle and Lighthouse CI budgets, synthetic checks, RUM alerts by release, named owners, and a champion.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next