Skip to content

Turbopack Chunking in Next.js 16.3: The 2/3 Guess I Never Questioned

Turbopack Chunking in Next.js 16.3: The 2/3 Guess I Never Questioned

Short version for the impatient: Turbopack in Next.js 16.3 shipped a handful of experimental flags that let you tell the bundler how people actually move through your site, and one of them (generateComponentChunks) fixes a problem I’d assumed was unfixable. If you want the why, read on.

I’ll admit I’d never opened a Turbopack chunk before last week. I ran next build, I looked at the size table, I moved on. Then a client asked me why their marketing site was making 70-odd JavaScript requests on first load, and I spent an afternoon in the network tab with a bunch of files named things like 36wnellv-yn9q.js. Not my finest hour. But it sent me to Sam Poder’s post on the Next.js blog about how Turbopack chunks your JavaScript, and it turns out the thing I’d been ignoring is a proper optimisation problem with real trade-offs, and 16.3 changed the answer.

What a chunk actually is, and why “one big file” isn’t dumb

A chunk is just a JavaScript file the bundler emits. Your code, your dependencies, and the runtime that stitches everything together get split across dozens of them. “Chunking” is the decision about which module lands in which file.

The naive options are all wrong in instructive ways. One chunk for the whole app is great for caching (every page after the first is a cache hit) and terrible for a landing page that needs 4 KB of JavaScript but downloads 400. One chunk per page never over-ships, but your <Footer /> ends up copied into every page’s file, so a visitor who reads four pages downloads the footer four times. One chunk per module fixes both problems and then hands the browser hundreds of tiny requests. HTTP/2 made requests cheaper, not free, and gzip does noticeably worse on many small files because it can only find repeated patterns inside a single file.

So the goal is two things that pull against each other: the least code and the fewest requests. Bigger chunks mean fewer requests. Bigger chunks are also less reusable across pages.

Chunk groups and the merge maths

Turbopack’s answer is to start fine-grained and then merge chunks upward, and the interesting part is the rule for when merging pays off.

The unit it reasons about is a chunk group: all the chunks a route loads together. /home is one group, /blog is another. Turbopack only merges chunks inside a group, which is a clever constraint because everything in a group was going to be downloaded anyway. Merging can’t cause over-shipping on that page.

It can cause over-shipping on the next page, though. Say A is the footer (used everywhere) and B is a video player (home page only). Both live in the home group, so they’re merge candidates. If a visitor lands on home and leaves, merging wins outright: one request instead of two. If they click through to /legal, which needs A but not B, the merged A+B file is useless there and the browser fetches A again on its own. They’ve now downloaded the footer twice.

The Next.js post lays out every two-page scenario in a table, and the only case where merging saves anything across a navigation is A+B → A+B. Every other combination is neutral or costs you a re-download. Turbopack weights those outcomes by how many chunk groups use only A, only B, or both, and then weights single-page visits against multi-page sessions with a fixed guess: two thirds of sessions are one page, one third are two or more.

That two-thirds number is the bit I keep coming back to. It’s a reasonable default. It’s also a guess that applies to every site equally, and my client’s site is not nextjs.org.

The numbers from nextjs.org

Poder ran three configurations against nextjs.org itself, using the turbopackChunking config: never merge, the defaults, and merge everything inside each group. Same eight-page navigation path each time.

Initial load was 363.6 KiB across 76 requests with no merging, 344.2 KiB across 24 with defaults, and 315.3 KiB across 6 with maximum merging. So far, maximum merging looks like a free win. Then you keep navigating. Over the whole session, no merging totalled 561.6 KiB and 96 requests, defaults came in at 554.8 KiB and 38 requests, and maximum merging ended at 610 KiB and 15 requests. That’s roughly 10% more JavaScript shipped in exchange for far fewer requests.

I don’t think there’s a universal winner in that table, and I appreciate that the post doesn’t pretend there is. If your visitors bounce, merge hard. If they browse, don’t. The defaults are a hedge, and hedges are what you ship when you don’t know your traffic. I wrote a while back about the migration cost nobody quotes you when switching bundlers, and this is the flip side: once you’re on Turbopack, the tuning surface is small enough that you can actually reason about it.

The flag that fixes the re-download problem

Here’s the part that changed my mind. The re-download cost in the A+B → A case exists because merging happens at build time, before anyone visits, so the bundler can’t know what a browser already has. The runtime can.

In 16.3, experimental.turbopackChunking.generateComponentChunks makes Turbopack emit the un-merged chunks alongside the merged ones. The runtime tracks which items make up each merged chunk and which are already loaded, and at request time it picks whichever is cheaper: the merged file, or only the missing pieces. It works in reverse too. If you’ve already got A+B, it won’t fetch A alone.

Before 16.3, the honest config was a shrug:

// next.config.js (Next.js 16.2 and earlier)
// Nothing to tune. Turbopack merged at build time with a fixed
// 2/3 single-page assumption and you lived with the re-downloads.
module.exports = {};

After:

// next.config.js (Next.js 16.3+)
module.exports = {
  experimental: {
    turbopackChunking: {
      // emit un-merged chunks too; runtime picks the cheaper set
      generateComponentChunks: true,
      // 0..1; higher favours first load over navigation. Default 0.67.
      // Your bounce rate is a sane starting value.
      firstPageLoadPriority: 0.8,
      // routes where initial load matters most; merged more eagerly
      priorityRoutes: ['/', '/pricing'],
      // routes people visit together; overlapping chunks merge more readily
      clusters: [[/^\/docs/], [/^\/blog/]],
    },
    // CJS deps finally get tree-shaken (ESM already was)
    turbopackCjsTreeShaking: true,
    // one runtime chunk instead of one per page; ~10 KB saved per navigation
    turbopackSharedRuntime: true,
  },
};

The three analytics knobs are the same idea from a different angle. firstPageLoadPriority replaces the hard-coded two-thirds with your number. priorityRoutes tells Turbopack which pages to merge more opportunistically. clusters groups routes people tend to visit together so their overlapping chunks merge, with the caveat from the post that a cluster mixing “just A”, “just B” and “both” pages will merge less, not more.

I went with firstPageLoadPriority: 0.8 on the marketing site because its bounce rate is somewhere north of that and nobody reads a second page of a landing site. Whether that’s the right value for yours, I can’t tell you. That’s the point: it’s your analytics, not Vercel’s guess.

The smaller wins I almost skipped

Two more flags in the same release are about shipping less code in the first place, and I nearly didn’t test them because they sounded boring.

turbopackCjsTreeShaking does what it says. Tree-shaking only worked for ESM before, so unused exports from CommonJS dependencies were riding along to the client. If you have a couple of older packages in the tree (I had date-fns v2 and a colour library that hasn’t been updated since 2021), this is worth trying today. Both of these are slated to become defaults later, per the post.

turbopackSharedRuntime replaces the per-page runtime chunk with one shared runtime. The post claims it saves a blocking request and about 10 KB of client JavaScript on every navigation after the first. On the client site that was a measurable request count drop with zero code changes, which is the best kind of change to bill for.

There’s also an only-if-cached experiment mentioned, using the Cache-Control directive to check what a returning visitor already has. That’s not shipped yet as far as I can tell, so treat it as a roadmap item and not a flag to hunt for.

Where I’m still unsure

Everything under experimental is experimental for a reason, and I’d be lying if I said I’d run these flags long enough to trust them on a revenue site. generateComponentChunks emits more files, so your build output grows and your CDN has more objects to cache. I haven’t measured whether that costs anything real. The clusters regex syntax also feels like something that’ll get cleaned up before it stabilises.

The other thing I can’t shake is that the two-thirds assumption was always there, baked in, and I never questioned it. The defaults were fine. They’re still fine. But “fine” for nextjs.org and “fine” for a five-page agency site with a 90% bounce rate are different numbers, and until this release there was no way to say so. I do a lot of this kind of tuning on client sites in my freelance work, and this is the first bundler config in years I’d describe as fun.

What to do this week

Upgrade one non-critical Next.js app to 16.3, turn on turbopackSharedRuntime and turbopackCjsTreeShaking, and rebuild. Compare the network tab on first load and on one soft navigation before and after. If the request count dropped and nothing broke, add generateComponentChunks: true and set firstPageLoadPriority to your bounce rate from whatever analytics you already run. Then read the turbopackChunking reference before touching clusters, because that’s the one where a wrong guess makes things worse.