Code Splitting
Cutting one graph into an initial chunk plus route and feature chunks — and the two failures on either side of it: shared code duplicated, and a waterfall of tiny chunks.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
My application ships as one large file that every user downloads in full. How do I cut it up without making things worse?
Someone follows a link to one page of a large application. They want that page. They do not want to download the settings editor, the admin console and the charting library to see it.
Split everything. Every route becomes a chunk, every heavy component becomes a chunk, and the initial download shrinks to almost nothing.
A chunk that is only discovered after another chunk has downloaded, parsed and executed cannot be requested any earlier. Ten small chunks in a chain is a sequence of round trips, and on a high-latency connection that is far worse than one larger file (Reading a Network Waterfall).
- A chunk that is only discovered after another chunk has downloaded, parsed and executed cannot be requested any earlier. Ten small chunks in a chain is a sequence of round trips, and on a high-latency connection that is far worse than one larger file (Reading a Network Waterfall).
- Code used by two chunks is either duplicated into both — so a user who visits both pages downloads it twice — or hoisted into a shared chunk that both must now wait for. Neither is free, and choosing badly is easy.
- Splitting on route boundaries alone still ships the framework, the design system, the router and the data layer in the initial chunk, which is usually where most of the bytes were.
- Each split point is a place where the network can fail. A user on a flaky connection now gets a dead button instead of a slow page, unless you handled it (Network Failures Only the Client Can See).
- A chunk requested after a deploy may no longer exist on the server, which turns a routine release into a broken navigation for everyone with a tab open (Long-Lived Clients and Version Skew).
What is actually happening
In the browser, not in the framework.
- The bundler cuts the module graph at split points. A dynamic
import()with a literal specifier is the canonical split point; configuration can add more (The Module Graph). - Each chunk is emitted as its own file with a content-derived name, and a manifest records which chunk contains which module (Content-Hashed Assets).
- A small runtime shipped in the initial chunk resolves a dynamic import into a network request for the right file, caches the result, and returns a promise.
- Modules reachable from more than one chunk force a decision: duplicate them, or lift them into a shared chunk that the dependants load first. Bundlers expose heuristics for this — minimum size, maximum requests, forced groupings.
- Splitting does not remove code. It moves *when* code is downloaded, and only helps if the code moved is genuinely not needed for the first meaningful interaction.
- Preloading and prefetching hints let you decouple *discovery* from *use*: a chunk can be fetched at low priority long before the user reaches it (Resource Hints).
What this makes the browser do
And which of it is avoidable.
- One request, one response and one parse-and-compile pass per chunk. The parse cost is proportional to bytes; the request cost is mostly latency and is paid per chunk.
- Under HTTP/1.1, concurrent requests to one origin are limited, so extra chunks queue behind each other. Under HTTP/2 and HTTP/3 they are multiplexed on one connection and the per-chunk overhead is much smaller (HTTP/2: Streams on One Connection in Networking).
- Executing each chunk's module bodies on the main thread on arrival, which competes with whatever interaction triggered the load (Long Tasks).
- Caching each chunk separately, which is the upside: a deploy that touches one route invalidates one chunk rather than the whole application (Browser HTTP Caching).
One graph, three kinds of cut
Splitting is a partition of the module graph, and it is worth naming the three kinds of cut separately because they have different justifications. The initial chunk is what must arrive before anything works. Route chunks map to navigations. Feature chunks map to things a user may never do — an export dialog, a rich text editor, a map.
The fourth box in the diagram is the one that causes the trouble. Shared modules do not belong to any single route, and where the tool puts them decides whether a two-page session downloads a library once or twice.
- The initial chunk is the only one whose size is paid by every user on every first visit. Everything else is conditional.
- A shared chunk is a dependency, not a discount: routes that use it cannot render until it arrives.
- A feature chunk pays off in proportion to how *rarely* the feature is used. Splitting something everyone clicks is a round trip you added.
The waterfall you can create by splitting too much
The failure mode on the far side of code splitting is a staircase. Each chunk is small, so the size report looks excellent, and each chunk is only discovered once the previous one has arrived and executed — so nothing overlaps and the user waits for the sum of the round trips.
This is why the number to watch is not chunk count or total bytes but the *depth* of the chunk dependency chain on the critical path. Two chunks fetched in parallel cost one round trip. Two chunks fetched in sequence cost two, and no amount of compression changes that (Minification Is Not Compression).
- Flat: initial chunk — Discovered in the HTML, requested immediately.
- Flat: route chunk (prefetched) — Discovered at the same time via a hint, so it overlaps rather than queues (Resource Hints).
- Chained: execute initial — Only now is the next specifier reached.
- Chained: route chunk — A round trip that could not begin earlier — the chunk name was inside the previous chunk.
- Chained: shared chunk — Discovered third. The staircase.
The chained version ships fewer bytes up front and becomes interactive later. Depth of the chain, not chunk count, is what you are optimising.
What goes wrong, and what to do about it
Most splitting problems announce themselves as something other than a splitting problem: a slow click, a broken button after a release, a route that got slower when an unrelated page was added. The table maps the symptom back to the partition that caused it.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A rarely-used route is split, but so is a component on every page | Every interaction has a small hitch before it responds | A split point on the critical path converts a function call into a network request | Un-split it. Splitting is for code that is genuinely conditional (Bundle Analysis). |
| Shared-chunk threshold set high | The same library appears in four route chunks in the treemap | Each chunk fell below the minimum size for hoisting, so the module was duplicated | Lower the threshold or group the library explicitly, then verify in the treemap rather than in the config. |
| Shared-chunk threshold set low | Every route waits on one large shared chunk | Tiny modules hoisted until the shared chunk became a second initial chunk | Raise the minimum size; accept a little duplication in exchange for independence. |
| Deploy while users have tabs open | Navigation fails with a chunk load error, spiking right after each release | The document is from the previous build and asks for a chunk name that build no longer emits | Keep previous builds served for a grace period; on a chunk load error, reload once rather than retrying (Deploying a Frontend). |
| Flaky mobile connection | A button does nothing and no error appears anywhere | The dynamic import rejected and the rejection was swallowed | Catch it, surface an announced error, offer a retry, and report it (Frontend Error Tracking). |
| Route chunk imports another route chunk | A three-step staircase in the waterfall on one route | A chunk's dependency is only discovered after that chunk executes | Flatten: hoist the shared dependency, or preload the second chunk alongside the first (Resource Hints). |
How to build it
Most important first.
- Split at the route boundary first. It maps to navigation, which is where users already expect a transition, and it is the split with the clearest payoff (Route Loading Boundaries).
- Then split by weight, not by structure: a single heavy dependency behind a rarely-used feature is worth a chunk; a small component is not.
- Configure shared chunks deliberately. Set a minimum size so tiny modules are not hoisted, and check the result in a treemap rather than trusting the heuristic (Bundle Analysis).
- Prefetch the chunk you can predict — the next route, the modal behind the primary button — at low priority, so the fetch overlaps with the user still reading (Resource Hints).
- Avoid chains. If chunk A always loads chunk B, they are one chunk with extra latency. Flatten the graph so chunks are discovered in parallel, not in sequence.
- Handle chunk load failure explicitly: a retry, and then a full reload as the honest fallback for the deploy-skew case (Deploying a Frontend).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A chunk boundary is a wait, and an unannounced wait is invisible to a screen-reader user. The boundary must expose a status that assistive technology can read, not only a spinner that assistive technology cannot see (Live Regions and Announcement).
- When a chunk fails to load, the control that triggered it must report a real, announced error and remain operable. A button that does nothing is indistinguishable from a broken page to someone who cannot see that no navigation occurred.
- Focus must be managed across the boundary. If a route's content arrives after focus has already moved on, a keyboard user is left on a stale element with no route to the new content (Focus Management).
- Shipping less JavaScript in the initial chunk is itself an accessibility win: on a low-end device the gap between "pixels present" and "operable" is main-thread work, and that gap is where assistive technology also waits (The Real Cost of JavaScript).
What can go wrong
- Over-splitting into a request waterfall, where the total download is smaller and the time to interactive is worse.
- A shared chunk that grows until it is effectively a second initial chunk, so every route pays for the union of everything.
- Duplicate code across chunks: the same utility library present in four chunks because the shared-chunk threshold was set too high.
- A split point inside a synchronous code path, so the split forces an interaction to become asynchronous and the surrounding code was never written for that.
- The mitigation failing: a retry loop around a chunk that is genuinely gone after a deploy, retrying forever instead of reloading once.
- Splitting code that was on the critical path anyway, which converts one large download into one slightly smaller download plus a round trip.
- A chunk request racing a deploy: the document was loaded from build N and asks for a chunk name that only build N emitted. Serve old builds for a grace period, and treat a missing chunk as a signal to reload (Long-Lived Clients and Version Skew).
- Two interactions triggering the same dynamic import concurrently — the loader must return the same in-flight promise rather than issuing two requests (Five Components, One Request).
- A user navigating away while a route chunk is still loading: the resolution must not mount content for a route that is no longer current (Cancelling a Request Nobody Is Waiting For).
- Chunks are public files on your origin. Splitting an admin feature into its own chunk hides nothing — anyone can request it and read it (Authorization-Aware UI).
- A split point does not create a trust boundary. Authorization is enforced by the server on every request, no matter which chunk the calling code lived in (What the Frontend Is Responsible For in Auth).
- Chunk URLs are generated by your build and loaded by your runtime. If a Content-Security-Policy restricts script sources, chunk loading must be covered by it, and nonce-based policies interact badly with dynamically injected script tags (Content Security Policy).
- A chunk fetched from a CDN inherits that CDN's integrity story. Subresource integrity and immutable content-hashed names are what keep a swapped file from executing silently (CDN Delivery).
- "Smaller initial bundle means faster." Faster to first paint, perhaps. If the deferred chunk is needed to make the page usable, you moved the cost into the interaction rather than removing it.
- "Split every route and you are done." The framework, the design system and the data layer usually dominate the initial chunk, and none of them are route-specific.
- "More chunks is more parallelism." Only if they are discovered at the same time. Chunks discovered one after another are the opposite of parallel.
- "The bundler's default shared-chunk heuristic is right." It is a starting point tuned for a generic project; verify it against your own output.
- "A separate chunk keeps the code private." Every chunk is a public URL on your origin.
Measuring it, and what changes in the field
- The Network panel, filtered to scripts, on a real navigation: the number of chunk requests and — more importantly — whether they overlap or form a staircase (Reading a Network Waterfall).
- A treemap of the emitted chunks, which shows both the initial chunk's composition and any module duplicated across chunks (Bundle Analysis).
- Interaction latency in field data for the interactions that cross a split point, which is where over-splitting actually shows up for users (Interaction Responsiveness).
- Chunk load error rates in your error tracker, broken down by release — the deploy-skew signature is a spike immediately after a deploy that decays over hours (Release Health).
- On a high-latency connection, each additional sequential chunk costs a round trip and the waterfall shape dominates total bytes.
- On HTTP/1.1 the per-connection request limit makes many chunks genuinely expensive; under multiplexed protocols the same split may be free or better (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
- On a slow device, fewer, larger chunks can be worse than more, smaller ones, because a large parse-and-compile is one long task that blocks input (Long Tasks).
- For a returning user with a warm cache, splitting is strictly better: only the chunks that changed are re-downloaded.
- In a long-lived tab, chunks are requested against a server that may have been deployed several times since the page loaded.
- Every split trades bytes now for a round trip later. That is a good trade only if the deferred code is genuinely not needed now.
- Shared chunks reduce duplication and add a dependency edge that every dependent route must wait for.
- Prefetching hides the round trip and spends bandwidth on code the user may never reach — which on a metered connection is a real cost you imposed on someone else.
- Every boundary needs loading and error handling, which is code, states and tests that a single bundle did not require.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- NETWORK-SPECIFICThe cost of an extra chunk depends on the protocol. Under HTTP/1.1 browsers limit concurrent connections per origin, so chunks queue; under HTTP/2 and HTTP/3 they are multiplexed on one connection and the marginal cost is much smaller — which means splitting advice written for one era misleads in the other (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
- FRAMEWORK-SPECIFICRouter-level splitting is automatic in most meta-frameworks and manual in a hand-rolled single-page app, so the same lesson describes a configuration question in one and an architecture question in the other (Client-Side Routing).
- DEVICE-SPECIFICOn a fast desktop the parse-and-compile cost of a large chunk is easy to miss; on a low-end phone it is a long task that blocks input, so the optimal chunk size is smaller there than local profiling suggests.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a deploy that changes the set of available chunk names is a version-skew problem: two builds are live at once and old clients keep asking the old questions.