Skip to content
Intermediate13 min study

Code splitting and lazy loading

Ship only the JavaScript a route or interaction actually needs, and measure the real trade-off in extra requests.

Question progress0 / 10 completed
Start the lesson
Code splitting and lazy loading visual explanation

WHAT YOU WILL BE ABLE TO DO

Learning outcomes

  • Explain code splitting and lazy loading in plain language.
  • Connect the behavior to the underlying browser or framework model.
  • Implement the core pattern and reason through edge cases.
  • Answer common follow-ups without relying on memorized phrases.

01Explain it simply

Code splitting breaks one large JavaScript bundle into smaller chunks that load only when needed, instead of forcing every visitor to download code for every feature up front. Lazy loading is the runtime side of this: deferring a chunk's fetch until the exact moment it's actually needed, like opening a modal or navigating to a route.

One-line definition: Ship only the JavaScript a route or interaction actually needs, and measure the real trade-off in extra requests.

02Mental model

Every split point trades initial load time against on-demand latency: splitting reduces what a fresh visitor downloads and parses before the page becomes interactive, but introduces a new network request, and potential loading state, the first time that split code is needed. The right split points are large, infrequently-needed features, not every tiny component, which just multiplies small requests without meaningfully shrinking the initial bundle.

03Step by step

  • Identify large dependencies or features not needed for the initial view, such as modals, editors, admin panels, or charts.
  • Split at route boundaries first — usually the biggest win with the least complexity.
  • Use dynamic import() for large, conditionally-rendered features within a route.
  • Show a meaningful loading state for the gap between trigger and chunk arrival.
  • Measure actual bundle composition with a bundle analyzer instead of guessing what's large.

04Working example

TSX
// Route-level split (framework router typically does this automatically)const Settings = lazy(() => import('./Settings'));
// Feature-level split: only fetch the heavy chart library when the user opens itfunction Dashboard() {  const [showChart, setShowChart] = useState(false);  return (    <>      <button onClick={() => setShowChart(true)}>Show chart</button>      {showChart && (        <Suspense fallback={<Spinner />}>          <LazyChart />        </Suspense>      )}    </>  );}const LazyChart = lazy(() => import('./Chart'));

Chart's code, and any charting library it imports, is excluded from the initial bundle entirely — it's only fetched the moment showChart becomes true, which for most visitors who never open the chart means that code is never downloaded at all.

05Where it is used

  • Route-based splitting in any multi-page SPA
  • Deferring rarely-used features such as admin views, complex editors, or export and print flows
  • Loading a heavy library, such as charting, rich text, or maps, only when its UI is actually shown
  • Reducing time-to-interactive on a marketing or landing page bundled with a larger app

06Common mistakes

  • Splitting so finely that dozens of tiny chunks add request overhead without shrinking meaningful initial weight
  • Lazy-loading something needed immediately on first paint, adding a visible loading flicker for no benefit
  • Forgetting a fallback UI, leaving a blank gap while the chunk loads
  • Not verifying with a bundle analyzer that the split actually removed the dependency from the main chunk — a shared import elsewhere can pull it back in

07Interview answer

Frame every splitting decision as a measured trade-off between initial bundle weight and on-demand request latency, not as an unconditional 'lazy load everything' rule.

A team lazy-loads a small 3KB icon component used on every page's first paint. Why is this likely to make things worse, not better?

The component is small and needed immediately, so splitting it adds a separate network request and a loading gap for negligible bundle-size savings — the request overhead and delayed render outweigh whatever tiny amount of initial JavaScript was avoided.

DDConcept deep dives

Deep dive 1

Splitting trades initial cost for on-demand cost

Every chunk boundary you introduce removes bytes from what a fresh visitor must download before the page is interactive, but adds a real network request — and often a loading state — the first time that chunk is actually needed. This isn't a free win; it's a deliberate trade that only pays off when the split-out code is large enough, and used rarely enough, that the saved initial weight outweighs the added request.

  • The best split points are large and infrequently needed, not small and frequently needed.
  • A bundle analyzer turns 'I think this is large' into a measured decision.
  • Route boundaries are usually the highest-leverage, lowest-complexity place to start.

Deep dive 2

A dynamic import boundary can be defeated by an unrelated eager import

Marking a module for lazy loading only removes it from the initial bundle if nothing else in the eagerly-loaded graph also imports it. A shared utility file, a barrel export, or an unrelated feature importing the same heavy dependency can silently pull it back into the main chunk, and the code will still work correctly — it just won't achieve the intended bundle-size reduction, which is why verifying with an actual bundle analyzer matters more than trusting the import() syntax alone.

  • A working feature and a successfully-split bundle are two different things to verify separately.
  • Barrel files (index.ts re-exporting everything) are a common accidental source of this problem.
  • Re-run the bundle analysis after any refactor that touches shared imports near a lazy boundary.

Deep dive 3

A loading state is part of the feature, not an afterthought

The moment between triggering a lazy-loaded feature and its chunk arriving is real, user-perceptible time on a real network — treating it as a technical implementation detail rather than designing for it produces blank gaps, layout shift, or a UI that looks frozen. A properly sized, non-shifting loading state (and an error state for a failed chunk fetch) is what makes code splitting invisible to the user instead of just moving the cost somewhere less obvious.

  • Size the loading placeholder to match the eventual content to avoid layout shift.
  • A failed dynamic import should be caught and offer a retry, not crash the tree.
  • Preloading a likely-next chunk on hover or idle can eliminate the visible gap entirely.

QAInterview questions and model answers

Attempt each answer aloud before opening it. The model answer shows the depth and precision expected in an interview; it is not a script to memorize.

Intermediate · Conceptual · 1 min · Question 1What problem does code splitting solve?Open model answer

Model answer

Without it, every visitor downloads and parses JavaScript for every feature of an application up front, even features they'll never use in that session, which delays the point where the page becomes interactive.

Open question page →
Intermediate · Conceptual · 1 min · Question 2What's the difference between code splitting and lazy loading?Open model answer

Model answer

Code splitting is the build-time act of dividing a bundle into separate chunks. Lazy loading is the runtime decision to actually fetch one of those chunks only when it's needed, rather than during the initial page load.

Open question page →
Intermediate · Conceptual · 1 min · Question 3Why is route-based splitting usually the first place to start?Open model answer

Model answer

Routes are a natural, already-existing boundary that a user only needs one of at a time, so splitting there tends to remove large amounts of unused code from the initial bundle with comparatively little implementation complexity.

Open question page →
Intermediate · Conceptual · 1 min · Question 4What's the downside of splitting too aggressively?Open model answer

Model answer

Each split point becomes a separate network request. Splitting many small pieces can add more request and coordination overhead than it saves in bundle size, especially for code that's needed almost immediately anyway.

Open question page →
Intermediate · Conceptual · 1 min · Question 5Why does a lazy-loaded feature need a loading state?Open model answer

Model answer

Fetching the chunk takes real time on a real network, and without a fallback the user sees nothing, or a broken interaction, during that gap — the loading state communicates that something is happening rather than looking frozen.

Open question page →
Intermediate · Conceptual · 1 min · Question 6How can a bundle analyzer catch a code-splitting mistake that seems to work correctly?Open model answer

Model answer

It's possible for an application-wide import elsewhere to pull an intended-to-be-lazy dependency back into the main chunk without any error; a bundle analyzer visualizes what's actually in each chunk, revealing that the split didn't achieve its intended savings.

Open question page →
Advanced · Conceptual · 1 min · Question 7What does tree shaking remove that code splitting doesn't?Open model answer

Model answer

Tree shaking removes exports that are provably never imported anywhere, shrinking a single bundle's contents at build time; code splitting instead reorganizes which code lives in which chunk and when that chunk loads, without necessarily removing anything.

Open question page →
Intermediate · Conceptual · 1 min · Question 8Can code splitting help Time to Interactive even if total bytes downloaded across a full session stay the same?Open model answer

Model answer

Yes — moving code out of the initial chunk means less JavaScript needs to be downloaded and parsed before the page becomes interactive, even if a user who eventually visits every feature downloads a similar total over time.

Open question page →
Advanced · Conceptual · 1 min · Question 9Why might preloading a lazy chunk before it's needed be worthwhile?Open model answer

Model answer

If a user's next likely action is predictable, such as hovering a link before clicking it, preloading its chunk in the background can eliminate the loading delay entirely by the time the user actually triggers the lazy-loaded feature.

Open question page →
Advanced · Conceptual · 1 min · Question 10What happens if a lazy-loaded chunk fails to load, such as from a network error?Open model answer

Model answer

The dynamic import's promise rejects, and without explicit handling this can crash the surrounding component tree; production code typically wraps lazy boundaries in an error boundary that offers a retry rather than a blank broken screen.

Open question page →

SCScenario questions

Scenario 1

A dashboard's initial JavaScript bundle is 800KB, and a bundle analyzer shows a charting library used only on one rarely-visited analytics tab accounts for 300KB of it.

  1. Confirm the charting library is only needed for the analytics tab.
  2. Move the analytics tab's component behind a dynamic import.
  3. Wrap it in a Suspense boundary with a meaningful loading state.
  4. Re-run the bundle analyzer to confirm the library left the main chunk.
Reveal worked answer

I would dynamically import the analytics tab's component so the charting library is only fetched when a user actually navigates there, which for most sessions means it's never downloaded at all. I would add a loading state sized to prevent layout shift, and verify with the bundle analyzer that the library is now in its own chunk rather than still bundled in via some other eager import.

Verify and go deeper