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.
| Question | Assumed answer | Why 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.
| Metric | What it measures | Good (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 painted | 2.5 s or less | over 4 s | 2.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 visit | 200 ms or less | over 500 ms | 200 ms or less |
| CLS (Cumulative Layout Shift) | How much visible content jumps around unexpectedly | 0.1 or less | over 0.25 | 0.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.
- 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.
- 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.
- 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.
- 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.
- 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?
Lighthouse runs one lab test: one simulated mid-range phone, one simulated network, an empty cache, no logged-in user, and no real interactions. It is a great debugging tool, but its score is not what users feel, and it is not what Google uses for search ranking (that is field data from real Chrome users).
A standard Lighthouse run (one page load) cannot measure INP, because INP needs real interactions; it reports Total Blocking Time (TBT: how long scripts blocked the main thread during load) instead. A page can score 95 and still freeze for 600 ms when a real user taps "Add to cart".
A better goal: p75 LCP, INP and CLS in the good range for phone users on product pages, measured from real users. Use Lighthouse to debug and to guard against regressions in CI, not as the 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?
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.
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) }));
});
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.
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.
| Segment (product pages, phones) | Share of views | LCP p75 | INP p75 | CLS p75 |
|---|---|---|---|---|
| All | 100% | 4.6 s | 380 ms | 0.22 |
| Logged in | 35% | 5.3 s | 410 ms | 0.24 |
| Logged out | 65% | 4.2 s | 360 ms | 0.21 |
| Low memory devices (2 GB or less) | 30% | 5.4 s | 560 ms | 0.23 |
| Connection reported as 3g or slower | 12% | 7.1 s | 400 ms | 0.22 |
| Visitors in a far region (no nearby origin) | 18% | 5.6 s | 390 ms | 0.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.
Starting numbers (field data, p75, phones, product pages)
| Metric | p75 now | Rating | Goal |
|---|---|---|---|
| LCP | 4.6 s | poor (over 4 s) | 2.5 s or less |
| INP | 380 ms | needs improvement | 200 ms or less |
| CLS | 0.22 | needs improvement | 0.1 or less |
| TTFB (not a Core Web Vital, but part of LCP) | 1.4 s | needs 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 KB | heavy for a mid-range phone | under 300 KB on this page |
| Third-party tags | 23 | no owner for most | only 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.
- 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.
- 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.
- 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.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?
Look for the biggest piece, and ask what it would take to remove it. The 1.5 seconds between the HTML arriving and the image request starting is the largest single piece, and it exists only because the image is found late. Putting the hero image in the server HTML (and marking it high priority) removes most of it.
TTFB (1.4 s) is the second biggest. Caching the HTML at the CDN can cut it to a few hundred milliseconds for most visits, but only after personalization moves out of the HTML, which is more work.
The image size (1.3 s) is third and is cheap to fix. A good plan does the cheap, large fixes first and starts the expensive ones early, in parallel.
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:
| Subpart | From | To | Our case | What makes it big |
|---|---|---|---|---|
| Time to first byte (TTFB) | The user starts the navigation | The first byte of HTML arrives | 1,400 ms | Redirects, slow DNS or connection, an origin far away, server rendering on every request, HTML not cached |
| Resource load delay | TTFB | The LCP image request starts | 1,500 ms | The image is found late: inserted by JavaScript, a CSS background, lazy-loaded, or queued behind other requests at low priority |
| Resource load duration | The image request starts | The image finishes downloading | 1,300 ms | Too many bytes: wrong size, old format, low compression; a slow image host; bandwidth shared with other downloads |
| Element render delay | The image finishes downloading | The LCP element is painted | 400 ms | Render-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.
| LCP element (slow product page visits on phones) | Share |
|---|---|
| The hero image in the gallery | 81% |
| The product title (when the image is still a placeholder) | 11% |
| The consent banner text (first visits only) | 6% |
| Other | 2% |
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 CSSbackground-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
srcsetandsizes, 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?
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.
<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>
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.
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
| Question | Answer 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.
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.
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();
}
});
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?
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.
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
}
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:
| Largest shift source | Share | What 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 it | 24% | Gallery thumbnails and review photos have no width and height, so they take 0 px until they load. |
| The product title and price | 17% | The brand font arrives, has wider letters than the fallback, and the title wraps onto a third line. |
| The recommendations row and footer | 12% | Recommendations are fetched on the client and inserted without reserved space. |
| Other | 9% | Consent banner on some locales, a size chart link that loads late. |
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-heightand 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
widthandheightattributes (or CSSaspect-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 withsize-adjust,ascent-overrideanddescent-overridein an@font-facerule 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.
| Fix | Metric and expected gain | Effort | Risk |
|---|---|---|---|
Hero: preload and fetchpriority=high, remove lazy loading | LCP: about -1.3 s (part of it moves into render delay) | 2 days | Low |
Responsive hero in AVIF with srcset and sizes | LCP: about -0.6 s, but only once the hero is server-rendered | 1 week (image CDN config) | Low |
| Reserve space: image dimensions, promo bar slot, recommendations skeleton | CLS: about -0.13 | 1 week | Low |
| Font fallback metrics and preload of one font | CLS: about -0.04, LCP: about -0.05 s | 3 days | Low |
| Server-render the first gallery slide | LCP: about -0.15 s alone, and it unlocks the AVIF gain | 2 weeks (gallery team) | Medium |
| Critical CSS inlined, rest loaded without blocking | LCP: about -0.15 s | 2 weeks | Medium (unstyled flashes) |
| Tag audit: remove unused tags, delay the rest, governance | INP: about -100 ms | 3 weeks, mostly process | Medium (marketing data) |
| Cut JavaScript: split by route and interaction, drop old polyfills, remove dead code | INP: about -50 ms | 4 weeks | Medium |
| Cache product HTML at the edge; personalization moved to the client | LCP: about -0.5 s (TTFB 1.4 s to about 0.9 s at p75), more in far regions | 6 weeks (3 teams) | High (wrong data shown) |
| Partial hydration of static parts (reviews, description) | INP: about -50 ms | 6 weeks | High |
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.
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?
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.
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"] }
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:
- 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.
- Load on interaction: the size chart, the reviews filter, the chat widget and the zoom viewer load only when used, through dynamic
import(). - 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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| CDN edge cache | Serves product HTML, images, JS and CSS from a nearby location; sends preload hints | Removes the origin from the critical path for most visits |
| Origin renderer (Node) | Renders public product HTML on a cache miss; no user data | Without user data, one render serves thousands of visitors |
| Image CDN | Resizes and converts images to AVIF and WebP on request, by width | Teams upload one master photo; the right size is a URL parameter |
| Product context API | Returns cart count, price tier, recommendations for the current user | The only per-user call; small, uncached, and off the LCP path |
| Page shell (server HTML) | Header, hero image, title, price, size picker markup, reserved slots | Everything needed for LCP, with no JavaScript required to paint it |
| Islands (client JavaScript) | Gallery carousel, size picker, add to cart, cart badge, recommendations | Only interactive parts ship and hydrate code |
| Tag loader | Loads approved tags after the page is interactive; forwards events to server-side tagging | Keeps third parties off the load path and visible in one place |
| RUM collector | Measures vitals with attribution; queues them and sends one beacon when the page is hidden | The feedback loop for every decision and every regression |
How data moves on a product page view
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.
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.
// 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
};
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?
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
| Source | What they report | How 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?
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.
{
"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 }]
}
}
}
}
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.
- 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.
| Weeks | Ship | Start or continue | Expected p75 on phones after |
|---|---|---|---|
| 1 to 2 | RUM with attribution; dashboard per page type and device; hero preload and fetchpriority, lazy loading removed from the hero | Server-rendered first slide with the gallery team; personalization design with three teams; tag inventory with marketing | LCP 3.3 s, INP 380 ms, CLS 0.22 |
| 3 to 4 | Server-rendered first gallery slide; responsive AVIF hero; image dimensions everywhere; promo bar slot; font fallback metrics | Critical CSS; product context API | LCP 2.5 s, INP 380 ms, CLS 0.05 |
| 5 to 7 | Tag cleanup (23 tags to 9, the rest after interactive); bundle cleanup; size chart and chat on interaction; A/B test of the early fixes | Cache keys and purge by tag; islands for static parts | LCP 2.5 s, INP 230 ms, CLS 0.05 |
| 8 to 10 | Critical CSS; personalization moved to the client behind a flag; CI budgets | Edge HTML caching experiment | LCP 2.4 s, INP 230 ms, CLS 0.05 |
| 11 to 13 | Edge HTML caching at 100% after the experiment; reviews and description no longer hydrated; alerts and owners live | Next quarter: category and search pages with the same method | LCP 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?
Do not let one dependency block the whole caching change. Ship edge caching for logged-out users first. Their only personal parts are the guest cart count, which a tiny call can fill, and recommendations, which the cached HTML can replace with popular items from the same category until the recommendations team is ready.
Logged-out users are 65% of product page views in our case, so this captures most of the TTFB gain on time. Logged-in users follow when the context API is ready.
Tell stakeholders the new expected numbers per week. A plan that changes with evidence is a good 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.
| Minutes | Step | What to say or draw |
|---|---|---|
| 0 to 5 | Clarify | Which 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 12 | Measure | Field first: RUM with attribution plus CrUX; p75 per page type and device; segments. Lab for debugging. Draw the RUM pipeline. |
| 12 to 25 | Hypothesize | Pick 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 33 | Prioritize | A 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 39 | Roll out and prove | Flags, staged rollout, an A/B test with the variant in the cache key, conversion as the primary metric. |
| 39 to 45 | Guard | CI 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.
References
- Optimize Largest Contentful Paint (web.dev) The four LCP subparts and how to shrink each one.
- Optimize Interaction to Next Paint (web.dev) Input delay, processing and presentation delay, and yielding.
- web-vitals library (GitHub) The RUM library and the fields of its attribution build.
- Long Animation Frames API (Chrome for Developers) Finding which scripts cause slow frames.
- Chrome UX Report overview (Chrome for Developers) What CrUX measures, its 28-day window, and how to query it.
- Vodafone: a 31% improvement in LCP increased sales by 8% (web.dev) A randomized test of a speed change against sales.
- Loading time matters (Zalando Engineering) Correlation, then an A/B test, linking load time to revenue per session.
- Lighthouse CI (GitHub) Running Lighthouse with assertions on every pull request.
- fetchpriority attribute (MDN) Raising the priority of the LCP image.
- Scheduler: yield() method (MDN) Yielding to the main thread, with current browser support.
- Optimize Time to First Byte (web.dev) TTFB thresholds and the server, CDN and Early Hints fixes.
- RFC 9213: Targeted HTTP Cache Control The CDN-Cache-Control header used for the cached product page.
Concepts behind it
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Islands and partial hydrationShip a page as static HTML and send JavaScript only for the few parts that need it, each waking up on its own trigger, so content pages stay fast on slow phones instead of paying to hydrate the whole app.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Web fontsLoading custom fonts without invisible text or a jumping page: choosing
font-display, preloading the right files, subsetting them, and matching the fallback font's size. - Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Compression and minificationTwo ways to send fewer bytes for the same code: minification rewrites JS and CSS into a shorter program at build time, and compression (gzip, Brotli, Zstandard) packs bytes for the trip, together often cutting text to a fifth of its size.
- Choosing a rendering strategyHow to pick the rendering strategy for each page by asking who sees it, how often it changes, how interactive it is, and whether strangers must find it, then mixing strategies route by route in one app instead of choosing one for everything.