awesome-everything RU
↑ Back to the climb

Frontend Architecture

Code splitting: the chunk graph is a latency budget, not a size win

Crux Splitting trades one big download for many small ones. Done by route it pays; done per-component on a high-latency phone it builds a request waterfall that loses to the bundle you were trying to beat.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 16 min

A team “optimizes” a dashboard: every panel gets its own React.lazy. The bundle analyzer looks gorgeous — 40 tiny chunks, none over 12 KB. Then field data lands. LCP on 4G regressed from 2.3s to 4.1s, CLS jumped to 0.31, and the loading spinners flash on every panel. On a 150 ms RTT phone, the page now makes a chain of round trips it never made before: fetch the route, parse it, then discover it needs panel A, fetch that, discover panel B, fetch that. The “smaller” build is slower because chunks are requests, and requests on mobile are expensive.

Splitting is a latency trade, not a free size cut

Bundling everything into one file means the user downloads code for pages they may never visit. Code splitting fixes that by carving the graph into chunks loaded on demand — webpack/Rollup/Vite see a dynamic import() and emit a separate file. Less initial JavaScript means faster parse, faster Time-to-Interactive, faster Largest Contentful Paint.

But you did not delete code; you turned one download into several. Each chunk is a separate HTTP request, and a request is not free: connection scheduling, round-trip latency, decompression, and the parse that can only start after the bytes arrive. On a fast desktop connection that overhead disappears into the noise. On a 4G phone with a 100–200 ms round trip, a chain of dependent requests is the dominant cost. The senior question is never “is the bundle smaller?” — it is “how many sequential round trips did splitting add to the critical path?”

Route-based splitting is the safe default; component-level is a scalpel

Route-based splitting is the highest-leverage, lowest-risk split: one chunk per page, loaded when you navigate. The initial route ships only its own code; the rest arrives as the user moves. Frameworks do this for free — Next.js, Remix, and React Router split per route automatically. The win is large and the waterfall is bounded, because each navigation is a deliberate user action with a natural loading moment.

Component-level splitting (React.lazy around a heavy widget) is a scalpel, and the rule is: split things that are heavy and not on the first paint. A 200 KB rich-text editor that opens behind a button — split it. A charting library used on one tab — split it. A 12 KB card that renders above the fold on load — never split it; you have added a round trip and a spinner to save almost nothing. The community heuristic is to only split components above roughly 30–50 KB; below that, the request overhead costs more than the bytes saved.

CandidateSplit?Why
A different route / pageYes — route-basedHighest leverage; framework default; waterfall bounded by navigation
200 KB editor behind a buttonYes — component-levelHeavy, below the fold, not on first paint → defer until interaction
12 KB card above the foldNoRound trip + spinner cost more than the bytes; ship it in the route chunk
Shared node_modules (React, date lib)Vendor chunkStable across deploys → long-cached separately from app code

The request waterfall: why over-splitting loses on mobile

The failure mode that bites in production is the waterfall. With React.lazy, the parent route renders, React hits the lazy boundary, and only then requests the child chunk — sequentially. Nest two or three lazy boundaries and you get a dependent chain: chunk A must arrive and parse before the browser even learns chunk B exists. On a 150 ms RTT link, three sequential chunks add ~450 ms of pure latency before any of them executes, on top of download and parse.

HTTP/2 helps but does not save you. Multiplexing lets independent requests share one connection in parallel, so many small chunks issued at once download without the HTTP/1.1 head-of-line penalty. That is real — granular chunking on Next.js used maxInitialRequests: 25 to ship more, smaller chunks safely. But multiplexing only parallelizes requests the browser knows about at the same time. A lazy waterfall is sequential by construction — the browser cannot fetch chunk B in parallel because it has not parsed chunk A yet. And tiny chunks compress worse: gzip/brotli need a window of repeated bytes, so splitting a 115 KB asset into many slivers can lose so much compression that even multiplexing cannot win it back (a classic sprite test: 10 KB combined vs 115 KB split).

Why this works

“HTTP/2 makes bundling obsolete” is a myth that cost teams real performance. Multiplexing removes the connection penalty of many requests, not the sequential-discovery penalty of a waterfall and not the compression penalty of tiny files. The webpack team’s own conclusion: still bundle into n chunks where n is greater than 1 and far less than your module count — find the middle, do not shatter the graph.

Bundle budgets and the deploy-cache trap

A senior sets a budget and enforces it in CI so a regression fails the build, not the field data. A common starting budget is roughly 200 KB JS and 50 KB CSS (compressed) per route, with the initial route held tightest because it sits on the LCP critical path. Above the split line goes everything needed for first paint and interactivity; below it goes everything gated behind a click, a route, or a scroll.

The subtler trap is the vendor chunk and cache invalidation. You isolate node_modules into a vendor chunk precisely because it changes rarely — so returning users keep it cached across deploys. But if your bundler hashes vendor and app code together, or inlines the webpack runtime/module-id map into the vendor file, then every deploy changes the vendor hash and every user re-downloads React on every release. The fix is granular chunking: a stable vendor chunk, the runtime in its own tiny file, and content-hashed filenames so only the chunks that actually changed get a new URL — Next.js’s granular chunking shipped exactly this to stop one app change from invalidating all of vendor.

Preload the critical chunk; prefetch the likely-next one

If you must split something on the critical path, do not let the browser discover it late. Two resource hints close the gap. <link rel="preload"> (or modulepreload for ES modules) tells the browser to fetch a chunk now, in parallel with the document, flattening the waterfall before it forms — use it for a chunk the current page will definitely need. <link rel="prefetch"> fetches at low priority for a future navigation — e.g. prefetch the dashboard chunk on hover over its link, so the click feels instant. Frameworks automate this: Next.js prefetches in-viewport <Link> targets, and writing import() inside next/dynamic lets it inject the preload before render.

The production accident this prevents is the above-the-fold lazy load. Lazy-load a hero or a primary panel and the user first sees the Suspense fallback (a spinner or blank box), then the real component pops in — shifting everything below it. That is a Cumulative Layout Shift hit; one real case went from CLS 0.85 down to 0.1 just by not lazy-loading above the fold and reserving space for what does load. The thresholds you are protecting: LCP under 2.5s, CLS under 0.1, INP under 200 ms. A spinner above the fold attacks all three at once.

Pick the best fit

A product page must show a 180 KB interactive 3D viewer that sits below the fold, opened only when the user scrolls to it. Pick the loading strategy for a 4G audience.

Quiz

On a 150 ms RTT 4G phone, why can a build of 40 tiny lazy chunks be slower than one bundle?

Quiz

Returning users re-download React on every deploy even though your dependencies never changed. What's the likely cause?

Order the steps

Order the decisions a senior makes when adding code splitting to a route:

  1. 1 Split per route first — biggest win, framework default, waterfall bounded by navigation
  2. 2 Find heavy (~30–50 KB+) components that are below the fold or interaction-gated
  3. 3 React.lazy only those; keep above-the-fold content in the route chunk
  4. 4 Preload critical deferred chunks; prefetch likely-next routes on hover/idle
  5. 5 Isolate a content-hashed vendor chunk so deploys don't re-download dependencies
Recall before you leave
  1. 01
    A teammate wants to wrap every component on a page in React.lazy 'to make the bundle smaller.' Explain why this can make a mobile page slower, not faster.
  2. 02
    Why does isolating dependencies into a vendor chunk help caching, and how does it accidentally backfire on every deploy?
Recap

Code splitting is a latency trade, not a free size cut: it turns one download into many requests, and each request costs a round trip you feel most on a high-latency mobile link. Route-based splitting is the safe, high-leverage default — one chunk per page, waterfall bounded by navigation — and frameworks do it for you. Component-level splitting is a scalpel reserved for things that are both heavy (around 30–50 KB and up) and off the first paint; splitting a small above-the-fold component just buys a round trip and a spinner. The classic production failure is over-splitting into a sequential waterfall that loses to the bundle on 4G, or lazy-loading above the fold and shifting the layout — watch LCP under 2.5s, CLS under 0.1, INP under 200 ms. Set a bundle budget (roughly 200 KB JS per route) and enforce it in CI, preload critical deferred chunks and prefetch likely-next routes, and isolate a content-hashed vendor chunk with the runtime split out so a single app change does not make every user re-download React. The chunk graph is a budget you design, not a number to minimize.

Continue the climb ↑Code splitting: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.