Skip to content
All posts

Webpack to Turbopack: what a production Next.js migration actually changed

Measured before-and-after from moving a production Next.js dashboard to Turbopack: CI builds fell from ~5 minutes to 44 seconds, and HMR did not improve at all.

Case study
5 min read
  • Next.js
  • Turbopack
  • Webpack
  • Build performance
  • React

A production Next.js App Router dashboard: 2,927 client modules, MUI, Apollo Client, Leaflet maps, seven locales. Production builds took 357 seconds on an M1 Pro, CI builds about five minutes.

Over seven months we profiled that build, benchmarked Turbopack against Webpack, migrated behind a fallback, ran a month of production traffic, then deleted the fallback. CI now takes 44 seconds. Here is what actually changed — including the headline number that didn't move.

Key Takeaways

  • CI next build went from an estimated ~5 minutes to a measured 44 seconds. Dev cold start 13.8s → 5.9s, first compile of the heaviest route 782ms → 102ms.
  • HMR didn't improve. 318ms before, 322ms after. The loop developers actually live in was already fast.
  • Memory got more stable, not smaller. Settled memory more than doubled, 67MB → 148MB. What improved was volatility.
  • Profiling before migrating found the Sentry webpack plugin alone cost 94.5 seconds — 26% of the build, fixable without changing bundler.

Profile before you migrate

The temptation is to treat the new bundler as the fix. We profiled first, and the most useful finding had nothing to do with Turbopack:

OptimizationTime savedShare of build
Move the Sentry plugin out of the build into a post-build CI step−94.5s−26.4%
Raise TerserPlugin parallelism from parallel: 2 to all cores−53.0s−14.8%

Both were measured independently against the same 357s cold baseline, so they don't add up: together they came to roughly 120–150s, not 147.5s.

The Sentry plugin was generating source maps even though productionBrowserSourceMaps: false was set, then uploading them — a quarter of the build producing artifacts the config said we didn't want. Take a win like that before you migrate: it also makes the later comparison honest, because you stop measuring Turbopack against a build with a known, fixable defect.

What improved, and what didn't

Both bundlers, same machine, same app, same day: scripted, three runs per scenario, median reported. Webpack on Next.js 15.1.12, Turbopack on 16.1.4. One confound: the branches ran different Node versions (v24.13.0 and v23.10.0), which is unlikely to explain a 58% startup gap but is a reason to treat the smaller numbers as indicative.

Dev startup and compile times, Webpack versus Turbopack Cold start 13,834ms to 5,853ms; warm start 10,939ms to 4,631ms; heaviest route first compile 782ms to 102ms; HMR effectively unchanged at 318ms against 322ms. Dev startup and compile (lower is better) Cold start 13,834 → 5,853ms Warm start 10,939 → 4,631ms Heaviest route 782 → 102ms HMR 318 → 322ms Webpack Turbopack Rows scaled independently. Median of 3 runs.
Startup and cold compile improved substantially. HMR, the innermost loop, did not move.

HMR was unchanged. 318ms median before, 322ms after: within noise, and if anything marginally slower. HMR is the loop a developer lives in — you start the dev server once a morning and save a file three hundred times. Turbopack's pitch centres on incremental compilation, and on an already-warm dev server, saving a component was never the bottleneck. The wins are real but concentrated in cold paths: process start, first compile of an unvisited route, CI. A different benefit from the advertised one.

Memory got more stable, not smaller. Two harnesses disagreed on peak — an idle run had Webpack spiking to 1,197MB against Turbopack's 513MB, a four-route walk showed the reverse — but agreed almost exactly on settled memory, 67MB against 148MB. Turbopack allocates more upfront and scales with how much of the app you touch. What improved is volatility, not the raw number.

One change is unambiguous: the Webpack build needed node --max-old-space-size=4096 to avoid heap exhaustion. Turbopack's build script is just next build.

What it deleted, and what it cost

The upgrade removed the entire webpack() function from next.config.js, roughly 120 lines down to 30. Every item in it was a workaround:

// All of this was deleted:
webpack: (config, { dev }) => {
  // 1. eslint-loader, to get lint errors during dev compilation
  // 2. config.cache = { type: "memory" }: slower builds, less memory —
  //    a direct trade against the heap ceiling
  // 3. splitChunks: { maxInitialRequests: 25, minSize: 20000 }
  // 4. TerserPlugin with parallel: 2, the under-parallelized minifier
  // 5. config.entry override to unshift polyfills into main.js
}

Turbopack handles chunking and minification itself. Only item 5 needed a replacement: it can't inject entries, so the polyfills moved into instrumentation-client.ts, which also absorbed sentry.client.config.js.

Three things got worse, all found during the benchmark phase rather than after. The bundle analyzer stopped working — @next/bundle-analyzer is Webpack-only. ESLint left the dev compile, with no Turbopack equivalent to eslint-loader. And peer dependencies blocked a clean install: two packages capped their next peer below 16. Budget time for that one; it reliably overruns.

Shipping it without an incident

Four tickets over seven months. The sequencing is the part I'd repeat unchanged.

  1. Profile the existing build — the baseline every later claim is measured against.
  2. Benchmark both bundlers on a throwaway branch, where you discover HMR won't improve before anyone has committed.
  3. Migrate with the old path retained. Turbopack became the default, but dev:webpack and build:webpack stayed working. Merge criteria were concrete: type-check clean, lint clean, 1,069 of 1,069 tests passing, both build paths succeeding.
  4. Delete the fallback only after production says so — a month of live traffic with no bundler-attributable incident, then a ticket removing the Webpack scripts and every stale doc reference.

Step 3 turns the migration from an event into a non-event: for that month, rolling back was a one-line script change rather than reverting a major framework upgrade. We never used it, which isn't the reason to keep one. Step 4 matters just as much — a fallback you never delete is a second build path that silently rots.

Was it worth it?

Yes, with one caveat.

MetricBeforeAfter
CI next build step~5 min (estimate)44s (single run)
Dev cold start13.8s5.9s
Heaviest route, first compile782ms102ms
HMR318ms322ms
Settled dev memory67 MB148 MB
Bundler config~120 lines~30 lines

The 44s is one CI run with the npm cache restored; the ~5 min baseline is an estimate from the profiling report rather than a measured median, and the derived percentage inherits that imprecision.

A meaningful share of that improvement also belongs to what the profiling found — the Sentry plugin and the under-parallelized minifier — not to Turbopack alone. We changed bundler and framework major version in one migration, so 44 seconds is a real result but not a clean attribution. To know what a new bundler bought you, fix the profiled defects first and re-baseline.

The strongest argument in the end was not the 44-second build. It was deleting ninety lines of configuration whose entire purpose was working around a memory ceiling, an under-parallelized minifier and an entry-point injection — problems that no longer exist.