Frontend Performance and Rendering Optimization Questions
Making web pages and single-page apps fast: Core Web Vitals (LCP, INP, CLS) and page-speed metrics, the critical rendering path, reflow, repaint, forced synchronous layout and compositing, and diagnosing loading and rendering bottlenecks with browser DevTools, long-task traces and field data. Covers bundle size, code splitting, tree shaking and lazy loading, JavaScript parse and compile cost, script loading (async, defer), resource hints and fetch priority, HTTP/2 and HTTP/3 loading trade-offs, image and web-font delivery, critical CSS, a page's own browser caching (Cache-Control, fingerprinted filenames) and service-worker caching of a web app's assets, offloading work to Web Workers, smooth animation on the compositor, list virtualization and React re-render optimization, browser memory leaks and garbage-collection jank, third-party script impact, loading WebAssembly modules, rendering-strategy trade-offs for speed (CSR, SSR, SSG, streaming and partial hydration), and measuring and defending speed: user timing, real user monitoring against synthetic tests, performance budgets in CI, regression investigation and performance targets across teams. Server-side and system latency, CDN and edge caching, distributed caches, general state-management design, and design-system or visual-design decisions are covered elsewhere.
A product manager says the page feels slow. How do you turn that vague complaint into measurable metrics and concrete engineering tasks?
Sample Answer
Direct answer
"Feels slow" is a symptom, so the first job is to find out which of several different problems the PM means, then attach a metric, a target and a way to measure to each one. The sequence: ask five clarifying questions, map each answer to a Core Web Vital or a custom timing, check field data to see whether and where the problem is real, set targets, and write one ticket per cause with a metric, an owner and a verification step. Then report back in the PM's language (which users, which page, from what to what).
Step 1: turn the complaint into five questions
- Which page or flow? (Home, search results, checkout.)
- Which moment is slow? Waiting for content to appear, waiting after tapping a button, content jumping while reading, or slow only after the first navigation.
- Who? Which devices, networks, countries, browsers; does it happen on the PM's phone only? Later steps group visitors into segments (for example mobile versus desktop, or one country) and into device classes (low-end phones, mid-range phones, laptops), because a slow experience on one group can be invisible in the overall numbers.
- Since when? A step change on a date points to a release or a third-party tag; a slow drift points to growth in bundle or data size.
- Compared with what? A competitor, last month, or a gut feeling. Each needs a different baseline.
Step 2: map the symptom to a metric
| What the PM says | What is probably happening | Metric |
|---|---|---|
| "The page takes forever to show up" | The main content appears late | LCP (Largest Contentful Paint), plus TTFB (time to first byte) to see whether the server or the front end is responsible |
| "I tap and nothing happens" | The main thread is busy or the next frame is slow to draw | INP (Interaction to Next Paint) |
| "Things move while I read / I tapped the wrong button" | Content shifts as images, banners or fonts arrive | CLS (Cumulative Layout Shift) |
| "Search results feel slow" | A flow-specific wait that no standard metric covers | A custom measurement: performance.mark stamps a named moment, and performance.measure reports the time between two stamps (snippet below) |
The good thresholds, measured at the 75th percentile of page loads (written p75 from here on: the value that 75% of visits are at or below, so the slowest 25% of visits are the ones allowed to be worse): LCP 2.5 seconds or less, INP 200 milliseconds or less, CLS 0.1 or less (web.dev).
The custom measurement for the search flow looks like this (the two marks sit in the keystroke handler and in the code that renders the results; here a 50 ms timer stands in for the wait):
performance.mark('keystroke');
setTimeout(() => {
performance.mark('results-rendered');
const m = performance.measure('search-latency', 'keystroke', 'results-rendered');
console.log(m.name, m.duration > 0);
}, 50);
Run in a node:22 container (Node 22), it prints search-latency true; in a browser the same measure entry carries the duration to send to analytics.
Step 3: check whether it is real and where
- Field data (measurements from real visitors, collected with the
web-vitalslibrary into your analytics, or from the Chrome User Experience Report, called CrUX, which is Google's public dataset of real Chrome users' measurements per site): segment by page template (all product pages share one template and one layout, so they tend to share one problem), device class and country. This tells you whether the PM's phone is typical. - Lab reproduction with Lighthouse or a DevTools Performance trace using CPU and network throttling, on the page and device class that field data flagged. The lab tells you why, the field tells you how many people.
Why the 75th percentile and not the average: a small illustrative set of eight LCP samples, in seconds, sorted: 1.2, 1.6, 1.9, 2.1, 2.4, 2.8, 3.5, 4.2. The middle (median) is (2.1 + 2.4) / 2 = 2.25 s, which looks fine. The 75th percentile by the nearest-rank method is the value at position ceil(0.75 x 8) = 6, which is 2.8 s, over the 2.5 s limit. Equivalently only 5 of 8 samples (62.5%) are within 2.5 s, short of the 75% needed. The page fails even though the typical visit passes, and that gap is exactly what a PM who hears about slow visits is picking up. In the sorted list the p75 position is found by counting from the fastest sample: 6 of the 8 samples are at or below the value at position 6. (Different tools interpolate percentiles slightly differently; the point holds either way.)
Step 4: turn findings into tasks
Each ticket has a metric, a target, an owner and a check:
| Finding | Task | Metric and target | How it is verified |
|---|---|---|---|
| Product page mobile LCP at the 75th percentile is over 2.5 s; the hero image is fetched late | Put the hero image in the HTML, fetchpriority="high", no lazy loading | Mobile p75 LCP of 2.5 s or less | Field dashboard two weeks after release, plus a lab trace |
| Taps on "Add to cart" feel dead on low-end phones | Break up the click handler's work: do only the visible update (button state, cart count) inside the handler, and push the analytics call and any heavy recalculation to after the next paint (for example requestAnimationFrame(() => setTimeout(fn, 0)): the animation-frame callback runs just before the browser draws, and the timer inside it runs in a new task after that frame; a bare setTimeout(fn, 0) is not enough, because the timer can fire before any frame is drawn) | p75 INP of 200 ms or less | Lab trace on throttled CPU, then field INP |
| The price jumps when the promo banner loads | Reserve the banner's height | CLS of 0.1 or less on that template | Layout-shift entries in a Playwright check (Chromium only), then field CLS |
High TTFB (time to first byte: how long the HTML request waits before the first byte arrives, which is mostly server and network time that front-end rendering changes do not shorten; redirects and the page's own Cache-Control headers are the exceptions to check first) on the HTML request | Hand to the backend owner with the trace, so they see which requests wait and for how long | TTFB target agreed with them | Server timing in the same trace |
The targets above are the published "good" thresholds, not predictions of what each change will achieve; you only know the result after measuring.
Step 5: report back in PM language
Example wording (the 38% is an invented figure): "On mobile, 38% of product-page visits take longer than 2.5 seconds to show the main image. The cause is late image discovery. We expect to bring that under the threshold; we will confirm with two weeks of field data." Only quote a percentage you computed from your own data, state the segment, and promise a measurement date rather than an outcome.
Pitfalls
- Optimising whatever Lighthouse flags first without checking it matches the complaint.
- Using a single lab score as the target: it is one run on one simulated device.
- Averages hiding the slow tail.
- Treating a server problem as a front-end task: separate TTFB from the rest of LCP before assigning work.
- Letting "fast" be defined by the engineering team's laptop.
Design an end-to-end approach to serving images in modern formats at the right sizes: when to generate variants, how the right one reaches each browser, how it is cached, and which loading choices protect LCP.
Sample Answer
Direct answer
Generate a fixed ladder of widths in AVIF, WebP and JPEG, eagerly for images that can be the main content of a page and on first request for the long tail. Let the browser choose among them with <picture> and srcset so every variant has its own URL, fingerprint each URL (a hash of the file's content sits in its name, so changed content means a new URL) and cache it for a year, keep the HTML itself revalidating (the browser asks the server whether its stored copy is still current before using it), and protect LCP (Largest Contentful Paint, the moment the largest visible element finishes rendering; web.dev's target is 2.5 seconds) by making the hero a real <img> in the HTML with fetchpriority="high", explicit dimensions and no lazy loading.
When to generate variants
| Image kind | When | Why |
|---|---|---|
| Hero and other above-the-fold images on key pages | At upload or build time | They decide LCP, so they must exist before the first visitor asks |
| Long-tail catalogue images | On first request, then stored | Most variants of most images are never requested, so pre-making them wastes storage and encoding time |
| Never | On every request without storing the result | Pays the encode cost repeatedly |
The ladder is the set of widths you produce. Derive it from the layout, not from guesswork: take the widths your layout gives the image (say 360, 720 and 1200 CSS pixels at three breakpoints) and multiply by the densities you serve (1x and 2x). That gives six pixel widths, and the first candidate in a 480, 960 and 1920 ladder that is at least as wide serves each:
| Slot (CSS px) | Density | Pixels wanted | Ladder width used |
|---|---|---|---|
| 360 | 1x | 360 | 480 |
| 360 | 2x | 720 | 960 |
| 720 | 1x | 720 | 960 |
| 720 | 2x | 1440 | 1920 |
| 1200 | 1x | 1200 | 1920 |
| 1200 | 2x | 2400 | 1920 (the largest candidate; nothing is wide enough) |
Five of the six are covered; only the 2x full-width case falls short, and capping at 1920 is a deliberate choice because the extra bytes for a 2x full-width desktop hero rarely show. Never produce a width larger than the source (upscaling adds bytes, not detail) or larger than twice your biggest slot.
With three widths and three formats a fully pre-generated image is 3 x 3 = 9 files. For a catalogue of 10,000 images, 200 of them heroes: the heroes cost 200 x 9 = 1,800 files up front, and the other 9,800 images cost at most 9,800 x 9 = 88,200 files, created only for variants somebody requests. Everything pre-generated would be 90,000 files (1,800 + 88,200). Those counts are illustrative inputs.
The browser-visible cost of on-demand generation is that the very first request for a variant waits for the encode, so any image that can be a page's LCP element belongs in the eager row.
How the right variant reaches each browser
| Approach | How it works | Cost |
|---|---|---|
Markup: <picture> with type on each <source>, srcset widths and sizes | The browser picks format and width itself; every variant has a distinct URL | You control the HTML; one URL per variant to produce and cache |
Server negotiation (content negotiation: the server picks the format for one URL): the browser sends an Accept request header listing the image types it decodes, and the server returns AVIF, WebP or JPEG | Works for HTML you cannot change (content from a content management system, CSS backgrounds, third-party embeds) | The same URL now returns different bytes, so the browser's own cache must key on Accept: MDN says that including Vary "ensures that responses are separately cached based on the headers listed in the Vary field", used most often "to create a cache key when content negotiation is in use". The response must carry Vary: Accept, and the width must still be encoded in the URL |
What the exchange looks like: Chromium 130, asked for an <img>, sent the request header Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 (logged by adding console.log(req.headers.accept) to the demo's server below). A negotiating server that sees image/avif in it answers that URL with Content-Type: image/avif and Vary: Accept; a browser whose header lacks image/avif gets the next format on the list at the same URL.
Recommendation: markup first, because it needs no special cache rules and the browser also accounts for screen density. Use Accept negotiation only where the markup is out of your hands. The run at the end shows the markup path choosing the 960 pixel AVIF at 400 CSS pixels and 2x density.
How it is cached
Put a hash of the content in each variant's name, for example /img/hero.9f3a1c-960.avif. A new upload gets a new hash and a new URL, so the old URL's bytes never change and can be cached for a long time. MDN's cache-busting guidance gives this exact pattern: "You can add a long max-age value and immutable because the content will never change." The long max-age is what keeps the variant fresh; immutable adds a promise on top of it.
# /img/* (fingerprinted image variants)
Cache-Control: max-age=31536000, immutable
# /index.html (the page that names the current fingerprints)
Cache-Control: no-cache
31,536,000 seconds is 365 days. immutable tells the browser the response will not change while it is fresh, so a user reload need not send a conditional request for it (MDN: it "avoids those kinds of unnecessary conditional requests to the server"). Browsers differ in whether a reload revalidates subresources at all, so treat immutable as the safe addition and the year-long max-age as the part that does the work. no-cache on the HTML makes the browser revalidate each time (send a conditional request and use the stored copy only if the server confirms it is current), so a page always names the current images (MDN recommends it for the HTML that references hashed assets). The demo at the end sends the first header and confirms that a second page view does not request the image again. In headless Chromium 130 that result comes from the freshness lifetime: the same check still passes with immutable removed, and replacing the header with no-cache gives two requests.
Which loading choices protect LCP
- Make the hero discoverable in the HTML. web.dev lists "The LCP element requires a CSS background image." among the cases where the image is not discoverable from the HTML: the browser learns the URL only after the stylesheet is applied. Use an
<img>in the markup, or a preload. - Set
fetchpriority="high"on that one image (web.dev: "It's a good idea to set fetchpriority="high" on an <img> element if you think it's likely to be your page's LCP element", and more than one or two high-priority images make priority "unhelpful"). - Never lazy-load it. web.dev: "Never lazy-load your LCP image, as that will always lead to unnecessary resource load delay, and will have a negative impact on LCP."
- Give it
widthandheightso layout does not shift when it arrives. - Preload with the same candidate list when the markup cannot be changed, so the preload and the real element pick the same file (the demo checks for a single request).
web.dev's guideline split of LCP time is about 40% time to first byte, under 10% resource load delay, about 40% resource load duration and under 10% element render delay, "guidelines, not strict rules". (Time to first byte is how long the server takes to start answering.) Image work shrinks the two middle parts: delay (steps 1 to 3) and duration (the ladder and formats). For a 2,500 ms target that guideline is 1,000 + at most 250 + 1,000 + at most 250 ms.
Practical per-image optimizations
Five practical optimizations apply to every image:
- Format: AVIF or WebP first, JPEG as the fallback, chosen by the browser.
- Several sizes: the width ladder above, so phones do not download desktop files.
- Lazy loading:
loading="lazy"for images below the fold, never for the LCP image. - Dimensions:
widthandheight(oraspect-ratio) on every image. - Compression quality: one quality setting per format judged by eye on real images, applied by the pipeline rather than by each editor.
Verified: one request, right variant, cached
The demo serves a fingerprinted AVIF ladder to an image slot 400 CSS pixels wide (sizes="50vw" in an 800 pixel viewport) at 2x density, with a preload (a <link rel="preload"> that makes the browser fetch the image early) that carries the same imagesrcset and imagesizes (the preload's copies of srcset and sizes) as the <source>, then reloads the page. It prints every image request the server saw. A preload that leaves out imagesizes assumes 100vw, picks a different file and the check fails. The check proves that the preload and the element agree on one file; it does not measure how much earlier the preload starts the download.
// preload-demo.mjs
import http from 'node:http';
import { chromium } from 'playwright';
const BREAK = process.env.BREAK || ''; // 'nosizes' drops imagesizes from the preload, so it assumes 100vw and fetches a different file
const PIXEL = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const SET = '/img/hero.9f3a1c-480.avif 480w, /img/hero.9f3a1c-960.avif 960w, /img/hero.9f3a1c-1920.avif 1920w';
const PRELOAD = BREAK === 'nosizes'
? `<link rel="preload" as="image" type="image/avif" imagesrcset="${SET}" fetchpriority="high">`
: `<link rel="preload" as="image" type="image/avif" imagesrcset="${SET}" imagesizes="50vw" fetchpriority="high">`;
const PAGE = `<!doctype html><head>${PRELOAD}</head><body style="margin:0"><picture>
<source type="image/avif" srcset="${SET}" sizes="50vw">
<img src="/img/hero.9f3a1c-960.jpg" width="1920" height="1080" alt="" fetchpriority="high" style="width:50%;height:auto">
</picture></body>`;
const hits = [];
const server = http.createServer((req, res) => {
if (req.url === '/') { res.setHeader('content-type', 'text/html'); return res.end(PAGE); }
hits.push(req.url);
res.setHeader('content-type', req.url.endsWith('.avif') ? 'image/avif' : 'image/jpeg');
res.setHeader('cache-control', 'max-age=31536000, immutable'); // what a fingerprinted variant should send
res.end(PIXEL);
}).listen(0);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 800, height: 800 }, deviceScaleFactor: 2 });
const page = await ctx.newPage();
await page.goto(`http://localhost:${server.address().port}/`);
await page.waitForFunction(() => document.querySelector('img').complete);
await page.waitForTimeout(300);
await page.reload(); // second view: the immutable variant must not be requested again
await page.waitForTimeout(500);
console.log('requests for images:', JSON.stringify(hits));
const ok = hits.length === 1 && hits[0] === '/img/hero.9f3a1c-960.avif';
console.log(`${ok ? 'PASS' : 'FAIL'}: one request in total across two page views, for the 960 px AVIF variant`);
await browser.close(); server.close(); process.exit(ok ? 0 : 1);
requests for images: ["/img/hero.9f3a1c-960.avif"]
PASS: one request in total across two page views, for the 960 px AVIF variant
Reading it: the server logs one image request only. The page view chose the 960 px file (50vw of 800 is 400 CSS px, x 2 = 800 pixels wanted, so 960), the preload and the <picture> agreed on it so it was fetched once, and the reload was served from the browser's cache because the variant was still fresh under its max-age=31536000. With BREAK=nosizes the preload drops imagesizes, assumes 100vw (1600 pixels wanted at 2x) and fetches the 1920 px file as well, so the log shows two requests (["/img/hero.9f3a1c-1920.avif","/img/hero.9f3a1c-960.avif"]) and the check prints FAIL with exit code 1.
Trade-offs and pitfalls
- More variants means more storage and more URLs to keep consistent. Cap the ladder and formats; add a width only when RUM (real user monitoring: measurements taken in actual visitors' browsers) shows a population that downloads a wastefully large file.
- A wrong
sizesvalue defeats the ladder: the browser trusts it when picking a width. - Fingerprints need a build or upload step that rewrites every reference; a missing rewrite serves stale images for a year.
- Always provide the JPEG: browsers that cannot decode AVIF or WebP get it.
Running the code
mkdir demo && cd demo && echo '{"type":"module"}' > package.json
npm i playwright@1.48.2 # save the listing above as preload-demo.mjs, then:
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy node preload-demo.mjs
# add -e BREAK=nosizes before the image name to drop imagesizes from the preload: it fetches the 1920 px file as well, FAIL line, exit code 1
The printed output comes from the mcr.microsoft.com/playwright:v1.48.2-jammy image (headless Chromium 130.0.6723.31, aarch64 Linux container).
For a React and TypeScript app, how would you configure webpack or Vite so routes and heavy components load on demand and third-party libraries lose their unused code? Cover chunk naming and prefetch behaviour.
Sample Answer
Direct answer
Use dynamic import() (a function-like form of import that returns a promise and tells the bundler "this module goes in its own file, loaded when this line runs") at every route and every heavy component, and let the bundler turn each one into a chunk (a separate output file). Remove unused library code with tree shaking (the bundler drops exports nothing imports), which only works when you import from libraries that ship ES module syntax (import / export) and you use named imports: lodash-es, not lodash. Name the stable vendor code explicitly so it caches across releases, and decide per chunk whether it deserves a prefetch hint (fetch in idle time for a likely future navigation) or a preload (needed during the current navigation).
The worked setup below uses Vite with React and TypeScript, measured in a real build, followed by the webpack equivalents.
Vite configuration
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// Named vendor chunk: react changes rarely, so it keeps its hash across app releases.
manualChunks: { 'vendor-react': ['react', 'react-dom'] },
// Vite's default pattern, written out so the name format is visible: assets/Reports-<hash>.js
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
});
// src/main.tsx
import { lazy, Suspense, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { debounce } from 'lodash-es'; // named import from the ES-module build: only debounce is kept
const loadReports = () => import('./pages/Reports'); // dynamic import() = split point
const Reports = lazy(loadReports);
function App() {
const [page, setPage] = useState<'home' | 'reports'>('home');
const onType = debounce((v: string) => console.log(v), 200);
return (
<main>
<input onChange={(e) => onType(e.target.value)} />
{/* Hovering or focusing the link warms the chunk, so the click usually finds it already loaded */}
<button id="go" onMouseEnter={loadReports} onFocus={loadReports} onClick={() => setPage('reports')}>
Reports
</button>
{page === 'reports' && (
<Suspense fallback={<p id="fb">Loading...</p>}>
<Reports />
</Suspense>
)}
</main>
);
}
createRoot(document.getElementById('root')!).render(<App />);
// src/pages/Reports.tsx
import { marked } from 'marked'; // heavy dependency that only this route needs
export default function Reports() {
return <article id="reports" dangerouslySetInnerHTML={{ __html: marked.parse('# REPORTS_ROUTE_MARKER') as string }} />;
}
What each piece does:
lazy(loadReports)withimport('./pages/Reports')is the split point.marked(a Markdown library standing in for any heavy dependency) is only imported inside that file, so it lands in the Reports chunk and never in the first load.import { debounce } from 'lodash-es'is a named import from an ES-module build, so onlydebounceand what it calls are kept.manualChunkspulls React intovendor-react-<hash>.js;chunkFileNamescontrols the pattern of every non-entry chunk (the value shown is Vite's default, so deleting the line changes nothing; set a different pattern to rename chunks), where[name]is the source file name (or themanualChunkskey) and[hash]is a fingerprint of the file's content, so the name changes only when the content does. The per-importwebpackChunkNamecomment belongs to webpack.- Prefetch behaviour: Vite's docs say it generates
modulepreloaddirectives (<link rel="modulepreload">tags that make the browser download and parse a JavaScript module early) "for entry chunks and their direct imports" (in the built HTML; the builtindex.htmlin this run contains one forvendor-react) and rewrites dynamic imports with a preload step so a chunk's own dependencies are fetched in parallel with it. It does not prefetch your lazy routes in idle time. That is your decision: the demo warms the Reports chunk ononMouseEnterandonFocus, so by the click the chunk has already been requested (the check below sees exactly one request, made on hover, and one more in a fresh page after keyboard focus alone), and you can callloadReports()fromrequestIdleCallbackfor a route that most users visit next. MDN marksrequestIdleCallbackas not Baseline (Safari has historically lacked it), so feature-detect it and fall back to a timer:(window.requestIdleCallback ?? ((f) => setTimeout(f, 200)))(loadReports).
Checking the claims in a real build
check.mjs has three parts. First it lists every built .js file with its raw and gzip (compressed, as a server would send it) size and sets a gzip budget for the entry chunk (the file index.html loads first). Second it asserts the shape: the Reports code is its own chunk, none of it leaked into the entry, a vendor-react chunk exists, and index.html does not reference the Reports chunk. Third it starts Vite's preview server, loads the built app in Chromium, and counts network requests for the Reports chunk after load, after hovering the button and after clicking it, then opens a second page and counts requests after keyboard focus alone; the script exits with code 1 if any assertion fails.
// check.mjs: assert the built output has the shape the answer claims, then drive it in Chromium.
import fs from 'node:fs'; import zlib from 'node:zlib';
import { preview } from 'vite'; import { chromium } from 'playwright';
const BUDGET_ENTRY_GZ = 6 * 1024; // gzip budget for the entry chunk (app code + the one lodash function)
const files = fs.readdirSync('dist/assets').filter((f) => f.endsWith('.js'));
const read = (f) => fs.readFileSync(`dist/assets/${f}`, 'utf8');
const gz = (f) => zlib.gzipSync(fs.readFileSync(`dist/assets/${f}`)).length;
const find = (p) => files.find((f) => f.startsWith(p));
for (const f of files) console.log(f.padEnd(34), String(fs.statSync(`dist/assets/${f}`).size).padStart(7), 'B raw', String(gz(f)).padStart(6), 'B gzip');
const entry = find('index-'), reports = find('Reports-'), vendor = find('vendor-react-');
const html = fs.readFileSync('dist/index.html', 'utf8');
const fail = [];
if (!reports || !read(reports).includes('REPORTS_ROUTE_MARKER')) fail.push('Reports route is not its own chunk');
if (entry && read(entry).includes('REPORTS_ROUTE_MARKER')) fail.push('Reports code leaked into the entry chunk');
if (!vendor) fail.push('no vendor-react chunk');
if (!entry || gz(entry) > BUDGET_ENTRY_GZ) fail.push(`entry gzip ${entry && gz(entry)} B over budget ${BUDGET_ENTRY_GZ} B`);
if (reports && html.includes(reports)) fail.push('Reports chunk is referenced from index.html');
// Browser: no Reports request on load, one request on hover, none extra on click.
const server = await preview({ preview: { port: 0 } });
const url = server.resolvedUrls.local[0];
const b = await chromium.launch(); const p = await b.newPage(); const seen = [];
p.on('request', (r) => seen.push(new URL(r.url()).pathname));
await p.goto(url); await p.waitForSelector('#go');
const onLoad = seen.filter((u) => u.includes('Reports-')).length;
await p.hover('#go'); await p.waitForFunction(() => performance.getEntriesByType('resource').some((e) => e.name.includes('Reports-')));
const afterHover = seen.filter((u) => u.includes('Reports-')).length;
await p.click('#go'); await p.waitForSelector('#reports');
const afterClick = seen.filter((u) => u.includes('Reports-')).length;
console.log(`Reports chunk requests: on load ${onLoad}, after hover ${afterHover}, after click ${afterClick}`);
if (onLoad !== 0 || afterHover !== 1 || afterClick !== 1) fail.push('lazy/prefetch request pattern is wrong');
// Keyboard users: focusing the button in a fresh page must warm the chunk too.
const p2 = await b.newPage(); const seen2 = [];
p2.on('request', (r) => seen2.push(new URL(r.url()).pathname));
await p2.goto(url); await p2.waitForSelector('#go'); await p2.focus('#go');
await p2.waitForFunction(() => performance.getEntriesByType('resource').some((e) => e.name.includes('Reports-')));
const afterFocus = seen2.filter((u) => u.includes('Reports-')).length;
console.log(`Reports chunk requests after focus alone: ${afterFocus}`);
if (afterFocus !== 1) fail.push('focus does not warm the Reports chunk');
await b.close(); await server.close();
if (fail.length) { console.log('FAIL:', fail.join('; ')); process.exit(1); }
console.log('PASS');
The first command in the last section prints these lines (Vite 5.4.11, React 18.3.1, lodash-es 4.17.21, marked 14.1.4; npm and Vite progress lines left out). The sizes are bytes, gzip with Node's default zlib level, and the hash in each file name is the fingerprint described above:
Reports-ClK8h0y8.js 36449 B raw 11455 B gzip
index-DUdyq3JX.js 5587 B raw 2716 B gzip
vendor-react-SIwY82C9.js 140739 B raw 45207 B gzip
Reports chunk requests: on load 0, after hover 1, after click 1
Reports chunk requests after focus alone: 1
PASS
exit=0
Read the table top to bottom: the Reports chunk is 36,449 B because it carries marked, the entry is 5,587 B because it holds only the app code and one lodash-es function, and the React vendor chunk is separate so it stays cached while the app code changes.
The second command in the last section replaces the named import with import _ from "lodash"; const debounce = _.debounce; (the CommonJS build of the whole library, which a bundler cannot shake) and rebuilds. Only the entry changes: 75,442 B raw and 28,381 B gzip instead of 5,587 and 2,716. That run prints:
Reports-B0AUNDOG.js 36449 B raw 11454 B gzip
index-bW-XIoSy.js 75442 B raw 28381 B gzip
vendor-react-BECWLT86.js 140970 B raw 45291 B gzip
Reports chunk requests: on load 0, after hover 1, after click 1
Reports chunk requests after focus alone: 1
FAIL: entry gzip 28381 B over budget 6144 B
exit=1
The budget is 6 x 1024 = 6,144 B, and one function pulled in the wrong way adds 28,381 - 2,716 = 25,665 B of gzip to the first load.
The same setup in webpack
| Goal | webpack setting | Source of the behaviour |
|---|---|---|
| Split a route or heavy component | import('./Reports') | webpack's guide calls import() the "recommended approach" for dynamic code splitting |
| Name the chunk | import(/* webpackChunkName: "reports" */ './Reports') | the chunk is named [my-chunk-name].js instead of [id].js |
| Shared and vendor code | optimization: { splitChunks: { chunks: 'all' } } (webpack's setting for pulling shared modules into their own chunks) | common dependencies are extracted only if they meet webpack's size thresholds |
| Prefetch a likely next route | import(/* webpackPrefetch: true */ './Reports') | adds <link rel="prefetch"> once the parent chunk has loaded; fetched in browser idle time |
| Preload something the current page needs | /* webpackPreload: true */ | starts in parallel with the parent at medium priority; the guide warns that using it incorrectly "can actually hurt performance" |
| Take only one export of a dynamic import | /* webpackExports: ["default"] */ (names the exports to keep from the chunk) | can decrease the output size of a chunk; cannot be used with destructuring assignments |
| Tree shaking | production mode, ES module syntax, "sideEffects" in package.json (a flag saying whether importing a file does anything beyond providing exports) | the guide: tree shaking "relies on the static structure of ES2015 module syntax"; "sideEffects": false tells webpack unused exports can be pruned |
Two webpack tree shaking traps: a compiler that rewrites ES modules to CommonJS before webpack sees them (the guide names @babel/preset-env, whose modules option controls it) disables tree shaking for that code, and a file imported only for its side effect (a CSS import) must be listed in the sideEffects array or production mode will drop it.
TypeScript choices that change the bundle
Vite compiles TypeScript file by file without type checking, so its docs ask for isolatedModules: true (a compiler setting that reports any code that cannot be compiled correctly one file at a time) and note that the transformer does not support const enum and implicit type-only imports; type checking is a separate tsc --noEmit (check the types, write no files). These settings change how many bytes of helper and wrapper code reach the bundle. Run in a node:22 container with TypeScript 5.6.3, ts-emit.sh shows what tsc itself emits:
#!/bin/sh
# ts-emit.sh: what TypeScript emits for enum / const enum / as const / namespace, and for async at two targets.
set -e
cat > emit.ts <<'EOF'
export enum Level { Low, High }
export const enum Mode { Fast = 'fast', Slow = 'slow' }
export const Size = { S: 's', L: 'l' } as const;
export namespace Util { export const id = (x: number) => x; }
export const m = Mode.Fast;
EOF
echo 'export async function a() { return 1; }' > a.ts
echo 'export async function b() { return 2; }' > b.ts
npx tsc --target ES2020 --module ESNext --outDir out emit.ts
cat out/emit.js
for t in ES2015 ES2017; do
npx tsc --target $t --module ESNext --outDir out_$t a.ts b.ts
echo "$t: inlined __awaiter copies = $(cat out_$t/a.js out_$t/b.js | grep -c 'var __awaiter')"
done
npx tsc --target ES2015 --module ESNext --moduleResolution Bundler --importHelpers --outDir out_ih a.ts b.ts
echo "ES2015 + importHelpers: inlined copies = $(cat out_ih/a.js out_ih/b.js | grep -c 'var __awaiter'); first line: $(head -1 out_ih/a.js)"
export var Level;
(function (Level) {
Level[Level["Low"] = 0] = "Low";
Level[Level["High"] = 1] = "High";
})(Level || (Level = {}));
export const Size = { S: 's', L: 'l' };
export var Util;
(function (Util) {
Util.id = (x) => x;
})(Util || (Util = {}));
export const m = "fast" /* Mode.Fast */;
ES2015: inlined __awaiter copies = 2
ES2017: inlined __awaiter copies = 0
ES2015 + importHelpers: inlined copies = 0; first line: import { __awaiter } from "tslib";
| Construct | What is emitted | Bundle consequence |
|---|---|---|
enum | a function call that builds an object (Level) | runtime code; a bundler cannot always prove an unused one is removable |
namespace | the same kind of function call (Util) | same; prefer ES modules |
const enum | no object; tsc inlines the value ("fast") | smallest, but needs whole-program knowledge that per-file transpilers do not have, which is why isolatedModules projects avoid it |
object as const plus a union type | a plain object literal | what a bundler can analyse and often drop |
target below the syntax you use (async at ES2015) | a helper (__awaiter, the function that implements async / await on older targets) copied into every file that needs it | raise target to what your browsers support, or set importHelpers so one shared copy is imported from tslib (TypeScript's library of helper functions) |
import type { X } / verbatimModuleSyntax (keep imports exactly as written, so only import type ones are erased) | the import is erased entirely | stops type-only imports from pulling modules into the bundle |
Keep module at ESNext (not CommonJS) so import and export syntax survives to the bundler.
Trade-offs and common wrong turns
- Splitting every component: each chunk is another request, and on a slow link two sequential round trips (parent, then chunk) cost more than the bytes saved. Split at routes and at heavy libraries.
- Preloading everything: preload competes with the resources the current page needs; use prefetch or intent-based warming for future navigations.
- Trusting
sideEffects: falseblindly: it is a promise to the bundler. A module that registers a global or a CSS import marked as side-effect free gets deleted in production. - Measuring only the dev server: tree shaking and chunking happen in the production build, so read sizes from that output, as the check does. These sizes are for this small app; the proportions, not the numbers, carry over.
Running the code
# folder holding vite.config.ts, src/main.tsx, src/pages/Reports.tsx, check.mjs, ts-emit.sh (all above)
echo '<!doctype html><div id="root"></div><script type="module" src="/src/main.tsx"></script>' > index.html
echo '{"compilerOptions":{"target":"ES2020","module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"isolatedModules":true,"verbatimModuleSyntax":true,"skipLibCheck":true,"noEmit":true},"include":["src"]}' > tsconfig.json
echo '{"type":"module","private":true,"sideEffects":false}' > package.json
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy sh -c '
npm i react@18.3.1 react-dom@18.3.1 lodash-es@4.17.21 marked@14.1.4 &&
npm i -D vite@5.4.11 @vitejs/plugin-react@4.3.4 typescript@5.6.3 @types/react@18.3.12 \
@types/react-dom@18.3.1 @types/lodash-es@4.17.12 playwright@1.48.2 &&
npx tsc -p . && npx vite build && node check.mjs; echo exit=$?
# the same app with the CommonJS build of all of lodash instead of one lodash-es function
npm i lodash@4.17.21 && npm i -D @types/lodash@4.17.13
sed -i "s|^import { debounce } from .lodash-es.;.*|import _ from \"lodash\"; const debounce = _.debounce;|" src/main.tsx
npx vite build && node check.mjs; echo exit=$?'
# TypeScript emit:
docker run --rm -v "$PWD":/w -w /w node:22 sh -c 'npm i typescript@5.6.3 tslib@2.8.1 && sh ts-emit.sh'
A lazily loaded React route fails to download on a flaky connection. What does the user see today, how do you handle that failure, and what other pitfalls come with lazy loading components?
Sample Answer
Direct answer
A lazily loaded route is code that is not in the first JavaScript file. The bundler (the build tool that packs your source into files) puts it in a separate file called a chunk, and React.lazy(() => import('./Settings')) downloads that chunk the first time the component renders. While the download is pending, the nearest <Suspense> boundary shows its fallback. If the download fails, React rethrows the rejection to the nearest error boundary (a class component that catches errors thrown while rendering its children). With no error boundary anywhere above it, the error is uncaught and the whole React tree is removed, so the user sees a blank page. In the demo below the #root element ended up empty after the failed chunk request.
The handling is layered: an error boundary placed outside the Suspense boundary with a visible Retry button, a retry that really asks the network again, a fallback that reserves the space the real content will take, and a one-time reload for the case where retrying cannot help (an old tab asking for a file that a deploy has since removed).
What the user sees, in order
- The user clicks to the route. React renders the lazy component, which calls the
import()once and suspends (React pauses that part of the tree and shows the closest fallback). - The request is in flight: the
Suspensefallback is on screen. - The request fails (connection reset, timeout, offline). The promise rejects, and the React docs say "If the Promise rejects, React will
throwthe rejection reason for the nearest Error Boundary to handle." - With a boundary: its fallback UI renders. Without one: the tree unmounts and the page is blank (printed as
page blanked = truebelow).
Handling the failure
- Boundary outside Suspense. Error boundaries must be class components, because only a class can define
getDerivedStateFromError(the static method React calls with the thrown error so the class can keep it in state and render a fallback). Put the boundary above theSuspenseso one boundary covers both states, and give it a message plus a Retry button. - Retry needs two new things. A
lazy()object remembers its rejection, so Retry must create a newlazy()(the demo does this with auseMemokeyed on an attempt counter, and akeyon the boundary). Second, the browser itself may remember the failure: in the Chromium 130 build that ships with Playwright 1.48.2, importing the same URL again after a failure rejected again (the secondimport()never reached the network), while the same file with a?retry=1query succeeded. So the demo changes the URL on each retry, and withBREAK=1(the same URL on every retry) the Settings content never appears and the run ends withpage.waitForSelector: Timeout 3000ms exceeded.and exit status 1 (the command is in the last section). A bundler rewritesimport('./Settings')into a hashed file URL (a file name containing a fingerprint of the content, such assettings.3f9a1c.js) that you cannot append a query to, so test your own build's retry in each target browser and keep a full page reload as the last resort. - Automatic retry first, user retry second. One or two quick automatic retries with a short delay absorb a blip; after that, stop and show the button instead of looping against a dead connection.
- Stale deploy. If the page was opened before a release and the old hashed file is now gone (HTTP 404), no retry helps. Reload the page once, guarded by a flag in
sessionStorage(a per-tab key-value store that survives a reload but not closing the tab) so a persistent failure cannot cause a reload loop, and report the error to your monitoring.
Other pitfalls of lazy loading
| Pitfall | Why it happens | Mitigation |
|---|---|---|
| Layout shift from the fallback | A short placeholder is replaced by tall content, pushing everything below it down | Give the fallback the real content's height (a skeleton: a grey placeholder shaped like the content). Measured below: 0.1839 without it, 0.0000 with it |
| Late discovery (waterfall: requests that start one after another instead of together) | The chunk is requested only after the parent code runs, so the page waits for two round trips | Warm the chunk on intent (hover or focus: call the same import() early, for example onMouseEnter={() => import('./Settings')}, so the chunk is already requested when the click arrives) or prefetch in idle time |
| Over-eager prefetch | Prefetching downloads bytes the user may never use | Prefetch only likely next routes. webpack's webpackPrefetch comment (written inside import(), it asks the browser to fetch the chunk for a likely later navigation) is fetched "while the browser is idle" and, per the webpack guide, webpackPreload (fetch the chunk at once, in parallel with its parent) used incorrectly "can actually hurt performance" |
lazy() declared inside a component | A new lazy object each render resets the state below it | Declare it at module level, as the React docs require (the demo's useMemo keeps one lazy per attempt on purpose) |
| Default export only | lazy reads the module's default export | Re-export named components as default or wrap in .then((m) => ({ default: m.Settings })) |
| Too many tiny chunks | Each chunk costs a request and delays rendering | Split by route and by heavy library, not per component |
Measured demo
The script serves a React 18 page whose Settings chunk can be made to fail, drives it in headless Chromium, and exits non-zero unless all of these hold: the first load fails and Retry succeeds on the second request; a slow (but successful) load shows the fallback; a page with no boundary ends up blank; and a 40px fallback causes a layout shift above 0.1 while a fallback reserved at the real 300px height causes none. The layout-shift number is computed by the browser's layout-shift entries (PerformanceObserver), an entry type that only Chromium-based browsers provide (MDN marks LayoutShift "Limited availability"), which is why the demo runs in Chromium. The score is the impact fraction times the distance fraction: the related-content block (1264 by 400 px) moves 260px down, the union of its old and new positions is 1264 by 660 px of a 1280 by 720 viewport, so impact fraction = (1264 x 660) / (1280 x 720) = 0.9053, distance fraction = 260 / 1280 = 0.2031, and 0.9053 x 0.2031 = 0.1839. Re-running prints the same lines, because the numbers come from layout and not from timing, though they describe this page and this viewport, not your app.
The page in the script uses plain <script> tags and React's UMD builds, so there is no JSX: h is an alias for React.createElement, which is what JSX compiles to, and h('div', { id: 'fb' }, 'Loading') is <div id="fb">Loading</div>. The 1264 px width is the 1280 px viewport minus the browser's default 8 px body margin on each side. In the script, p.route(...) intercepts the browser's requests so the test can drop one (route.abort('connectionreset'), a simulated flaky network) or delay it; noBoundaryRun loads the page with ?noboundary, which swaps the boundary for a pass-through, and waits until #root is empty; clsRun rewrites the served HTML to make the fallback 40 px tall instead of 300 px and sums the browser's layout-shift entries.
// lazy-retry.mjs: React.lazy route chunk that fails once, with an error boundary and a retry.
import http from 'node:http';
import fs from 'node:fs';
import { chromium } from 'playwright';
const BREAK = process.env.BREAK === '1'; // BREAK=1 retries without changing the chunk URL
const react = (f) => fs.readFileSync(`node_modules/${f}`);
const page = `<!doctype html><meta charset=utf-8><div id=root></div>
<script src=/react.js></script><script src=/react-dom.js></script>
<script>
const { createElement: h, lazy, Suspense, Component, useState, useMemo } = React;
// The browser remembers a failed import() per URL, so a retry must change the URL.
const load = (n) => import('/chunk/settings.js' + (n && !${BREAK} ? '?retry=' + n : ''));
class Boundary extends Component { // error boundary: only class components can be one
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
render() {
return this.state.error
? h('div', { id: 'err', style: { minHeight: 300 } },
'Could not load Settings. ',
h('button', { id: 'retry', onClick: () => { this.setState({ error: null }); this.props.onRetry(); } }, 'Retry'))
: this.props.children;
}
}
function Loading() { window.fallbackSeen = true; return h('div', { id: 'fb', style: { height: 300 } }, 'Loading Settings...'); }
function App() {
const [attempt, setAttempt] = useState(0);
// A rejected lazy() stays rejected, so a retry needs a NEW lazy() object as well.
const Settings = useMemo(() => lazy(() => load(attempt)), [attempt]);
const Guard = location.search.includes('noboundary') ? ({ children }) => children : Boundary;
return h('div', null,
h(Guard, { key: attempt, onRetry: () => setAttempt((a) => a + 1) },
h(Suspense, { fallback: h(Loading) },
h(Settings))),
h('section', { id: 'related', style: { height: 400, background: '#eee' } }, 'Related content'));
}
ReactDOM.createRoot(document.getElementById('root')).render(h(App));
</script>`;
const server = http.createServer((req, res) => {
const u = req.url.split('?')[0];
if (u === '/') return res.setHeader('content-type', 'text/html'), res.end(page);
if (u === '/react.js') return res.setHeader('content-type', 'text/javascript'), res.end(react('react/umd/react.production.min.js'));
if (u === '/react-dom.js') return res.setHeader('content-type', 'text/javascript'), res.end(react('react-dom/umd/react-dom.production.min.js'));
if (u === '/chunk/settings.js') {
res.setHeader('content-type', 'text/javascript');
return res.end(`export default () => window.React.createElement('div', { id: 'ok', style: { height: 300 } }, 'Settings loaded');`);
}
res.statusCode = 404; res.end();
}).listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
async function run(failFirst) {
const browser = await chromium.launch();
const p = await browser.newPage({ viewport: { width: 1280, height: 720 } });
let chunkRequests = 0;
await p.route('**/chunk/settings.js*', async (route) => {
chunkRequests++;
if (chunkRequests <= failFirst) return route.abort('connectionreset'); // simulated flaky network
await new Promise((r) => setTimeout(r, 200)); // slow success
return route.continue();
});
const out = {};
await p.goto(base);
if (failFirst) {
await p.waitForSelector('#err');
out.afterFailure = (await p.textContent('#err')).trim();
await p.click('#retry');
} else {
await p.waitForFunction(() => window.fallbackSeen); // the fallback rendered at least once
out.fallbackShown = true;
}
await p.waitForSelector('#ok', { timeout: 3000 });
out.final = await p.textContent('#ok');
out.chunkRequests = chunkRequests;
await browser.close();
return out;
}
async function noBoundaryRun() {
const browser = await chromium.launch();
const p = await browser.newPage();
const errors = [];
p.on('pageerror', (e) => errors.push(e.message));
await p.route('**/chunk/settings.js*', async (route) => { await new Promise((r) => setTimeout(r, 300)); route.abort('connectionreset'); });
await p.goto(base + '/?noboundary');
await p.waitForFunction(() => window.fallbackSeen); // fallback first, then the failure
await p.waitForFunction(() => document.getElementById('root').innerHTML === '');
await browser.close();
return errors.length > 0;
}
async function clsRun(reserve) {
const browser = await chromium.launch();
const p = await browser.newPage({ viewport: { width: 1280, height: 720 } });
await p.addInitScript(() => {
window.__cls = 0;
new PerformanceObserver((l) => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value; })
.observe({ type: 'layout-shift', buffered: true });
});
await p.route('**/chunk/settings.js*', async (r) => { await new Promise((x) => setTimeout(x, 300)); r.continue(); });
// Variant: the fallback is 40px tall (not reserved) vs 300px tall (reserved, equals the real content height)
await p.route(base + '/', async (r) => {
const resp = await r.fetch(); let body = await resp.text();
if (!reserve) body = body.replace('id: \'fb\', style: { height: 300 }', 'id: \'fb\', style: { height: 40 }');
r.fulfill({ status: 200, contentType: 'text/html', body });
});
await p.goto(base);
await p.waitForSelector('#ok');
await p.waitForTimeout(500);
const cls = await p.evaluate(() => window.__cls);
await browser.close();
return cls;
}
const failed = await run(1);
console.log('retry flow :', JSON.stringify(failed));
const slow = await run(0);
console.log('slow, no fail :', JSON.stringify(slow));
const blank = await noBoundaryRun();
console.log('no boundary : page blanked =', blank);
const clsLoose = await clsRun(false), clsReserved = await clsRun(true);
console.log('layout shift : fallback 40px =', clsLoose.toFixed(4), '| fallback reserved 300px =', clsReserved.toFixed(4));
server.close();
const ok = failed.final === 'Settings loaded' && failed.chunkRequests === 2 && slow.fallbackShown
&& blank && clsLoose > 0.1 && clsReserved === 0;
console.log(ok ? 'PASS' : 'FAIL');
process.exit(ok ? 0 : 1);
The run command above prints:
retry flow : {"afterFailure":"Could not load Settings. Retry","final":"Settings loaded","chunkRequests":2}
slow, no fail : {"fallbackShown":true,"final":"Settings loaded","chunkRequests":1}
no boundary : page blanked = true
layout shift : fallback 40px = 0.1839 | fallback reserved 300px = 0.0000
PASS
exit=0
Trade-offs and common wrong turns
- Wrapping only the
Suspenseand forgetting the error boundary: the failure then takes down the whole app, not one route. - Retrying inside a
catchthat keeps the samelazy()object or the same URL, then concluding "retry does not work". - Showing a spinner of arbitrary size, which trades a loading problem for a CLS (Cumulative Layout Shift) problem; web.dev's good CLS threshold is 0.1 or less at the 75th percentile of page loads.
- Splitting everything: the first load gets smaller but every navigation becomes a network wait, which on a flaky connection is exactly where users see failures.
Running the code
# folder with lazy-retry.mjs (above) and package.json containing {"type":"module","private":true}
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy sh -c \
'npm i playwright@1.48.2 react@18.3.1 react-dom@18.3.1 && node lazy-retry.mjs; echo exit=$?'
# same check with retries on an unchanged URL; prints exit=1, then the timeout line:
docker run --rm -e BREAK=1 -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy sh -c \
'node lazy-retry.mjs > out.txt 2>&1; echo exit=$?; grep -m1 waitForSelector out.txt'
Your team keeps accidentally making a screen re-render more than it should after small changes. How would you write an automated check that fails when a component re-renders more times than expected during a user flow, and what are the limits of that check?
Sample Answer
Direct answer
Count how many times React calls the component while a scripted user flow runs, and assert the count stays within a budget. Wrap the component in a tiny counter, render the screen in a test environment, perform the flow (type, click, navigate), then compare the counts with a number checked into the test. The limits: a count says how often something rendered, not how long it took or whether it mattered; the number depends on the mode (React's StrictMode, a development-only wrapper that deliberately calls components twice to expose code that is not pure, doubles the count); and the budget needs an owner or it is either raised blindly or ignored.
Terms: a re-render is React calling your component function again; memo (React.memo) skips a re-render when props are shallowly equal (compared key by key with Object.is); a budget is the maximum count you accept; Object.is is JavaScript's same-value comparison, so a fresh {} is never equal to the previous {} even when the contents match; displayName is the name React DevTools shows for a component; findBy... is a Testing Library query that keeps retrying until the element appears or a timeout passes, so it waits for asynchronous updates.
The check
- Instrument. A helper
counted(name, Component)incrementscounts[name]each time React calls the function. It callsComp(props)directly, as a plain function, so React sees one component (the wrapper) and the count is that component's own render count; renderingCompas a child element would add a second component to the tree. - Drive a realistic flow. The test renders the screen with React Testing Library (under jsdom, a Node implementation of the DOM) and types three keystrokes into an unrelated field using
fireEvent.change. - Assert against a budget. Mount counts as one render. A component that does not depend on the note field must not render again. The budget for
Summaryis therefore 1: the mount, plus 0 for each of the 3 keystrokes. In practice the budget lives in the test file next to the flow it covers (for example{ Summary: 1 }beside the checkout test), and the team that owns that screen reviews any pull request that raises it. - Show the failure. A guard that has never failed has not been shown to work, so the test also runs a deliberately regressed version and asserts the guard throws.
Demo
The regression is the classic one: the parent passes a fresh { color: 'gray' } object as the style prop on every render. The memoized Summary compares props shallowly, sees a new object each time and re-renders on every keystroke.
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const dom = new JSDOM('<!doctype html><body></body>');
globalThis.window = dom.window; globalThis.document = dom.window.document;
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
const { default: React, useState, memo, StrictMode } = await import('react');
const { render, fireEvent, cleanup } = await import('@testing-library/react');
const h = React.createElement;
// Render counter: wraps a component and counts every time React calls it.
const counts = {};
const counted = (name, Comp) => {
const Wrapped = props => { counts[name] = (counts[name] ?? 0) + 1; return Comp(props); };
Wrapped.displayName = name;
return Wrapped;
};
const Summary = memo(counted('Summary', ({ items, style }) =>
h('p', { style }, `${items.length} items`)));
// Screen with a note field. Typing in the note must not re-render Summary.
const makeScreen = ({ regressed }) => {
const items = [{ id: 1 }, { id: 2 }]; // stable reference
return function Screen() {
const [note, setNote] = useState('');
// Regression: a fresh style object per render breaks memo's shallow props check.
// BREAK=1 forces the fresh object even in the "good" screen
const style = regressed || process.env.BREAK ? { color: 'gray' } : STABLE_STYLE;
return h('div', null,
h('input', { 'aria-label': 'note', value: note, onChange: e => setNote(e.target.value) }),
h(Summary, { items, style }));
};
};
const STABLE_STYLE = { color: 'gray' };
// The guard: run a user flow, then fail if a component rendered more than its budget.
function runFlow(Screen, { strict = false } = {}) {
for (const k of Object.keys(counts)) delete counts[k];
const tree = strict ? h(StrictMode, null, h(Screen)) : h(Screen);
const { getByLabelText } = render(tree);
const input = getByLabelText('note');
for (const value of ['a', 'ab', 'abc']) fireEvent.change(input, { target: { value } }); // 3 keystrokes
const result = { ...counts };
cleanup();
return result;
}
const assertWithinBudget = (result, budgets) => {
for (const [name, max] of Object.entries(budgets))
assert.ok((result[name] ?? 0) <= max, `${name} rendered ${result[name]} times, budget ${max}`);
};
let failed = false;
const expect = (label, fn) => { try { fn(); console.log('ok ' + label); } catch (e) { console.log('FAIL ' + label + ' :: ' + e.message); failed = true; } };
const good = runFlow(makeScreen({ regressed: false }));
const bad = runFlow(makeScreen({ regressed: true }));
const strict = runFlow(makeScreen({ regressed: false }), { strict: true });
console.log('good', good, 'regressed', bad, 'good under StrictMode', strict);
expect('stable props: Summary renders once (mount) across 3 keystrokes', () => assertWithinBudget(good, { Summary: 1 }));
expect('regressed props: the guard catches it', () => assert.throws(() => assertWithinBudget(bad, { Summary: 1 }), /Summary rendered 4 times/));
expect('StrictMode doubles the count, so a budget must be set for the mode the test uses', () => assert.equal(strict.Summary, 2));
process.exit(failed ? 1 : 0);
Run with Node 22 it prints:
good { Summary: 1 } regressed { Summary: 4 } good under StrictMode { Summary: 2 }
ok stable props: Summary renders once (mount) across 3 keystrokes
ok regressed props: the guard catches it
ok StrictMode doubles the count, so a budget must be set for the mode the test uses
exit=0
Reading it: with a stable style object Summary rendered once (the mount). With the regression it rendered four times: the mount plus one per keystroke (1 + 3 = 4), because each keystroke re-renders Screen, which builds a new style object that memo sees as a changed prop. The script exits 1 if the regressed screen stops being caught. Started with BREAK=1, which gives the "good" screen an always-fresh style object, it prints:
good { Summary: 4 } regressed { Summary: 4 } good under StrictMode { Summary: 8 }
FAIL stable props: Summary renders once (mount) across 3 keystrokes :: Summary rendered 4 times, budget 1
ok regressed props: the guard catches it
FAIL StrictMode doubles the count, so a budget must be set for the mode the test uses :: Expected values to be strictly equal:
8 !== 2
exit=1
The StrictMode line fails in that run because the count under StrictMode is now 8 (4 renders, each called twice), not 2.
What the guard cannot tell you
| Limit | Why it matters | What to add |
|---|---|---|
| Counts are not durations | One render of a 5,000-row list can cost more than 50 renders of a button | Profile with the React DevTools Profiler or a DevTools Performance recording for time |
| Mode changes the number | React docs: StrictMode "calls some of your functions (only the ones that should be pure) twice in development" and the checks "do not impact the production build" | Pick one mode for the test (the demo shows 1 without StrictMode, 2 with it on mount) and state it next to the budget |
| Async work changes the number | Effects that fetch and set state add renders after the flow ends | Wait for the final UI condition (findBy...) before reading counts |
| jsdom is not a browser | The count is exact, but layout and paint cost are not measured | Keep one browser-level performance test on the hot screens |
| Budgets rot | A number nobody owns gets bumped until it means nothing | Keep the budget next to the test, review changes to it, and add a comment saying which interaction it covers |
Trade-offs
- Where to count. Count only the components you care about (an expensive list, a chart). Counting everything makes tests brittle against harmless refactors.
- How tight. Assert an exact count for hot, expensive components; use a ceiling for the rest.
- Division of labour. The React DevTools Profiler finds the cause interactively; the automated guard stops it coming back.
Running the code
mkdir guard && cd guard
cat > package.json <<'J'
{"type":"module","dependencies":{"react":"18.3.1","react-dom":"18.3.1","jsdom":"24.1.0","@testing-library/react":"16.0.1","@testing-library/dom":"10.4.0"}}
J
# save the listing above as render_guard.mjs, then (the second run is the BREAK=1 run):
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 \
sh -c 'npm i --prefer-offline && node render_guard.mjs; echo exit=$?; BREAK=1 node render_guard.mjs; echo exit=$?'
Unlock Full Question Bank
Get access to all Frontend Performance and Rendering Optimization interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.