Skip to content

    Frontend System Design

    Code Splitting, Lazy Loading and Tree Shaking

    Learn how bundlers cut a module graph into chunks, remove unused exports, and why a sideEffects flag or a barrel file can change what a visitor downloads.

    By Published 17 min read

    • frontend-system-design
    • code-splitting
    • lazy-loading
    • tree-shaking
    • bundling
    Share

    Flagship lessonintermediateTypeScript labFrontend System Design

    Before this: The Critical Rendering Path, JavaScript Modules and TypeScript at Scale (coming soon)

    What you'll learn: By the end of this lesson you will be able to explain the difference between code splitting, lazy loading and tree shaking, read an application as a module graph, predict which exports and modules a bundler can remove and which chunks it creates, say what the sideEffects flag promises and how a wrong one breaks a page, choose where to split, handle a lazy chunk that fails to load, and test your predictions with a small analyser.

    Think of moving house. You could unload every box on the first day, including the winter coats in July. Or you could unpack the kitchen first, leave the rest in the garage and fetch a box when you need it, after throwing away what you know you will never use. A web page has the same choice with JavaScript: every byte downloaded, parsed and run before the first screen competes with what the visitor came to see.

    Three ideas share this job. Tree shaking decides what to throw away, code splitting how to cut the rest into files, lazy loading when each file is fetched. One small storefront is the running example.

    The core idea

    1. A bundler works on a graph, not on files. Each module names the modules it imports; from an entry point the bundler builds a module graph, and everything below is a computation over it.
    2. Static imports are fixed at build time; import() is the exit. A dynamic import() is a run-time request that returns a promise, and it is the only place a build can cut the graph into pieces that load later.
    3. Code may be removed only when the bundler can prove it is unused. The proof comes from the static shape of import and export, and from one question the bundler cannot answer alone: does importing this module do something beyond providing values?

    In short: tree shaking removes exports and modules that nothing uses; code splitting cuts the output into several files; lazy loading fetches one of them only when needed. Tree shaking makes the total smaller; splitting and lazy loading change what loads first, and the total can even grow a little. A lab models these ideas in a few hundred lines of TypeScript. It is not a bundler: it reads no files, parses no JavaScript, and its sizes are abstract units.

    The module graph

    A module is a file with its own scope that can export values and import values from other modules. MDN states that import declarations "can only be present in modules, and only at the top-level (i.e., not inside blocks, functions, etc.)", and that they are hoisted, so the imported modules' side effects "are produced before the rest of the module's code runs". So a tool can read the whole graph without running anything. Webpack's guide says tree shaking "relies on the static structure of ES2015 module syntax, i.e. import and export", and warns that no compiler should turn that syntax into CommonJS modules (the default of the Babel preset @babel/preset-env does).

    In the storefront, the entry main imports a router, a UI library and analytics. The router opens three pages (product, checkout, admin) and the admin page opens a charting module. The UI library is a barrel: a module that only re-exports components from other modules (button, header, modal, datepicker, tooltip) so callers write one import line.

    Figure 1. The storefront as a module graph, cut into what loads first and what loads on demand

    The storefront's code: an entry chunk loaded at start and page chunks loaded on demandVisitor to Entry chunk (first visit); Entry chunk to Product page (import()); Entry chunk to Checkout page (import()); Entry chunk to Admin page (import()); Admin page to Charts (import()); Product page to Shared chunk; Checkout page to Shared chunkLOADED AT STARTLOADED WHEN NEEDEDVisitor: opens the siteVisitoropens the siteEntry chunk: main, router, headerEntry chunkmain, router,headerProduct page: loads on navigationProduct pageloads onnavigationCheckout page: loads on navigationCheckout pageloads onnavigationAdmin page: loads on navigationAdmin pageloads onnavigationCharts: loads on a clickChartsloads on a clickShared chunk: price formattingShared chunkprice formattingfirst visitimport()import()import()import()

    How to read this: only the entry chunk is needed for the first screen. Each dashed arrow is a dynamic import(), a point where the graph is cut. The shared chunk exists because two pages need the same module, so the bundler gives it a file of its own rather than copying it into both.

    Tree shaking: deciding what to throw away

    Tree shaking starts from what must run. The lab's rules:

    1. An entry module always runs, so whatever its top-level code uses is live.
    2. A live export keeps its module in the output and makes whatever that export's code needs live too.
    3. A module imported statically but with no live export is kept only if the bundler must assume importing it has side effects.

    Rule 3 is the subtle one. Webpack's guide defines a side effect as "code that performs a special behavior when imported, other than exposing one or more exports", and gives polyfills as the example. Registering a global listener, patching a built-in or injecting a stylesheet all happen at import time, so dropping such a module would change the program.

    Two different mechanisms

    Webpack's guide separates two optimisations. usedExports finds individual unused exports and relies on a minifier to spot side effects "in statements", which it calls "a difficult task in JavaScript". sideEffects is a declaration: "If no direct export from a module flagged with no-sideEffects is used, the bundler can skip evaluating the module for side effects." The guide calls it "much more effective" because a skipped module's own imports need not be evaluated either.

    You state the flag yourself: in webpack the "sideEffects" field of package.json (false, or a list of files that do have effects); in Rollup the treeshake.moduleSideEffects option, documented default true, which when false assumes "modules and external dependencies from which nothing is imported do not have other side effects". The shared idea is the safe default: unless you say otherwise, an imported module is assumed to do something.

    Figure 2. Which parts of a barrel survive, and why

    A barrel module re-exports five components; the build keeps the four that are used and drops the tooltipEntry and pages to ui barrel (imports 4 names); ui barrel to ui/button; ui barrel to ui/header; ui barrel to ui/modal; ui barrel to ui/datepicker; ui barrel to ui/tooltip (removed)Entry and pages: use fourEntry andpagesuse fourui barrel: re-exports fiveui barrelre-exports fiveui/button: usedui/buttonusedui/header: usedui/headerusedui/modal: usedui/modalusedui/datepicker: usedui/datepickerusedui/tooltip: no export used, flag falseui/tooltipno export used,flag falseimports 4 namesremoved

    How to read this: the dashed box is the only module the build can leave out, because nothing uses it and its flag says importing it does nothing. Without that flag the build must keep it.

    Illustration 1: the minimal example

    // math.js
    export function square(x) { return x * x; }
    export function cube(x) { return x * x * x; }
    
    // main.js
    import { square } from "./math.js";
    console.log(square(4));

    A tree-shaking build can leave cube out, because nothing uses it and declaring a function has no effect. Had math.js contained a top-level window.__mathLoaded = true, the module would have to stay even with no export used, unless a flag declared it pure.

    Code splitting: deciding how to cut the output

    Webpack's guide names three general approaches: entry points (each its own bundle), preventing duplication (so a module two entries need is not copied into both) and dynamic imports (cutting at import() calls). It names the pitfall of the first: "If there are any duplicated modules between entry chunks they will be included in both bundles."

    The lab implements a grouping rule in the spirit of most bundlers, without claiming to match any one tool:

    • The entry points and the targets of used import() calls are roots.
    • Each surviving module goes into the chunk for the set of roots that need it. Needed by one root, it joins that root's chunk. Needed by several, it goes into a shared chunk named for that set (for example shared:pages/checkout+pages/product).
    • A root is removed from that set when every place that can call import() for it has already loaded the module. A module used by main and by a page it opens stays in main's chunk: it is already downloaded when the page loads.

    Real bundlers add more: size thresholds (Rollup documents output.experimentalMinChunkSize as "a minimal chunk size target in Byte for code-splitting setups"), manual chunks (output.manualChunks "allows the creation of custom shared common chunks") and content hashes. At the time of writing, Vite's current documentation names Rolldown as the bundler underneath, configured through build.rolldownOptions; earlier majors used Rollup. Check the docs of your version before copying a chunking option from a blog post.

    What import() is

    MDN describes dynamic import as a function-like expression "that allows loading an ECMAScript module asynchronously and dynamically", and notes that it is "available in both scripts and modules". It returns a promise that fulfils to the module namespace object, and the module is evaluated once: "a piece of JavaScript code is never executed more than once, even if it is imported multiple times". It is the right tool when a static import "significantly slows the loading of your code" and there is "a low likelihood that you will need the code you are importing". And MDN warns: "Use dynamic import only when necessary. The static form is preferable for loading initial dependencies, and can benefit more readily from static analysis tools and tree shaking."

    Figure 3. A lazy chunk loading on demand

    sequenceDiagram accTitle: Loading a lazy chunk when the user asks for it accDescr: The user clicks a link, the page code calls import, the browser fetches the chunk and its shared dependency, evaluates it once, and the promise resolves with the module so the page can render the screen. participant U as User participant P as Page code participant B as Browser participant S as Server or CDN U->>P: clicks "Checkout" P->>B: import("./pages/checkout.js") B->>S: GET checkout chunk, shared chunk S-->>B: files Note over B: evaluate once, cache in the module map B-->>P: promise resolves with the module P->>U: renders the checkout page

    How to read this: the wait between the click and the render is the price of laziness. It is paid once per chunk per visit, and everything the visitor never clicks is never fetched.

    Illustration 2: the realistic example, a storefront

    The lab models the storefront as a graph of 15 modules with illustrative sizes. Build 3 in the output below (flags honoured) puts main, the router, analytics, the header and the UI barrel into an entry chunk of size 83, and the pages, the charting module and a small shared chunk into chunks that load later. A first visit fetches 83; opening checkout fetches 30 more; an admin who opens the admin page and then a chart fetches 40 and then 60.

    Now the barrel's cost. In build 3 the date picker, which only checkout needs, still lands in the entry chunk: the barrel statically imports it, it is in the output because checkout uses it through the barrel, so whoever loads the barrel loads the date picker. In build 4 the entry and the pages import each component's own module. The total stays 233, but the entry chunk falls from 83 to 23 and the date picker moves into the checkout chunk. That is a result of this lab's rules; whether a given bundler lays out a barrel the same way depends on the tool and version, so measure your own build with its analyser before restructuring imports.

    Lazy loading in the framework

    Frameworks wrap import() so that the loading state fits the component model. React's lazy "lets you defer loading component's code until it is rendered for the first time", and its load function must return a promise (or other thenable) that resolves to an object whose .default is a valid component. It is declared at the top level of a module, never inside a component, and wrapped in <Suspense> to show a fallback: "If MarkdownPreview hasn't loaded yet, Loading will be shown in its place."

    import { lazy, Suspense } from "react";
    
    // Declared at module top level, outside any component.
    const CheckoutPage = lazy(() => import("./pages/CheckoutPage.js"));
    
    export function Routes() {
      return (
        <Suspense fallback={<p>Loading checkout…</p>}>
          <CheckoutPage />
        </Suspense>
      );
    }

    The React documentation also states what happens when the load fails: "If the Promise rejects, React will throw the rejection reason for the nearest Error Boundary to handle." A lazy component with no error boundary above it is a page that can go blank. Other frameworks and routers offer their own wrappers around import(); check how yours reports a failed load.

    Figure 4. The states of a lazy chunk

    stateDiagram-v2 accTitle: States of a lazy loaded chunk accDescr: A chunk starts not requested, becomes loading when a render or a hint asks for it, then either ready and cached or failed. A failed chunk can be retried or shown as an error. [*] --> NotRequested NotRequested --> Loading: rendered, clicked or hinted Loading --> Ready: fetched and evaluated Loading --> Failed: network error, 404, bad deploy Failed --> Loading: retry Failed --> ErrorShown: no retry or retries exhausted Ready --> [*]

    How to read this: most tutorials draw only the top path. The Failed branch is the one that costs you users, because a lazy chunk is a network request that can fail while the page around it looks fine.

    When to split, and what to split

    Splitting is not free: each chunk is a request (cheaper over HTTP/2 and later, see HTTP protocols), a loading state and a place where the network can fail. The deciding question is: does the first screen need this code?

    Figure 5. Where to put a split

    flowchart TD accTitle: Deciding whether to split a piece of code accDescr: If the first screen needs the code it stays in the entry. Otherwise a route level split comes first, then a heavy component behind a user action, then a rarely used feature, and anything tiny is left alone. A["Does the first screen need this code?"] -->|"yes"| B["Keep it in the entry chunk"] A -->|"no"| C{"Is it a whole route?"} C -->|"yes"| D["Split by route:
    import() in the router"] C -->|"no"| E{"Is it large and behind a
    user action? (editor, chart, map)"} E -->|"yes"| F["Split the component,
    hint it on hover or idle"] E -->|"no"| G{"Is it small?"} G -->|"yes"| B G -->|"no"| H["Split a rarely used feature
    or a locale, then measure"]

    How to read this: split along the lines users already choose (routes) first. Tiny pieces are not worth a request. Web.dev calls splitting "on the route or component level" the simpler approach with a client-side framework.

    Hiding the wait: preload and prefetch

    A lazy chunk downloads only when the code asks, so the click pays the full round trip. To start earlier, webpack documents magic comments that emit resource hints: import(/* webpackPrefetch: true */ "./LoginModal.js") and import(/* webpackPreload: true */ "ChartingLibrary"). In plain HTML, MDN describes rel="modulepreload" as "a declarative way to preemptively fetch a module script, parse and compile it, and store it in the document's module map for later execution", and adds that fetching the dependencies of a module "is a browser-specific optimization", so listing each one is the portable approach. Hint what is likely next, not everything.

    Trade-offs

    Technique What it buys What it costs Watch for
    Tree shaking Smaller total output Needs static import/export; trusts your flags A wrong sideEffects: false drops code that does something
    Route splitting Small entry, pages fetched on demand A wait per first navigation; a loading and error state Shared code duplicated or fragmented into many tiny chunks
    Component splitting Heavy widgets leave the entry More chunks, more states Layout shift when the component appears (see CLS)
    Preload and prefetch Hides the wait Bandwidth spent on code that may go unused Hints for everything compete with what the page needs now
    Barrel files One short import line Can pull unrelated code into a chunk in a build that cannot separate it Measure the chunk layout, not the import line

    Build it: a module graph analyser

    Goal. Describe a module graph as data, run it through the analyser, and read off what survives, how it is chunked and what each visit downloads. Then change a flag or an import and predict the effect before you run it.

    This is a model, not a bundler. It reads no files and parses no JavaScript, and knows nothing about minification, CSS, content hashes or any bundler's real heuristics. Its sizes are abstract units, so it cannot tell you how big your bundle is. It shows the reasoning a bundler performs.

    Setup. The lab lives in the course's example workspace. It needs Node.js (the TypeScript files run directly on Node 26.12.1, where it was captured) and pnpm.

    cd examples/frontend
    pnpm install
    pnpm --filter ./labs/module-graph-analyser test   # 28 tests
    cd labs/module-graph-analyser
    node src/demo.ts                                  # prints the eight scenarios below

    Steps.

    1. Compare scenarios 1 and 3. Find the module scenario 3 removes and the exports it removes from charts and lib/dates.
    2. Compare scenarios 2 and 3. Scenario 2 treats every module as possibly effectful, so ui/tooltip stays, with its setup code (3 units) but not its unused export.
    3. Compare scenarios 3 and 4. The total is the same, the entry chunk is not: find where the date picker lands in each.
    4. Read scenarios 5 and 6: each open and then line is what a step fetches.
    5. Read scenarios 7 and 8. When the polyfill is wrongly declared pure, the output shrinks and the last line says LOST EFFECT. The analyser can say so only because the graph records the polyfill's real effect in a separate field; a real bundler has no such field and stays silent.

    Expected output, captured from a real run (Node 26.12.1, 2026-11-08):

    === 1. Everything reachable is bundled (no tree shaking)
    options: treeShake=false honourSideEffectsFlag=true keepEntryExports=false
    modules in the output: 15, removed: 0, total size: 297
    chunk entry:main                           size 107  modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal, ui/tooltip
    chunk shared:pages/checkout+pages/product  size   5  modules: lib/format
    chunk async:charts                         size 100  modules: charts
    chunk async:pages/admin                    size  40  modules: pages/admin
    chunk async:pages/checkout                 size  25  modules: pages/checkout
    chunk async:pages/product                  size  20  modules: pages/product
    
    === 2. Tree shaking, side-effect flags ignored (every module assumed effectful)
    options: treeShake=true honourSideEffectsFlag=false keepEntryExports=false
    modules in the output: 15, removed: 0, total size: 236
    chunk entry:main                           size  86  modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal, ui/tooltip
    chunk shared:pages/checkout+pages/product  size   5  modules: lib/format
    chunk async:charts                         size  60  modules: charts
    chunk async:pages/admin                    size  40  modules: pages/admin
    chunk async:pages/checkout                 size  25  modules: pages/checkout
    chunk async:pages/product                  size  20  modules: pages/product
    unused exports removed from charts: drawPie
    unused exports removed from lib/dates: diff
    unused exports removed from ui: Tooltip
    unused exports removed from ui/tooltip: Tooltip
    
    === 3. Tree shaking, side-effect flags honoured
    options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
    modules in the output: 14, removed: 1, total size: 233
    chunk entry:main                           size  83  modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal
    chunk shared:pages/checkout+pages/product  size   5  modules: lib/format
    chunk async:charts                         size  60  modules: charts
    chunk async:pages/admin                    size  40  modules: pages/admin
    chunk async:pages/checkout                 size  25  modules: pages/checkout
    chunk async:pages/product                  size  20  modules: pages/product
    removed modules: ui/tooltip
    unused exports removed from charts: drawPie
    unused exports removed from lib/dates: diff
    unused exports removed from ui: Tooltip
    
    === 4. The same, but the entry and pages import components directly (no barrel)
    options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
    modules in the output: 13, removed: 0, total size: 233
    chunk entry:main                                  size  23  modules: analytics, main, router, ui/button, ui/header
    chunk shared:charts+pages/checkout+pages/product  size  22  modules: lib/dates
    chunk shared:pages/admin+pages/product            size   8  modules: ui/modal
    chunk shared:pages/checkout+pages/product         size   5  modules: lib/format
    chunk async:charts                                size  60  modules: charts
    chunk async:pages/admin                           size  40  modules: pages/admin
    chunk async:pages/checkout                        size  55  modules: pages/checkout, ui/datepicker
    chunk async:pages/product                         size  20  modules: pages/product
    unused exports removed from charts: drawPie
    unused exports removed from lib/dates: diff
    
    === 5. Visits to build 3
    --- the shopper opens the site
    open main             fetch  83  total  83  entry:main
    --- then opens the checkout page
    open main             fetch  83  total  83  entry:main
    then pages/checkout   fetch  30  total 113  shared:pages/checkout+pages/product, async:pages/checkout
    --- or the admin opens the admin page and then the chart
    open main             fetch  83  total  83  entry:main
    then pages/admin      fetch  40  total 123  async:pages/admin
    then charts           fetch  60  total 183  async:charts
    
    === 6. The same visits to build 4
    --- the shopper opens the site
    open main             fetch  23  total  23  entry:main
    --- then opens the checkout page
    open main             fetch  23  total  23  entry:main
    then pages/checkout   fetch  82  total 105  shared:charts+pages/checkout+pages/product, shared:pages/checkout+pages/product, async:pages/checkout
    
    === 7. A polyfill imported only for its effect, with no flag
    options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
    modules in the output: 15, removed: 1, total size: 240
    chunk entry:main                           size  90  modules: analytics, lib/dates, main, polyfill/intl, router, ui, ui/button, ui/datepicker, ui/header, ui/modal
    chunk shared:pages/checkout+pages/product  size   5  modules: lib/format
    chunk async:charts                         size  60  modules: charts
    chunk async:pages/admin                    size  40  modules: pages/admin
    chunk async:pages/checkout                 size  25  modules: pages/checkout
    chunk async:pages/product                  size  20  modules: pages/product
    removed modules: ui/tooltip
    unused exports removed from charts: drawPie
    unused exports removed from lib/dates: diff
    unused exports removed from ui: Tooltip
    
    === 8. The same polyfill wrongly flagged sideEffects: false
    options: treeShake=true honourSideEffectsFlag=true keepEntryExports=false
    modules in the output: 14, removed: 2, total size: 233
    chunk entry:main                           size  83  modules: analytics, lib/dates, main, router, ui, ui/button, ui/datepicker, ui/header, ui/modal
    chunk shared:pages/checkout+pages/product  size   5  modules: lib/format
    chunk async:charts                         size  60  modules: charts
    chunk async:pages/admin                    size  40  modules: pages/admin
    chunk async:pages/checkout                 size  25  modules: pages/checkout
    chunk async:pages/product                  size  20  modules: pages/product
    removed modules: polyfill/intl, ui/tooltip
    unused exports removed from charts: drawPie
    unused exports removed from lib/dates: diff
    unused exports removed from ui: Tooltip
    LOST EFFECT: polyfill/intl was removed, but it has a top-level effect

    Break it. In src/analyse.ts, change (m.sideEffects ?? true) in mustKeepForEffects to (m.sideEffects ?? false), so a missing flag is treated as "pure". Two tests fail: an imported module with no flag must be kept, and so must the top-level code it runs. Revert it. Dropping a module that does something is a bug; keeping one that does nothing only wastes bytes.

    Stretch. Pick one:

    • Add a manualChunks-style option that forces named modules into one chunk, and find a graph where a visit fetches more than before.
    • Model a minimum chunk size: merge a shared chunk below N units into the chunk of one of its roots, and compare the number of requests with the bytes.

    In practice: a storefront whose admin tools ship to every shopper

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    A storefront ships one JavaScript file for the shopper pages, the checkout and an internal admin dashboard that staff open once a day. Shoppers on mid-range phones see a slow first screen and a long task while the page parses code they will never run. Field data (illustrative) shows well under one percent of visitors ever open the admin page. Three engineers own the front end.

    Starting architecture

    Figure 6. One bundle for everybody

    Starting architecture: every visitor downloads the admin tools and the chartsShopper to One bundle (download); One bundle to Main thread (parse and run)EVERY FIRST VISITShopper: mid-range phoneShoppermid-range phoneOne bundle: pages, admin, chartsOne bundlepages, admin,chartsMain thread: parse and run it allMain threadparse and run italldownloadparse and run

    How to read this: both boxes are paid by every visitor, whether or not they will ever open the admin page.

    Options considered

    Option What it fixes What it costs Risk
    Split by route Admin and checkout leave the entry A loading state per route A failed chunk is a failed navigation
    Split only the charting library Removes the heaviest piece Smaller win Admin code still ships to shoppers

    The decision

    Field Decision record
    Context The entry includes admin pages and a charting module that almost no visitor uses.
    Decision Split by route with import(), put the charting module behind the admin page's own import(), import components from their own modules instead of the barrel, wrap every lazy route in an error boundary with a reload action, and set sideEffects honestly.
    Consequences, good Smaller entry for shoppers; admin cost is paid by admins only.
    Consequences, bad A loading state on first navigation to each page; more failure modes.
    Revisit when Field data shows slow navigations to checkout, in which case prefetch that chunk on idle.

    Rollout and verification

    1. Predict first: write down which chunk each module should land in, then compare with the bundler's analysis report. A module in the wrong chunk is an unexpected static import.
    2. Ship behind a flag to a share of traffic and compare field metrics.
    3. Test the failure path: block a chunk request in the browser's developer tools and confirm the error boundary offers a reload.
    4. Check effects: confirm the analytics listener and the polyfills still run with the new flags.

    The 2 a.m. failure

    After a night release, support reports that "the checkout button does nothing". The cause is not the new code. Visitors who loaded the site before the release still hold the old entry chunk, which asks for page chunks by their old hashed names, and the deploy replaced the whole asset folder. Each import() rejects, React's lazy throws the rejection to the nearest error boundary, and the only boundary is at the app root, which shows a blank screen.

    First three checks: (1) the network panel of an affected session, for a failed chunk request; (2) whether the failing file name matches an earlier release; (3) whether an error boundary exists below the router. Mitigation: put the previous release's assets back next to the new ones. Follow-up: keep the previous release's assets for a defined period, reload once on failure (Vite documents a vite:preloadError event, emitted "when it fails to load dynamic imports", where event.preventDefault() stops the error being thrown), and add a retrying boundary around each lazy route.

    Illustration 3: when the flag lies

    Scenarios 7 and 8 show the other classic failure. A polyfill that adds a missing method to a global is imported only for that effect, and with no flag the bundler keeps it. Someone adds "sideEffects": false "to get a smaller bundle", the build drops the polyfill, and the page breaks in exactly the browsers that needed it, with no build error. Webpack's guide lists polyfills and global listeners as hidden side effects, and notes that a CSS file imported only for its effect needs to be on the side-effect list "so it will not be unintentionally dropped in production mode".

    Architect's notes

    • Split along routes first: the lowest-risk cut, and one users already understand as a navigation.
    • Keep the entry chunk a budget and check it in CI; it grows one import at a time.
    • Treat sideEffects as a contract. Declare it per package, list the files that do have effects, and add a test that loads the site's polyfills and global listeners.
    • Look at the chunk graph, not the import line. Whether a barrel enlarges the output depends on your bundler. Measure.
    • Design the failed state of every lazy boundary and keep the previous release's assets available after a deploy.

    Common mistakes

    • Using import() for the first screen. Anything needed at start belongs in the entry; the static form is preferable there.
    • Splitting into many tiny chunks: each adds a request and a failure mode for almost no saving.
    • Setting sideEffects: false for the whole package when some files register listeners, patch globals or import CSS.
    • Converting module syntax to CommonJS in a compiler step, which removes the static shape tree shaking needs.
    • Declaring lazy components inside other components: React's documentation says to declare them at module top level.
    • Having no error boundary around lazy routes, so a failed chunk request blanks the page.
    • Deleting old hashed assets at deploy time while visitors still hold the old entry chunk.

    Key takeaways

    • Tree shaking removes unused exports and modules; code splitting cuts the survivors into chunks; lazy loading decides when a chunk is fetched.
    • A module imported but unused is kept unless the bundler is told, with sideEffects (webpack) or moduleSideEffects (Rollup), that importing it does nothing. A false claim drops real behaviour silently.
    • import() is the cut point: it returns a promise, evaluates the module once, and is best used for what the first screen does not need.
    • A module goes into the chunk for the set of roots that need it; several roots mean a shared chunk.
    • Every lazy chunk is a network request: design a loading state, an error state and a deploy story that keeps old chunks reachable.
    • Preload hints hide the wait for what is likely next; too many cost the bandwidth splitting saved.

    Practice exercises

    Exercise 1 — Starter (30 min): In the lab, add a new module lib/markdown (size 20, sideEffects: false) used only by pages/product. Predict which chunk it lands in and the new entry size, then run the demo and check.

    Exercise 2 — Intermediate (2 hours): In a small app that uses a barrel file, read your bundler's analysis report, import a one-page component directly instead, and compare the two reports. State what one app cannot prove.

    Exercise 3 — Advanced (half day): Run the failure drill: build twice with different content hashes, serve the old entry with only the new assets, click a lazy route, and record what the page shows with and without an error boundary.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments