Last updated on

What Turbopack actually gives you over webpack, and what it broke


Next.js made Turbopack the default bundler back in version 16, so this site has been running it since day one of that upgrade: no flag, no opt-in, no decision made on my end at all. That's true of basically every Next.js 16 app, which is exactly why "Turbopack is fast" is a fairly boring thing to write on its own. The more useful thing to do is go find out what it's actually giving this app, with this CMS wired into it, and whether any of that comes free.

Short version: most of what Turbopack gives you here is invisible, in a good way. And the one place it wasn't invisible, it broke a workflow this site depends on, in a way that happened to surface while I was mid-draft on the post explaining why it wouldn't.

What Turbopack actually does differently

Turbopack and webpack solve the same problem (turn a tree of JavaScript, TypeScript, and CSS into something a browser or server can run) with different internal models. Webpack builds one dependency graph per target (client, server, edge) and stitches the results together. Turbopack keeps a single unified graph across all of them, and it caches at the level of individual function calls rather than whole files: change one file, and it only recomputes the specific pieces of work that depended on it. In dev mode it goes further and doesn't compile a route at all until the browser actually asks for it.

None of that requires this app to do anything differently, and that's the first real finding. I went through every Turbopack-specific option Next.js exposes: a way to swap in webpack-compatible loaders for unusual file types, a way to write custom module resolve aliases, a way to override which file extensions get resolved, a way to pin the project's root directory. Then I checked each one against what this app's build actually needs. The answer, for all four, was "nothing."

No custom loaders, no legacy Sass import syntax that only webpack understood, and nothing pushing source maps hard enough to want the extra bundle instrumentation Turbopack offers for crash reporting. The CMS sitting in front of everything (Payload) does inject a couple of Turbopack-specific settings on its own behalf. It externalizes its own server-only packages during development so fewer files need recompiling on a hot reload, and it disables an experimental fast-refresh mode that apparently breaks its admin UI's hot reload. None of that required a line of config from me. It already worked.

An audit whose honest answer to "what do we need to configure" is "nothing" isn't a dramatic finding. It's also the real headline here, even though it makes for a shorter post than a list of twelve gotchas would.

What we actually get from it

Two on-disk caches do the real work, and they're both on by default now, though they didn't start out that way at the same time. One persists dev-server compilation between restarts, so a killed and restarted next dev doesn't start from zero; that's been the default since an early Next.js 16 release. The other does the same thing for next build, and for a while it wasn't on by default, which meant every production build on Vercel was recompiling the whole app from scratch, every single time. It got flipped on for free a few weeks ago by a routine dependency bump, done to clear some security advisories, with zero intention of touching build performance.

I went and checked whether that flip actually shows up in this site's real Vercel build history, instead of just trusting the release notes. It does: the eleven production and preview builds immediately before the bump averaged 54 seconds, and the 89 builds immediately after it averaged 44 seconds, roughly a fifth faster, on a site whose builds were never slow to begin with. That's not a controlled benchmark. Plenty of other things changed in that window too, and the sample sizes are small and lopsided. But it's a real number pulled from a real deploy history, not a number I'm repeating from someone else's blog post.

I also timed the dev loop locally, with the caveat that I was sharing a machine with another running dev server at the time, so treat the specific seconds as illustrative rather than a benchmark. A cold server start took a few seconds regardless of app size, because Turbopack doesn't compile a single route until something requests it: "ready" just means the server is listening. The first real request after that cold start, the one that has to compile the whole page for the first time, took over three minutes the day I measured it. The second request to that same route, now that it was warm, dropped to about thirteen seconds. The absolute numbers are inflated by contention, but the shape underneath them holds up: nothing gets compiled until it's actually requested, and once it has been, it's fast. That's the durable part.

What it broke

This is the part that doesn't show up in anyone's release notes.

This site's development setup runs several coding agents against the same repository at once, each one working in its own git worktree so they don't collide on the same files mid-edit. To make spinning up a new worktree cheap, each one gets a node_modules that's a symlink pointing back at one shared install, instead of its own independently installed copy. It's the trick that makes the multi-agent workflow affordable: a new worktree can start working immediately against the same dependencies, without a fresh install.

Turbopack decides where a project's root is by walking up the directory tree until it finds a lockfile, and it uses that root to resolve every module the project needs, node_modules included. A node_modules that's a symlink pointing outside that root doesn't get quietly tolerated. Turbopack refuses to serve anything, citing a symlink that "is invalid, it points out of the filesystem root."

That failure never fired against this setup, right up until the same routine dependency bump that turned the build cache on for free also turned this panic on for free, in the other direction. The first time it fired was against a brand-new worktree doing nothing more unusual than starting a dev server for the first time.

The part I didn't see coming: I'd already written, and gotten two separate rounds of automated code review to sign off on, an explanation for exactly why this symlink setup was safe under Turbopack. It argued that one particular config option should stay unset, because pinning it would break the workflow worse than leaving it alone. That reasoning was correct for the problem I'd actually diagnosed at the time, a cosmetic warning about an unrelated stray lockfile sitting on my own machine. It had nothing useful to say about the real failure mode, which showed up a few hours later, from a completely unrelated change, while that explanation was still sitting in a pull request waiting to be merged.

Two independent automated reviewers, a third-party review bot and one I'd built myself for this exact repo, both flagged the write-up as stale within hours of each other, in two separate review passes, days apart. Each one caught a fact that had quietly stopped being true underneath a section I'd already convinced myself was finished. That's the more useful part of this story than the bug itself: the fix that actually shipped wasn't the config change the doc had predicted. It was a small script change that detects the symlink at dev-server startup and falls back to the previous bundler, for local development only. Production builds were never at risk, because a production build never runs against a symlinked install in the first place.

What I'd take from this

Auditing every config option a new build tool exposes, and confirming your app doesn't need any of them, is a genuinely useful exercise before adopting one. But it's a snapshot, not a guarantee. The thing most likely to invalidate it isn't a bug in the tool. It's an assumption about the environment the tool runs in that nobody wrote down, because it seemed too obvious to state: that node_modules is a real directory, not a symlink pointing somewhere unusual.

Most projects will never hit this, because most projects don't run several coding agents against one repo through worktrees sharing a single install. That's exactly why it was worth writing down instead of assuming away. Nobody else's changelog was going to flag it.

The other thing worth keeping is smaller but maybe more durable: automated review earning its keep isn't really about catching typos. It's catching a document quietly going wrong while nothing about the document itself changed, because the ground underneath it moved. That's a harder kind of correctness to check by hand, and it's exactly the kind of thing worth having a second, and third, reader for. Even when, especially when, that reader is a bot re-checking its own earlier verdict against a world that's already moved on.

Related: how this site runs multiple coding agents against one repo at once, and what flipping the newest Next.js flags actually changed here.

Comments

No comments yet. Be the first to comment.

Leave a comment