20 min read

FE-03 — Bundle & Loading Performance

How Code Gets to the Browser, Why Bundles Get Fat, and How to Fix Them

Part of The Production Frontend Engineer series. Prerequisites: FE-00 (browser internals, CRP, HTTP), FE-04 (Web Vitals, LCP root causes).


Opening

By the end of this chapter, you will never npm install a package without asking what it costs. You will look at a slow LCP and immediately think "what is the browser parsing before it can render?" You will understand why a perfectly optimised React app can still feel catastrophically slow on first load — not because of re-renders, not because of server latency, but because 4MB of JavaScript has to be downloaded, decompressed, parsed, and compiled before a single pixel of your UI can respond to a user interaction. The bundle is the foundation everything else sits on. If it is wrong, nothing else you do matters.


Part 1: What a Bundle Actually Is — Before "Webpack" Means Anything

In 2010, if you wanted to use someone else's JavaScript code in your browser app, you downloaded their .js file and added a <script> tag. Ten files meant ten <script> tags. The browser made ten separate HTTP requests. Each request had its own DNS lookup, TCP handshake, and network round trip. On a typical connection with 100ms RTT, ten scripts meant at minimum 1 second of sequential fetching before any of your code ran.

The browser also had no concept of modules. All ten scripts shared a single global scope. A variable named utils in one file collided with utils in another. Libraries worked around this with patterns like immediately-invoked function expressions — wrapping all their code in (function() { ... })() to create a private scope. This was fragile, manual, and did not scale.

The problem had two components: too many HTTP requests, and no module system. These are distinct problems that required distinct solutions, and a bundler solves both simultaneously.

A bundler takes your source code — which is split across hundreds of files for organisational clarity — and combines them into one or a few output files. The process is not simple concatenation. The bundler analyses every import and export statement in every file, builds a graph of which modules depend on which, resolves the dependency order, and then emits the minimal JavaScript needed to make everything work together in a browser that understands none of your source file structure.

The output of this process is a bundle: a self-contained JavaScript file (or set of files) that the browser can download and execute without needing to understand your source file organisation.

This chapter is about understanding that output so precisely that you can control it — making it smaller, splitting it intelligently, and loading it in the right order so the user sees your page as fast as physically possible.


Part 2: The Module Graph — The Data Structure at the Heart of Every Bundler

Before Webpack, Rollup, Turbopack, or any bundler can produce output, it must build a complete picture of how every file in your codebase relates to every other file. This picture is called the module graph, and understanding it is the key to understanding every bundling concept that follows.

Start with your application entry point — typically src/index.tsx or app/page.tsx. The bundler reads this file and finds every import statement:

typescript
// src/app/dashboard/page.tsx
import { Chart } from '@/components/Chart'
import { UserTable } from '@/components/UserTable'
import { formatCurrency } from '@/lib/formatting'
import { useAnalytics } from '@/hooks/useAnalytics'

The bundler follows each import to its source file, reads that file, finds its imports, and repeats. This is a depth-first traversal of your codebase. The result is a directed acyclic graph — each node is a module (file), each edge is a dependency relationship.

code
dashboard/page.tsx
├── components/Chart.tsx
│   ├── recharts  (node_modules)
│   │   └── d3-scale
│   │       └── d3-array
│   └── components/ChartTooltip.tsx
├── components/UserTable.tsx
│   ├── @tanstack/react-table  (node_modules)
│   └── components/TablePagination.tsx
├── lib/formatting.ts
└── hooks/useAnalytics.ts
    └── posthog-js  (node_modules)

Every node in this graph is a file. Every edge is an import. The bundler traverses every node and every edge, resolves every import to an actual file on disk, and includes every reachable node in the output.

Here is the critical implication: if a module is reachable from your entry point via any chain of imports, it is included in the bundle — whether you use it or not. If recharts exports 40 components and you only import one, the bundler still analyses all 40. Whether it includes all 40 in the output depends on whether it can determine the others are unused — this is tree shaking, covered in Part 3.

The module graph is also why large dependencies have disproportionate bundle costs. When you import { format } from 'date-fns', you are adding one node to the graph. But date-fns' format function imports locale data, helper functions, and formatting tokens — each of those is a node, each of those has its own imports. A seemingly small import can add dozens of transitive dependencies to your graph.

To see your module graph, run ANALYZE=true next build with @next/bundle-analyzer installed. This renders the module graph as a zoomable treemap — each rectangle is a module, sized proportionally to its contribution to the bundle. When you see a giant rectangle for moment.js taking up 40% of your bundle, you are seeing the module graph made visual.


Part 3: Tree Shaking — Why It Works, Why It Fails, and How to Fix It

The Physical Problem Tree Shaking Solves

Imagine you import one function from a library that exports 200 functions. Without tree shaking, all 200 functions are in your bundle. The user's browser downloads, parses, and compiles 200 functions when they needed one. This is waste at the byte level — bytes transferred, bytes parsed, bytes compiled, all for code that never executes.

Tree shaking is the bundler's process of statically analysing your code, identifying exports that are never imported anywhere in the module graph, and removing them from the output. The name comes from the mental image: shake the tree (your module graph), and dead leaves (unused code) fall off.

Why Static Analysis Is the Prerequisite

Tree shaking only works if the bundler can determine at build time — before any code runs — which exports are used and which are not. This requires that import and export relationships be statically declared: written in the source code in a form that can be read without executing the code.

ES Modules (the import/export syntax) are statically analysable. The bundler reads the source file as text, finds every import and export keyword, and knows the full dependency graph without running anything.

CommonJS (the require()/module.exports syntax used in Node.js) is not statically analysable. require() is a function call that happens at runtime. The path it receives can be a variable:

javascript
// CommonJS — dynamic, cannot be statically analysed
const moduleName = condition ? 'moduleA' : 'moduleB'
const module = require(moduleName) // bundler cannot know which module at build time

// Also CommonJS — exporting dynamically
module.exports = {
  [computedKey]: value // bundler cannot know the export name at build time
}

When the bundler encounters CommonJS, it cannot determine statically which exports are used. Its only safe option is to include the entire module — all of module.exports — because it cannot prove any of it is unused.

This is the physical reason why ES Modules enable tree shaking and CommonJS does not. It is not a policy decision by Webpack or Rollup. It is a consequence of what is knowable at build time versus what requires runtime execution to determine.

Why Tree Shaking Fails in Practice

Even with ES Modules, tree shaking fails in specific situations.

Side effects. A side effect is code that runs when a module is imported, regardless of whether you use any of its exports. If a bundler removes a module with side effects because none of its exports are used, the side effect is also removed — which may break the app. So bundlers are conservative: by default, they assume every module might have side effects and do not remove it even if its exports are unused.

The sideEffects field in package.json tells the bundler which files are safe to remove when unused:

json
{
  "name": "my-component-library",
  "sideEffects": false,
  "sideEffects": ["./src/styles.css", "./src/polyfills.js"]
}

When a library's package.json lacks a sideEffects field, the bundler assumes the worst and includes the entire library. This is why some libraries that claim to support tree shaking still bloat your bundle.

Barrel files. A barrel file is a common pattern: components/index.ts exports everything from every component file so consumers can write import { Button, Input, Modal } from '@/components'. It is convenient and it can destroy tree shaking.

typescript
// components/index.ts
export { Button } from './Button'
export { Input } from './Input'
export { Modal } from './Modal'
export { DataTable } from './DataTable'
export { Chart } from './Chart'
// ... 40 more components

When you write import { Button } from '@/components', the bundler must process components/index.ts, which imports all 42 components. Even if it tree-shakes 41 of them, it must still read all 42 files and build their module graph — making the build slower and potentially including all 42 in some bundler configurations.

The fix: import directly from the source file.

typescript
// May defeat tree shaking
import { Button } from '@/components'

// Precise — only Button.tsx and its dependencies enter the graph
import { Button } from '@/components/Button'

CommonJS in node_modules. Many npm packages still ship CommonJS. Even if your code uses ES Modules, importing from a CommonJS package means that package cannot be tree-shaken. You need either a package that ships an ES Module version (check for a "module" field in the package's package.json), or a different package.


Part 4: Code Splitting — The Architecture of a Fast App

A bundle that contains all your code must be fully downloaded before any route is functional. A user visiting your landing page downloads the code for your dashboard, settings, admin panel, and every other page.

Code splitting divides your bundle into multiple smaller chunks, each loaded on demand. The user's browser downloads only the chunk needed for the current route.

How Code Splitting Works Mechanically

The mechanism is the dynamic import() — a JavaScript expression that returns a Promise. When the bundler encounters a dynamic import, it treats the imported module and all its dependencies as a separate chunk.

typescript
// Static import — always in the bundle, loaded at startup
import { HeavyChart } from './HeavyChart'

// Dynamic import — becomes a separate chunk, loaded when this code executes
const { HeavyChart } = await import('./HeavyChart')

The bundler produces two output files: main.js and HeavyChart.chunk.js. The browser downloads main.js first. When your code executes await import('./HeavyChart'), the browser fetches HeavyChart.chunk.js. The Promise resolves when the chunk is downloaded and parsed.

React's lazy() wraps dynamic import for component-level code splitting:

typescript
import { lazy, Suspense } from 'react'

const RichTextEditor = lazy(() => import('./RichTextEditor'))

function Editor() {
  return (
    <Suspense fallback={<div>Loading editor...</div>}>
      <RichTextEditor />
    </Suspense>
  )
}

The Loading Waterfall — The Hidden Cost of Nested Lazy Loading

Code splitting introduces a class of performance bug called the loading waterfall. It happens when lazy-loaded components are nested — one chunk must be loaded before the browser discovers it needs another.

code
User navigates to /dashboard
  ↓
Browser downloads main.js
  ↓                          ← first network round trip
main.js executes, renders <Suspense>
  ↓
Browser discovers it needs dashboard.chunk.js
  ↓                          ← second network round trip
dashboard.chunk.js executes, renders <UserProfile>
  ↓
Browser discovers it needs user-profile.chunk.js
  ↓                          ← third network round trip
user-profile.chunk.js executes — user finally sees content

Three sequential network round trips. On a 100ms connection, that is 300ms of pure waiting before download even begins. The fix: ensure that all chunks needed for the first render are discovered in the first chunk, so they fetch in parallel.


Part 5: The True Cost of JavaScript — Parse, Compile, Execute

Engineers think about JavaScript cost in terms of bytes transferred. This is necessary but not sufficient. A 200KB JavaScript file has four costs, not one.

Transfer cost: bytes travel from server to browser. At 5Mbps (typical 4G), 200KB takes about 0.3 seconds.

Decompression cost: servers compress JavaScript with gzip or Brotli. Brotli typically compresses JavaScript to 15-20% of original size. A 200KB gzipped file was originally ~900KB — the browser must decompress those bytes before they are usable.

Parse cost: the browser converts decompressed JavaScript text to an Abstract Syntax Tree before any execution. V8 parses approximately 1MB/s on a high-end device. On a mid-range Android phone — representing a significant portion of real users — parse speed drops to 100-200KB/s. A 900KB decompressed bundle takes 4-9 seconds to parse on a mid-range Android phone. The main thread is blocked entirely during this time.

Compile cost: V8 compiles the parsed AST to bytecode via its Ignition interpreter, then to optimised machine code via Turbofan for hot code paths. Additional CPU time and memory.

The practical implication: parse and compile cost is proportional to bundle size, not execution cost. A utility function that takes 0.01ms to execute might take 5ms to parse. If that function is in a library with 300 other functions you never use, you pay 5ms × 300 = 1500ms of parse cost for 0.01ms of execution value. This is the physical reason bundle size matters even for fast interactions on fast networks.

The Coverage Tool — Finding Unused Code

Chrome DevTools → Command Palette (Ctrl+Shift+P) → "Show Coverage" → record → reload → stop.

The Coverage panel shows each file, total bytes, and unused bytes. A file showing 85% unused means you downloaded 100KB but only 15KB ever ran. For a typical React SPA on its landing page, 60-80% of the JavaScript bundle is unused code — components for other routes, utilities for features not on this page, entire library sections never invoked.


Part 6: Preloading and Prefetching — Loading Before You Need To

Code splitting introduces on-demand loading. Prefetching bridges the gap — starting the download of a chunk before the user navigates to it, so the chunk is already cached when they arrive.

The Physical Difference Between Preload and Prefetch

<link rel="preload"> tells the browser: "I will definitely need this resource very soon on the current page. Download it now at high priority." The browser downloads the resource immediately, competing for bandwidth with other critical resources.

<link rel="prefetch"> tells the browser: "I might need this for a future navigation. Download it at low priority during idle time." The browser downloads it when the network and CPU are underutilised, without competing with critical resources.

Using preload for a resource you won't need for several seconds causes resource contention — your optimisation makes things worse by competing with resources needed immediately (like your LCP image).

Next.js's <Link> component prefetches the destination route's JavaScript chunk when the link enters the viewport via IntersectionObserver. On hover, Next.js upgrades to a higher-priority preload — because hover strongly predicts an imminent click.

By the time the user clicks, the chunk is already in cache. Navigation appears instant — not because navigation is fast, but because the work happened speculatively during idle time.

Manual Prefetching for Lazy Components

typescript
const RichTextEditor = lazy(() => import('./RichTextEditor'))

function preloadEditor() {
  import('./RichTextEditor') // starts chunk download before user clicks
}

function EditButton() {
  return (
    <button
      onMouseEnter={preloadEditor}
      onClick={() => setEditing(true)}
    >
      Edit
    </button>
  )
}

When preloadEditor fires on hover, the browser starts downloading the chunk. When the user clicks and React renders <RichTextEditor>, React.lazy()'s Promise resolves immediately — the chunk is already there. No loading spinner.


Part 7: Third-Party Scripts — The Hidden Bundle Killer

Your own code is under your control. Third-party scripts are not.

A typical mid-size SaaS product includes:

  • Google Analytics: ~45KB
  • Intercom or Crisp (chat): ~200KB
  • Hotjar (session recording): ~140KB
  • Stripe.js: ~180KB
  • An A/B testing platform: ~80KB

That is approximately 650KB of third-party JavaScript before you have written a single line of your own code. On a mobile device, parsing 650KB costs 3-5 seconds of main thread blocking. Every user interaction during those 3-5 seconds is delayed. Your INP score pays the price.

Third-party scripts are particularly damaging because they execute on your main thread, their size and execution time are outside your control, and they frequently load their own sub-dependencies.

Load async or defer: <script> without attributes blocks HTML parsing. defer downloads in parallel and executes after HTML parsing completes. For analytics and chat scripts, defer is always correct.

Delay until after interaction: load chat widgets only after the user first interacts with the page — they are never needed in the first 5 seconds.

typescript
let loaded = false
function loadIntercom() {
  if (loaded) return
  loaded = true
  // inject the Intercom script tag dynamically
}

document.addEventListener('mousemove', loadIntercom, { once: true })
document.addEventListener('touchstart', loadIntercom, { once: true })

Use a facade: render a static placeholder in place of a third-party widget. Only load the real widget when the user interacts with the placeholder. A video embed is a thumbnail until the user clicks play.


Part 8: Import Cost Thinking — The Mental Model for Every Dependency Decision

Before installing any npm package, run this mental checklist.

Check bundlephobia.com first. Type the package name. It shows: minified size, gzipped size, download time on 4G, parse time estimate, and whether the package is tree-shakeable. Anything above 100ms estimated parse time on a slow device is a significant cost.

Check for smaller alternatives.

PackageGzippedAlternativeGzipped
moment72KBdate-fns (tree-shakeable)2-5KB for used functions
lodash24KBlodash-es + direct imports<1KB per function
axios11KBnative fetch0KB
chart.js (full)63KBchart.js (tree-shaken)20-30KB

moment is the canonical example. Not tree-shakeable (CommonJS), includes locale data for every locale in the world, 72KB gzipped. date-fns is fully tree-shakeable — if you only use format and parseISO, you pay approximately 5KB. Same functionality, 14× smaller.

Consider native APIs first. Native fetch replaces axios. Intl.DateTimeFormat handles most date formatting. Native Array methods cover most of lodash. The cost of a native API: zero bytes.

Check how you import. Even tree-shakeable packages can be imported incorrectly:

typescript
// Imports the entire library — defeats tree shaking
import _ from 'lodash-es'
const result = _.groupBy(items, 'category')

// Imports only groupBy — correct
import { groupBy } from 'lodash-es'
const result = groupBy(items, 'category')

Part 9: Caching and Content Hashing — Making Bundles Permanently Fast

After making your bundle small, ensure the browser caches it aggressively. A bundle downloaded once and cached indefinitely costs 0ms on every subsequent page load.

The mechanism: content hashing. The bundler computes a hash of each chunk's content and includes it in the filename.

code
main.abc123def.js      ← hash computed from file content
vendor.xyz789abc.js

When you deploy a new version, the content changes, the hash changes, the filename changes. The browser sees a URL it has never seen — downloads the new version. Files with unchanged hashes are served from cache.

The server sends for hashed assets:

code
Cache-Control: public, max-age=31536000, immutable

One year. Immutable means this URL's content will never change. The browser never checks for updates.

For index.html — which references the hashed filenames:

code
Cache-Control: no-cache

The browser checks with the server before using it. When you deploy, the HTML changes (new hashed filenames), the browser downloads the new HTML, then loads the new chunks. The invariant: hashed chunks are cached forever. HTML is verified every load. Deployments are always reflected immediately with zero stale-cache bugs.


Vocabulary Map

TermWhat it physically is
BundleA single JavaScript file produced by combining all source modules and their dependencies into a form the browser can execute without understanding your source file structure
Module graphA directed graph where every node is a JavaScript file and every edge is an import relationship — the complete picture of how every file depends on every other file
Tree shakingStatic analysis of the module graph to identify exports that are never imported, and removal of those exports from output — only possible with ES Modules because they are statically analysable
Side effectCode that runs when a module is imported regardless of whether any of its exports are used — examples: polyfills modifying globals, CSS injected into the document
CommonJSNode.js's original module system using require() and module.exports — not statically analysable because require() is a runtime function call that can receive dynamic values
ES ModulesJavaScript's native module system using static import/export — analysable at parse time without executing the code, enabling tree shaking
Code splittingDividing the bundle into multiple chunks where each is loaded on demand — reduces JavaScript that must be downloaded before the page is interactive
ChunkA separate bundle output file produced when the bundler encounters a dynamic import() — the browser fetches it only when that code path executes
Dynamic importThe expression import('./module') that returns a Promise — the bundler treats the imported module as a separate chunk
Barrel fileAn index.ts file that re-exports from multiple module files — convenient but can prevent tree shaking by pulling entire directories into the module graph
Parse costCPU time the browser spends converting raw JavaScript text into an AST before any code executes — proportional to uncompressed file size, blocks the main thread entirely
PreloadBrowser hint to download a resource immediately at high priority for the current page — competes for bandwidth with other critical resources
PrefetchBrowser hint to download a resource at low priority during idle time for a future page — does not compete with critical resources
Content hashA hash of file contents included in the filename — changes when file content changes, allowing permanent caching with guaranteed freshness on deploy
sideEffects fieldA field in package.json that tells bundlers which files are safe to remove when unused — absence forces bundlers to defensively include entire packages
Loading waterfallA performance pattern where chunks must be loaded sequentially because each chunk's existence is only discovered after the previous chunk has been downloaded and executed

Practice Problems

Problem 1 — Failure Analysis

A Next.js app has a bundle analyser showing lodash taking 24KB gzipped in the main bundle. The engineering team installed lodash-es (the ES Module version) specifically to enable tree shaking, and imports with import { debounce } from 'lodash-es' in five files. debounce alone should be under 2KB. Why is the full 24KB present in the bundle? Identify the two most likely causes given what you know about tree shaking failure modes, and describe exactly what you would look for in the codebase to confirm each hypothesis.

Problem 2 — Design from Scratch

You are building a SaaS dashboard with these routes: / (landing page, static, no auth), /login (form, minimal JS), /dashboard (data-heavy, requires Chart.js and TanStack Table), /dashboard/reports (requires a PDF generation library — 340KB gzipped). Your current bundle is a single 780KB file served to all routes. Design the complete code splitting strategy: which chunks should exist, what goes in each chunk, and what prefetching strategy you would apply. Your target: the landing page downloads under 50KB of JavaScript. State your assumptions about what Chart.js and TanStack Table contribute to bundle size.

Problem 3 — Synthesis

A product manager reports that users on the paid plan using /reports experience slow loads on mobile. Your bundle analyser shows the reports route chunk is 380KB gzipped. LCP for /reports is 6.2 seconds on throttled 4G. TTFB is 120ms. Connect what you know from this chapter (bundle cost, parse cost) and FE-04 (LCP root causes and the rendering pipeline) to explain precisely which stage of the browser's pipeline is responsible for the 6.2 second LCP. Then state the correct order of fixes and explain why each fix affects LCP even though LCP appears to be a rendering metric rather than a JavaScript metric.

Problem 4 — Go Deeper

V8 parses approximately 1MB/s on a high-end laptop and 100-200KB/s on a mid-range Android phone. Your bundle is 400KB gzipped, decompressing to approximately 1.8MB. Calculate the parse time on each device. A React.lazy() split moves 200KB gzipped into a separate chunk loaded on the second page the user visits. On a warm cache (the chunk was already downloaded on first visit), the browser must only parse it — no download. Recalculate what the user experiences on each device for the second-page navigation. What does this tell you about who benefits most from code splitting — and why does this change your prioritisation of which routes to split first?

Problem 5 — Previous Chapter Connection (FE-04)

In FE-04 you learned that a resource discovered late causes poor LCP because the browser cannot start downloading it until the discovery point. A developer code-splits their hero image component with React.lazy() to reduce the initial bundle size. Trace the exact sequence of events from initial HTML arrival to LCP paint for both the non-split case (image in server-rendered HTML) and the split case (image inside a lazy-loaded React component). Explain precisely why the split decision — while reducing bundle size — will worsen LCP. Then design a solution that achieves both goals simultaneously: smaller initial bundle and fast LCP. Your solution must not require putting the image back into the main bundle.


What's Coming Next

You now control what goes into the bundle before it reaches the browser, and you understand the cost each byte pays in parse time on the main thread. FE-06 picks up exactly at the moment the bundle has been executed and React has rendered your component tree — the moment the browser must convert that tree into pixels on the GPU. The rendering pipeline that turns JSX into a composited frame is governed by physics: some CSS properties trigger all three stages (layout, paint, composite), some trigger only paint, some only composite. Knowing which is which — not from a reference card but from understanding why the GPU renders certain operations without involving the CPU at all — is what allows you to write CSS that is structurally fast rather than CSS you hope is fast.