open atlas
↑ Back to track
React, zero to senior RCT · 05 · 04

Code splitting with React.lazy: ship the screen, not the application

React.lazy turns a dynamic import into a component that suspends until its chunk loads. Split at routes first, then heavy conditional components; preload on hover/viewport; never chain lazy inside lazy — each hop is a full round trip. Measure chunks, not vibes.

RCT Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The checkout conversion review surfaced an odd number: mobile users on 4G waited a median 6.8 seconds before the payment form became interactive. The bundle analyzer explained it — checkout shipped as part of one 1.9 MB (612 KB gzipped) application bundle that included the admin dashboard, a charting library the admin used, a markdown editor for support replies, and three locales of date formatting. Customers were downloading and parsing the back office to pay for socks. Route-level splitting cut the checkout path to 240 KB and the wait to 2.1 s. Then came the overcorrection: a teammate, sold on lazy loading, converted thirty components to React.lazy — including the settings panel that lazily loaded a form that lazily loaded its rich-text editor. On a 200 ms RTT connection, opening settings now took three sequential round trips — request, parse, render, discover the next lazy child, request again — a 1.4 s spinner staircase that profiling on office wifi never showed. Splitting is not free granularity; every boundary is a network edge you must place deliberately.

Mechanics: a component that suspends until its code arrives

Before you reach for React.lazy, it is worth understanding what the browser is actually doing when it hits a dynamic import — because the fallback flash you will see in development is the observable surface of a network round trip, and knowing that shapes every placement decision that follows.

React.lazy(() => import("./Editor")) returns a component you render like any other. The first time React tries to render it, the loader fires: import() tells the bundler to cut the module (and everything only it imports) into a separate chunk, fetched over the network at that moment. While the promise is pending, the lazy component suspends — the nearest Suspense boundary shows its fallback — and when the module resolves, React renders its default export. The module is fetched and evaluated once; later renders are synchronous.

import { lazy, Suspense } from "react";

const Editor = lazy(() => import("./Editor"));            // needs default export

// Named-export workaround: re-shape the module in the loader
const Chart = lazy(() =>
  import("./charts").then((m) => ({ default: m.RevenueChart }))
);

function ReplyPanel({ open }) {
  return (
    <Suspense fallback={<EditorSkeleton />}>
      {open && <Editor />}
    </Suspense>
  );
}

Two declaration rules prevent self-inflicted bugs. Declare lazy components at module top level — a lazy() created inside a render produces a new component type every render, so React unmounts and remounts the subtree (state loss, re-fetch) each time the parent updates. And lazy resolves the default export only; for named exports, map the module in the loader as above. The deeper point: each lazy boundary is a promise that resolves on network time, which makes placement an architecture decision, not a syntax one.

Where to cut: routes first, components second

The highest-value split is the route: users on one screen get none of the code for the others, the chunk boundary matches a natural loading state (navigation), and modern framework routers do it by default. The Hook’s first fix — separating checkout from the admin’s charting and markdown libraries — was pure route splitting and took the bundle from 612 KB to 240 KB gzipped. As a budget anchor: 300 KB of gzipped JS is roughly 1 MB to parse and execute, which is seconds of CPU on a mid-range phone even after the download finishes — splitting saves parse time, not just bytes.

Component-level splitting earns its keep for things that are heavy and conditional: modals, rich-text editors, chart panels, anything below the fold or behind a click. The test is concrete — open the bundle analyzer and look at the chunk’s actual size. Lazy-loading a 6 KB date picker buys ~nothing and costs a request plus a fallback flash; lazy-loading a 280 KB editor that 4% of sessions open is the whole point. Failure mode — the waterfall: a lazy component whose chunk, once loaded, renders another lazy component creates sequential round trips, because the second request cannot be discovered until the first chunk has arrived and rendered. At 200 ms RTT, three nested boundaries are 600+ ms of pure latency before any rendering work. Keep boundaries siblings (one Suspense, chunks fetched in parallel), not ancestors.

Quiz

A lazily-loaded settings panel renders a lazily-loaded form, which renders a lazily-loaded editor. Office wifi shows nothing wrong; on 200 ms RTT mobile, opening settings shows a 1.4 s spinner staircase. Why?

Preloading on intent: spend the latency before the click

A lazy chunk’s latency does not have to start at render time. The dynamic import() is just a function call that warms the module cache — call it early, and by the time React suspends, the promise may already be resolved:

const loadEditor = () => import("./Editor");      // shared loader = shared cache
const Editor = lazy(loadEditor);

<button
  onMouseEnter={loadEditor}                        // intent: ~200-300 ms head start
  onFocus={loadEditor}
  onClick={() => setOpen(true)}
>
  Reply
</button>

Hover-to-click gaps run a few hundred milliseconds — frequently the entire chunk fetch on a decent connection, turning the fallback flash into a non-event. The same idea generalizes: preload route chunks when a link enters the viewport (IntersectionObserver), after first paint for the most-likely next route, or via requestIdleCallback for low-priority warming. The browser-level equivalent is <link rel="modulepreload"> for chunks you know statically. Tradeoff: preloading spends bandwidth on predictions — over-preloading on a data-constrained mobile connection re-creates the monolith one speculative chunk at a time, so tie it to genuine intent signals (hover, focus, viewport) rather than preloading everything.

SSR and the measurement loop

Two operational notes close the topic. Server rendering: on the server there is no “later” — classic React.lazy historically could not be server-rendered, which is why ecosystems grew loadable-components; modern React 18+ streaming SSR plus framework-level route splitting handles this for you, but the hydration constraint remains: the client must load the same chunk before it can hydrate that subtree, so a slow chunk means server-rendered HTML that is visible but inert — wire the fallback so users do not click dead UI. Measurement: code splitting is a bytes game, so verify in bytes. The bundle analyzer (rollup-plugin-visualizer, webpack-bundle-analyzer) shows whether the split actually moved the library out of the entry chunk — a single stray static import of the editor anywhere in the entry graph silently un-splits it, and the analyzer is where you catch that regression. Track the entry chunk size in CI; it is the metric that decays one innocent import at a time.

Quiz

After converting the markdown editor to React.lazy, the entry bundle size has not changed at all. The lazy() code is correct and the editor renders fine. What is the most likely cause?

Recall before you leave
  1. 01
    Trace everything that happens between the first render of a React.lazy component and its content appearing, and explain why the second render is different.
  2. 02
    Your team wants to lazy-load aggressively after a bundle-size incident. Give the decision framework: where splitting pays, where it hurts, and the two traps to check in review.
Recap

Code splitting attacks the cost users pay before your code even runs: download plus parse plus execute, where 300 KB gzipped is roughly a megabyte of JavaScript for a phone CPU to compile. React.lazy is the rendering-side adapter: it wraps a dynamic import — which the bundler turns into a separately-fetched chunk — into a component that suspends at first render, shows the nearest Suspense fallback, and renders the module’s default export once the chunk is fetched and evaluated; afterwards it is an ordinary synchronous component. Declare lazy components at module top level (an inline lazy() is a new type each render — remount, lost state), and map named exports to a default in the loader. Place boundaries like the network edges they are: routes first — that is what took the Hook’s checkout from 612 KB to 240 KB and 6.8 s to 2.1 s — then components that are heavy and conditional per the bundle analyzer, never small widgets where the request and fallback flash cost more than the bytes. The waterfall is the signature failure: lazy nested inside lazy serializes chunk discovery, each level a full round trip, invisible on office wifi and a 1.4 s staircase at 200 ms RTT — keep boundaries parallel siblings. Hide remaining latency by preloading on intent: the shared import() loader on hover, focus, or viewport entry warms the module cache a few hundred milliseconds before the click, often resolving the promise before React ever suspends. On the server, streaming SSR and framework routers handle splitting, but hydration still waits for the client chunk — visible-but-inert UI is the trap. And verify in bytes: one stray static import silently un-splits a chunk, so the analyzer and a CI budget on the entry chunk are part of the feature. Now when you open the bundle analyzer and see the entry chunk growing again, you will know exactly what to look for — and which question to ask in the PR that caused it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.