Browsers have supported ES modules natively since 2018. Every browser you care about can read import and export without help.
So why is there still a build step that takes your clean module graph and produces 0od.e4.nsryo4.js?
The modules post covered the graph — how import statements form a structure a tool can analyse, and how tree shaking uses that structure to drop code nobody reached. This post is about what happens to that graph afterwards, when something has to turn a few hundred module records into a handful of files a browser can actually fetch.
Why bundling survived native ESM
The obvious answer is "fewer requests," and it's the wrong one. HTTP/2 multiplexes; a hundred small requests aren't a hundred round trips.
The real problem is depth.
Your app imports a component, which imports a hook, which imports a utility, which imports a date library. The browser cannot discover that date library until it has downloaded and parsed the three files above it. Each layer costs a full round trip, and the browser can't parallelise what it hasn't learned about yet.
A deep graph on native ESM turns into a staircase. On a fast connection you might not notice. On hotel wifi at 200ms latency, a five-level import chain is a second of nothing before anything renders.
A bundler knows the whole graph at build time. It can put the date library in the same file as the component that eventually needs it, and now that staircase is one request.
Two smaller reasons, both real:
Bare specifiers.
import _ from 'lodash'means nothing to a browser. There's nonode_moduleson the web. Something has to turn that into a URL.Your source isn't JavaScript. TypeScript and JSX are not things a browser can run. Something has to compile them regardless of bundling.
The pipeline
Every bundler, including Turbopack, does the same sequence. The differences are in speed and strategy, not in shape.
Resolve. Turn import { formatDate } from './utils' into an absolute file path. This is more work than it sounds — extension guessing, node_modules directory walking, package.json exports maps, and conditions.
Conditions are worth pausing on. A package can ship different files for different environments, and the bundler picks based on which condition names are active — import versus require, browser versus node, and one you'll meet properly in the next post: react-server. The same import statement in the same file can resolve to two different files depending on which graph is being built. Hold onto that.
Load. Read the bytes off disk.
Transform. TypeScript strips to JavaScript, JSX compiles to function calls. Next uses SWC for this — a Rust compiler doing what Babel used to do, faster. Nothing clever here, just translation.
Parse. Turn source text into an AST, and read the import and export statements out of it. This is the step that only works because ESM is statically analysable, which is the whole argument the modules post made.
Build the graph. Take every import you found, resolve it, load it, transform it, parse it, repeat. Stop when you run out of new modules. Now you have every file your app can reach, and the edges between them.
Everything up to here is bookkeeping. The interesting part is next.
Chunking: how the bundler decides what goes in which file
You have a graph of, say, 1,400 modules. You need to emit files. How many, and which modules go in which one?
This is the part most developers have never thought about, and it's the part with real consequences.
The two failure modes bound the problem:
One giant file. Every route downloads every byte of your app. Compression is excellent. Change one line and the whole thing invalidates for every returning user.
One file per module. Perfect caching granularity, perfect precision. Fourteen hundred requests, terrible compression, and you've reinvented the staircase you were trying to avoid.
Every real strategy lives between those.
Entry points
Chunking starts from entries — the places a browser begins loading. In an App Router app that's roughly one per route, plus shared framework code.
From each entry the bundler walks the graph and collects everything reachable. Two routes will overlap heavily; both need React, both need your design system. Emitting that shared code twice is waste, so it gets pulled into a chunk both routes load.
import() is a split point
This is the one lever you control directly.
const Chart = lazy(() => import('./HeavyChart'))That isn't a hint. The bundler treats a dynamic import as a hard boundary — HeavyChart and everything only it reaches go into a separate file, not loaded until that import() executes.
Every static import says "this must be present before my module runs." Every dynamic import() says "this can arrive later." That's the entire API for controlling your bundle, and it's two characters.
Chunk groups
Turbopack's model is worth knowing because it's a genuinely clean idea.
A chunk group is the set of chunks that get loaded together. Everything /home needs is one group; everything /blog needs is another. A shared chunk can belong to both.
Turbopack starts by producing many small chunks — far more than webpack did — and then merges them back up. The rule for merging is the interesting bit: it only merges chunks that belong to the same group.
The reason is the over-shipping problem. If you merge a /home-only chunk into a /blog-only chunk, every visitor to /blog now downloads code that only /home uses. But merging two chunks within the /home group costs nothing, because anyone loading one was already loading the other. The merge saves a request and adds no bytes.
That constraint is what lets it be aggressive about reducing file count without accidentally shipping your admin dashboard to your marketing page.
The thresholds are configurable, and the defaults tell you how Turbopack thinks:
// next.config.ts
const nextConfig = {
experimental: {
turbopackChunking: {
minChunkSize: 50000,
maxChunkCountPerGroup: 40,
maxMergeChunkSize: 200000,
},
},
}Read those as: don't bother making chunks smaller than 50KB, don't let a route need more than 40 files, don't merge past 200KB.
One catch worth internalising — those numbers are uncompressed, unminified bytes. Roughly five times the size of what actually ships. A 50KB minChunkSize is about 10KB on the wire. If you go tuning these, you'll be off by 5x in your head otherwise.
Module IDs, and why they decide your cache hit rate
Inside a chunk, your modules don't have file paths anymore. Each one gets an ID, and imports between them become lookups in a registry.
That sounds like an implementation detail. It's a caching mechanism.
Chunk filenames contain a content hash. Change the content, change the hash, change the URL, and every returning user downloads it again. So if module IDs were assigned in, say, the order the bundler happened to discover them, then adding one import to one file could shift the IDs of hundreds of modules — changing content in chunks you never touched, invalidating caches across your whole app for a one-line change.
So IDs are assigned deterministically. Same input, same ID, regardless of build order or machine. Your chunk hashes only move when the code in them actually moves.
And one of those IDs is the address from the last post.
1:I["./components/LikeButton.js",["chunks/main.js"],"default"]That module path and that chunk name are bundler output. The RSC payload can only point at a client component because the bundler assigned it a stable identity and wrote down which file it lives in. Post 5 is about the table that gets written down.
Minification is not tree shaking
Commonly conflated, entirely different passes.
Tree shaking works on the graph, before chunking. It asks which modules and exports nothing reaches, and drops them. It's the thing your modules post covered — sideEffects: false, /*#__PURE__*/, the export * problem.
Minification works on syntax, after chunking. It renames local variables to single letters, deletes whitespace and comments, folds constants, collapses if (true). It has no idea what a module is. It's a text transformation that preserves behaviour.
Both make the bundle smaller and that's their only similarity. Tree shaking removes code you never reached; minification shrinks code you did.
Source maps are the third piece here. Once names are mangled and files are concatenated, a production stack trace points at line 1, column 48,203 of a file that doesn't exist in your repo. A source map is the lookup table back — which is why an error monitoring service that can't find your source maps is nearly useless.
Your dev bundle is not your prod bundle
They're built differently, on purpose, and this is where people mislead themselves.
Dev optimises for the edit loop. Turbopack compiles lazily — code for routes you haven't visited isn't compiled at all. Module boundaries stay intact so Fast Refresh can swap one module without reloading the page. Nothing is minified, everything is source-mapped, and chunking barely happens.
Prod optimises for delivery. Full graph, real chunking, minification, content hashes.
Which means dev tells you nothing about bundle size. If you want to know what you're shipping, you have to build. This is also why a component can work perfectly in dev and break in prod: the dev bundle never exercised the chunking or the minifier.
What changed in Next 16
Turbopack is now the default for both next dev and next build. Two consequences you'll actually see.
More files. Webpack packed aggressively — a handful of large chunks like main-abc123.js and commons-def456.js. Turbopack produces many small, granular chunks before merging. Look in .next/static/chunks after a build and expect dozens where you used to see a few.
That's better caching, worse deploy ergonomics. If you sync build output to object storage, you now have far more files, and content-hashed names mean every build creates new ones. Old chunks accumulate unless you're deleting what's no longer in the output.
One graph instead of three. Webpack ran separate compiler processes for client, server and edge, then stitched their outputs together. Turbopack keeps a single logical graph that understands multiple environments — the same module resolves differently depending on which environment is pulling it in.
That's not a performance footnote. It's the machinery that makes the next post possible, and it's why "the same file exists in two graphs at once" is a coherent statement rather than a contradiction.
Go and look at your own build
Twenty minutes, and it's more useful than the rest of this post.
next build
ls .next/static/chunksCount them. Look at the sizes. Find the biggest one.
Then install @next/bundle-analyzer and open the treemap. You're looking for exactly one thing: something large that you don't recognise. A date library you imported once for one format call. An icon set where you used four icons. A component that pulled its entire package in through a barrel file.
What's next
Everything above applies to any JavaScript app. Nothing in it is React-specific.
But previous post left a loose end: the payload contains addresses, and something resolves them — a manifest, built at build time, mapping module IDs to chunks. That manifest is written by the bundler, which means React's server-side render depends on bundler output to function at all.
That's an unusual dependency, and it has consequences: two module graphs from one repo, imports that fail in one direction and silently poison in the other, and a single misplaced "use client" that can put your whole app in the browser's download.
Next post.
If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.