Turbopack Incremental Compilation

Turbopack replaces monolithic rebuild cycles with a Rust-native demand-driven computation engine: instead of re-traversing entry points on every file change, it models the project as a graph of memoized functions and recomputes only the nodes a change actually invalidates. By isolating changed nodes, computing minimal deltas, and persisting serialized results across sessions, it reaches sub-50ms hot module replacement (HMR) and high cache reuse on warm starts. This guide details the graph engine, delta propagation, and invalidation strategy needed to run Turbopack in real Next.js projects; for the broader engine comparison and pipeline orchestration, start from esbuild & Turbopack Workflows before tuning the knobs below.

The problem this solves is structural, not incremental tuning. A conventional bundler treats a rebuild as a fresh function call: given the entry points, produce the graph, produce the output. Because the function has no memory, the cost of a rebuild is proportional to the size of the reachable graph rather than the size of the change. On a project of a few hundred modules nobody notices; on an App Router tree of several thousand modules, a one-character edit to a leaf component pays for re-parsing, re-resolving, and re-transforming code that did not change. The keystroke-to-pixel latency that results — often several hundred milliseconds — is the tax that makes large frontends feel sluggish to develop even on fast hardware. Turbopack’s thesis is that the rebuild should cost what the change costs, and nothing more.

To make that true, the engine has to remember what it already computed and prove which of those results a given edit cannot have affected. It does this by modelling the whole build as a graph of memoized functions and tracking, at fine granularity, which function’s output fed which other function’s input. When a byte changes on disk, the engine does not ask “what is the new graph”; it asks “which cached function results are now stale, and what is the smallest set of recomputations that restores consistency”. Everything else in this guide — the persistent cache layout, the sub-50ms HMR delta, the failure modes — falls out of that one design decision. Get the memoization contract right and the numbers hold; violate it anywhere and you silently fall back to full-rebuild behavior while the engine still reports success.

Where this sits in the pipeline matters too. Turbopack is the dev-time compilation and serving engine; it is not (yet) the production bundler, and it does not replace your framework’s routing, server-rendering, or deployment steps. It owns the loop between “you saved a file” and “the browser shows the change”, which is the loop you spend all day inside. Understanding its graph model is therefore less about squeezing a benchmark and more about keeping that loop honest as the codebase grows past the point where a naive bundler would stall.

Turbopack ships as the development server engine inside Next.js. Enable it with next dev --turbopack (the --turbopack flag is stable as of Next.js 15). Standalone Turbopack outside Next.js is not yet stable for production.

Turbopack demand-driven incremental computation A request walks the memoized function graph; unchanged nodes are reused from cache and only invalidated nodes are recomputed. Request a route, recompute only invalidated nodes Request app/page.tsx parse() node cache hit, reuse resolve() node cache hit, reuse transform() node invalidated, recompute HMR delta < 50KB patch Persistent cache: .next/cache/turbopack serialized node results keyed by content hash + compiler flags + env digest, restored on warm start
Figure: a request walks the memoized function graph; cached nodes are reused and only the invalidated transform is recomputed and shipped as an HMR delta.

Prerequisites

This guide assumes Next.js 15.x, Node 18.18+ (Node 20 LTS recommended), and a project using the App Router. Verify your toolchain before configuring anything:

# Next.js 15.x / Node 20+
node --version          # v20.x or newer
npx next --version      # Next.js 15.x
cat package.json | grep '"next"'

Pin Next.js exactly in CI. Turbopack’s on-disk cache format is tied to the engine version baked into each Next.js release; a minor bump can invalidate every persisted node, so an unpinned dependency turns warm starts into silent cold starts.

The failure mode here is quiet, which is what makes it dangerous. Nothing errors when the cache format changes: Turbopack reads the directory, finds no entries whose format signature matches the current engine, discards them, and rebuilds from scratch. Your CI logs show a green run and a slightly longer build, and unless you are watching ready-time as a tracked metric you will never notice that the cache you carefully restored contributed nothing. This is why the recommendation is an exact pin ("next": "15.3.2"), not a caret range. A caret range lets a patch or minor land during a routine npm install and silently retire the entire warm-start budget for every developer and every CI job until someone re-pins. Treat the Next.js version the same way you would treat a compiler version in a language toolchain, because for Turbopack’s purposes that is exactly what it is.

Node’s version matters for a narrower reason: the loader runtime and the file-watching layer both depend on Node’s underlying APIs, and older Node lines have watcher quirks on Linux inotify and macOS FSEvents that surface as missed rebuilds rather than crashes. Node 20 LTS is the safe floor; if you are stuck on 18, stay on 18.18 or newer, below which several App Router features degrade. Confirm all three — Node, Next.js, and the App Router directory layout — before you spend any time interpreting timing numbers, because a mismatched toolchain produces symptoms that look exactly like a Turbopack bug and waste an afternoon.

An unpinned Next.js version silently voids the cache Turbopack's on-disk cache format is tied to the engine version inside each Next.js release, so a pinned version keeps warm starts fast while an unpinned minor bump invalidates every persisted node and turns a warm start into a silent cold start. next pinned 15.3.2cache format stable minor bump 15.4engine version changes silent cold startevery node invalidated pin the version or lose every warm start
Figure: the cache is keyed to the engine baked into each release — an unpinned bump quietly reverts you to cold builds.

Core Mechanics

Demand-driven graph tracking

Invalidation propagates outward until output stops changing When a source file mutates, Turbopack marks the dirty transform node and recomputes outward through dependents, stopping propagation as soon as a node's output is unchanged; unchanged parse and resolve nodes upstream are reused from cache. recompute only the dirty subgraph, then stop edit sourcebyte changes transform nodedirty, recompute dependent chunkrecompute output unchangedpropagation stops unchanged parse/resolve nodes never re-run — reused from cache
Figure: the walk halts the moment a node's output matches its cached value — that early stop is the whole speed story.

Turbopack constructs a persistent computation graph during the initial parse phase. Each unit of work — parse, resolve, transform — is a memoized function whose output is cached against its inputs. Imports resolve at the AST level and map to immutable module identifiers. When a source file mutates, the engine marks the dirty function nodes and recomputes only the affected subgraph, walking outward until a node’s output is unchanged and propagation stops. This is the foundation of every incremental claim Turbopack makes; the same demand-driven model underpins the engine comparison in esbuild & Turbopack Workflows.

The word “demand-driven” is doing real work in that sentence and is worth unpacking. The graph is not eagerly recomputed top to bottom when a file changes. Instead, computation is pulled: the dev server asks for the output of a particular route, that request pulls on the functions it depends on, and each of those pulls on its own dependencies, until the request reaches either a cached result it can reuse or a dirty node it must recompute. A function whose result no consumer currently demands is never recomputed at all, even if its inputs changed — the work is deferred until something actually asks for it. In practice this means an edit to a component that is not on any open route costs nothing until you navigate to a page that imports it. This is the opposite of a watch-and-rebuild loop that eagerly reprocesses every changed file regardless of whether the output is needed.

The mechanism that makes early stopping correct is per-node output comparison. When a dirty transform node recomputes, the engine compares the new output against the previously cached output. If they are byte-identical — which happens constantly, for example when you edit a comment, reformat whitespace, or make a change that the transform normalizes away — the node’s consumers are not marked dirty, and propagation halts right there. Only when the output genuinely differs does the dirtiness ripple to dependents. This is why a formatting-only save is nearly free even in the middle of a deep dependency chain: the transform reruns once, produces the same bytes, and the wave dies immediately. The whole speed story is that stop condition, not raw transform throughput.

Two invariants keep this graph sound, and both are contracts you can break from application code. First, every function must be pure with respect to its declared inputs: given the same source bytes, resolution context, and compiler flags, it must produce the same output. Second, every input a function actually reads must be part of its cache key, or the engine cannot know to invalidate it. Turbopack tracks the standard inputs (file contents, resolved dependencies, config, environment digest) automatically, but a custom loader that reaches outside those — reading the clock, a network resource, or an untracked file — silently violates both invariants. The consequence is not an error; it is a graph that is confidently wrong, either serving stale output or recomputing forever. The debugging section returns to this because most “Turbopack is slow again” reports trace back to exactly one of these two violations.

Partial module evaluation

Only modules with changed AST nodes or updated dependency hashes are re-evaluated. The Rust execution engine isolates evaluation contexts so unchanged modules retain their compiled output. In benchmarked App Router projects, this drops incremental latency from roughly 800ms (full rebuild) to under 35ms for a localized edit in a tree exceeding 5,000 modules.

The granularity here is finer than the file. A module’s compiled output depends on its own transformed source and on the resolved identities of its imports — not on the internal contents of those imports. That distinction is what lets an edit stay local. If you change the body of a function inside utils.ts but its exported signature and the set of names it exports are unchanged, the modules that import utils.ts see the same resolved identifiers and the same module boundary, so their transform results stay valid and are reused. Only utils.ts itself recompiles. The dirtiness would propagate to importers only if the change altered something they actually observe — a new export, a removed export, or a change to the module’s shape that the resolver records. This is the concrete reason a “localized edit” stays localized: locality is a property the graph enforces, not a hope.

The corollary is that some edits are structurally expensive no matter how small they look. Changing a widely-imported barrel file, a shared type that forces re-resolution, or a module near the root of the dependency tree touches many dependents’ inputs, so many nodes go dirty and the recompute is large. A 200ms rebuild after a one-line change is almost always this: you edited something with a high fan-out, not something Turbopack handled badly. The fix is architectural — narrow the module boundary so edits land on leaves — which is why the performance section treats slow single-file rebuilds as a module-boundary smell rather than an engine problem.

State persistence across sessions

Graph state survives process restarts through serialized snapshots written under .next/cache/turbopack. The engine persists module boundaries, hash digests, and node results, so a warm start restores the graph instead of re-parsing the dependency tree. The detailed on-disk layout and CI restoration strategy live in Configuring Turbopack cache for Next.js projects.

Conceptually, the persistent cache is the in-memory graph written to disk with its keys intact. Each node result is stored against a composite key: the content hash of the source it was computed from, the compiler flags in effect, and a digest of the environment that participated in the computation. On a warm start the engine walks the requested routes exactly as it would on a cold start, but each pull first checks the on-disk cache for a matching key. A hit deserializes the stored result and skips the work entirely; a miss recomputes and writes a fresh entry. Because the key includes the content hash, a file that changed while the server was stopped simply misses and recomputes on first demand — there is no separate “is the cache stale” scan, correctness falls out of the keying.

This is also why the cache is safe to delete but expensive to lose. Deleting .next/cache/turbopack never produces wrong output; it only forfeits the warm-start speedup, because the next run recomputes every demanded node and repopulates the directory. The reset commands later in this guide lean on exactly that property: when something looks corrupt, throwing the cache away is a correctness-preserving operation, not a gamble. The cost is a single slow start while the graph is rebuilt from source.

Delta HMR and the update protocol

Recomputing the minimal subgraph would be wasted if the browser then reloaded the whole page. The second half of the incremental story is that Turbopack ships only the changed module output to the client over a WebSocket and patches it into the running application in place. When the transform node for an edited module produces new output, the engine diffs the affected chunk against what the client already holds and emits a small update payload — typically well under 50KB for a leaf edit — that names the changed module and carries its new factory function. The client-side runtime swaps that module’s implementation and re-runs the accept boundaries that depend on it, preserving component state where the framework’s HMR handlers allow it.

The boundary that determines whether state survives is the same graph boundary the compiler tracks. React Fast Refresh, which Next.js wires into the runtime, can hot-swap a component and keep its hooks state as long as the edit stays within a module that only exports components. The moment an edit crosses into a module that also exports non-component values, or changes something the refresh boundary cannot reconcile, the runtime falls back to a full route reload. So a “why did my form state just reset” complaint is usually not a Turbopack bug but a module that mixes a component with other exports, forcing the wider reload. Splitting the non-component export into its own file restores in-place patching. You can watch this distinction live: a true delta shows a small WebSocket frame and no navigation entry, whereas a fallback reload shows a document request in the Network tab.

Configuration & CLI Reference

Turbopack configuration lives under the top-level turbopack key in next.config.js as of Next.js 15.3 (the older experimental.turbo namespace is deprecated and emits a warning). The block below is complete and runnable.

Keep the surface area small on purpose. Turbopack deliberately exposes far fewer knobs than a webpack config, and that is a feature: every rule you add is a loader whose determinism you now own, and every alias is a resolution edge the graph must track. Three keys cover almost every real project. rules maps file extensions to webpack-compatible loaders for asset types the engine does not handle natively. resolveAlias re-points import specifiers, and it must mirror your tsconfig.json paths exactly — a divergence means TypeScript and the bundler disagree about what @/lib resolves to, which produces editor-green, build-red confusion. resolveExtensions sets the order in which the resolver tries suffixes for extensionless imports; overriding it is rare and mostly needed when a legacy codebase relies on an unusual precedence.

Three Turbopack config keys and where the namespace moved The turbopack config block holds rules that map extensions to loaders, resolveAlias that mirrors tsconfig paths, and resolveExtensions that sets resolve order; as of Next.js 15.3 this lives at the top-level turbopack key, not the deprecated experimental.turbo namespace. experimental.turbo → top-level turbopack (15.3+) rules*.svg → @svgr/webpackextension → loader resolveAlias@/components → ./srcmirror tsconfig paths resolveExtensions.tsx .ts .jsx .jsresolve order
Figure: three keys cover almost every project — and the namespace move to top-level turbopack is the one migration people miss.
// next.config.js — Next.js 15.3.x / Node 20+
/** @type {import('next').NextConfig} */
const nextConfig = {
  turbopack: {
    // Map non-standard extensions to webpack-compatible loaders.
    rules: {
      '*.svg': {
        loaders: ['@svgr/webpack'],
        as: '*.js',
      },
    },
    // Override module resolution aliases (mirror your tsconfig paths).
    resolveAlias: {
      '@/components': './src/components',
      '@/lib': './src/lib',
    },
    // Resolve order. Defaults: .tsx .ts .jsx .js .mjs .cjs .json
    resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.json'],
  },
};

module.exports = nextConfig;
# Next.js 15.3.x — start, trace, and reset
next dev --turbopack                          # enable the dev engine
NEXT_TURBOPACK_TRACING=1 next dev --turbopack  # verbose timing trace
rm -rf .next && next dev --turbopack           # discard all cache, full rebuild

The resolveAlias values must stay in lockstep with tsconfig.json, and the cheapest way to guarantee that is to treat the tsconfig as the source of truth and derive both from it mentally when you edit either. The two files below are the matched pair; if you change one alias, change the other in the same commit, or you will get an import that the type-checker accepts and the bundler cannot resolve.

// tsconfig.json — the paths Turbopack's resolveAlias must mirror
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/components/*": ["./src/components/*"],
      "@/lib/*": ["./src/lib/*"]
    }
  }
}

A note on the tracing flag, because it is the single most useful diagnostic here. NEXT_TURBOPACK_TRACING=1 writes a structured timing trace of the recompute — which nodes ran, how long each took, and how the work fanned out. When a rebuild is slower than it should be, the trace tells you whether time went into parsing, resolution, or a specific loader, and it names the module that dominated. Read it before guessing; the difference between “a barrel file forced 400 transforms” and “one loader is slow” is obvious in the trace and invisible from the terminal banner alone.

Step-by-Step Workflow

Five steps to operationalize and measure Turbopack Enable the engine with next dev turbopack, confirm the cache directory materialized, measure a warm start against the cold run, edit a leaf component and watch Compiled in N ms, then trace any slow rebuild with the tracing env var. 1 enable--turbopack 2 confirm cachels .next/cache 3 warm startcompare ready 4 edit leafCompiled in Nms 5 trace slowTRACING=1
Figure: steps 3 and 4 are the measurements that prove the cache is doing its job; step 5 is where you look when it isn't.
  1. Enable the engine. Switch your dev script to next dev --turbopack and start it once to populate the cache. This first run is a cold start by definition — there is no persisted graph to restore — so do not read anything into its ready time except as the baseline you will measure warm starts against. If the flag is silently ignored, you are on a Next.js version too old to stabilize it; check npx next --version before anything else.
  2. Confirm the cache materialized. Run ls -lh .next/cache/turbopack. An empty or missing directory means the cold start never got far enough to persist anything, usually because it errored during the initial graph build; a populated directory with recently-written files is the prerequisite for any warm-start gain. If the directory is present but stays empty across runs, something is discarding it — a clean script, a container that mounts .next on tmpfs, or a CI cache step that never restored it.
  3. Measure a warm start. Stop the server, restart with next dev --turbopack, and compare ready-time against the first run. A warm start should be markedly faster because it deserializes cached node results instead of re-parsing the tree. If the two times are within noise of each other, the cache is not being reused — the most common cause is a Next.js version that changed between runs, which voids the format, followed by an env or config edit that invalidated everything.
  4. Edit a leaf component and watch the terminal for Compiled in <N>ms. A single-file edit should recompile in well under 200ms. Choose a genuine leaf — a component nothing else imports — for this measurement, because editing a shared module measures fan-out, not incremental speed. Save the same file twice with no change and the second save should be near-instant, confirming the output-comparison stop condition is working.
  5. Trace a slow rebuild. If a rebuild exceeds 200ms, run NEXT_TURBOPACK_TRACING=1 next dev --turbopack and inspect which node dominates the recompute. The trace distinguishes the two causes that look identical from the outside: a high-fan-out edit that dirtied many nodes, versus a single slow loader or an accidental full invalidation. Fix the first by narrowing module boundaries and the second by auditing your rules loaders for determinism.

Verify the delta path directly: open the browser DevTools Network tab, filter to WebSocket frames, and confirm a single-file edit ships a payload under ~50KB rather than a full bundle. If instead you see a document navigation request, the update fell back to a full route reload rather than an in-place patch — refer back to the delta HMR discussion, since the usual cause is a module that mixes component and non-component exports and defeats Fast Refresh.

Debugging & Failure Modes

Four Turbopack failure modes and their fixes Symlink and dynamic-import edge cases make the watcher miss events so use static literals and workspace deps; non-deterministic loaders break memoization so keep loader output pure; env-variable drift needs a dev-server restart; a corrupt cache is fixed by removing .next and restarting. symlink / dynamic import→ static literals + workspace deps non-deterministic loader→ pure output, no timestamps env-variable drift→ restart dev server corrupt cache→ rm -rf .next && restart
Figure: three are memoization-contract violations; the fourth is the nuclear reset when HMR stops reflecting edits.

Symlinked directories and dynamic import() with variable paths can make the watcher miss filesystem events, leaving stale results. The symptom is specific: you edit a file, the terminal shows no Compiled in line, and the browser keeps serving the old output — the edit landed on a path the watcher is not observing. The root cause is that the file watcher registers real inode paths, while a symlink presents a second name for the same content; an edit made through the canonical path may not fire an event on the symlinked path the graph recorded, or vice versa. The fix for monorepo packages is to declare them as workspace dependencies in package.json so the resolver walks them through the workspace protocol and records the real paths it watches. Confirm the fix by editing the linked package and checking that a Compiled in line appears within a second.

Dynamic import() with a computed path is the other half of this. When the specifier is a variable, the resolver cannot enumerate the dependency at graph-build time, so it cannot register the target for watching, and edits to that target go unnoticed until a full rebuild. Prefer static string literals, or where you genuinely need dynamic selection, an explicit glob pattern the resolver can expand into a known set of modules. Resolution failures from these same patterns are diagnosed in depth in Debugging Turbopack module resolution errors.

Non-deterministic loaders

Webpack-compatible loaders registered under turbopack.rules must produce identical output for identical input. A loader that embeds a timestamp or random id breaks the memoization contract and forces perpetual recompilation, defeating the cache. The symptom is the tell: rebuilds that should be free stay slow, the trace shows the same loader node recomputing on every save regardless of what you edited, and warm starts never beat cold starts. The root cause is that the engine caches the loader’s output against its input, then on the next demand recomputes, compares, finds the output different (because the timestamp advanced), and marks all dependents dirty — every single time. The cache is technically working; the loader is lying to it.

The fix is to make loader output a pure function of its input: strip timestamps, seed any randomness deterministically from the source, and never read the wall clock or an untracked file. To confirm, run the loader against a fixed input twice and diff the two outputs; they must be byte-identical. If you cannot make a third-party loader deterministic, the honest options are to pin its output through a caching wrapper or to move that transformation out of the hot dev loop entirely. A loader that generates unique IDs for, say, CSS module class names should derive them by hashing the file path and local name, which is stable, rather than from a counter or clock, which is not.

Environment-variable drift

Environment variables are folded into node cache keys. Editing .env.local invalidates affected boundaries, and the running server may hold a stale value. The symptom is a value that is correct in one place and wrong in another: a module that inlined process.env.NEXT_PUBLIC_API_URL at transform time keeps the old string because its transform result is still cached against the old env digest, while code that reads the variable at runtime already sees the new value. The root cause is that the env digest participates in the cache key only for nodes that actually read env during their computation; the dev server does not proactively re-diff the entire .env file against every node on each save. Restart the dev server after any env change to guarantee a consistent graph — a restart re-reads the environment and re-keys from a clean digest. Confirm by grepping the served bundle for the expected value, not by trusting the terminal, which reports success either way.

Forced rebuild

When HMR stops reflecting changes or the cache appears corrupt, escalate in order rather than jumping straight to the sledgehammer. First try a hard refresh in the browser to rule out a client-side runtime that missed an update frame. If the terminal is showing Compiled in lines but the browser is not updating, the problem is on the client; if the terminal shows nothing on save, the problem is in the watcher or the graph and a reset is warranted. Only then remove .next. Because the cache is keyed by content hash, this reset can never produce wrong output — it only forfeits the warm-start budget for one run, which is a cheap price for a clean graph.

# Most reliable reset
rm -rf .next && next dev --turbopack

# Bisect against the webpack bundler to isolate a Turbopack-specific bug
next dev

The second command is a bisection tool, not a fallback you should live on. If a page reflects edits under next dev (webpack) but not under --turbopack, you have isolated a Turbopack-specific defect rather than an application bug, and that distinction is exactly what a maintainer needs. Capture NEXT_TURBOPACK_TRACING=1 output alongside a minimal reproduction and file it against Next.js with the repro steps. Conversely, if the page misbehaves under both engines, the bug is in your code or config and Turbopack is an innocent bystander — stop blaming the engine and debug the application.

Performance & Measurement

Full rebuild versus incremental HMR latency A localized edit in a tree over 5000 modules drops from roughly 800 milliseconds of full rebuild to under 35 milliseconds incremental, shipping an HMR payload under 50 kilobytes; a single-file rebuild over 200 milliseconds is a module-boundary smell worth tracing. incremental turns 800ms into under 35ms full ~800ms full rebuild delta <35ms HMR payload < 50KB > 200ms single-file rebuild = module-boundary smell
Figure: the gap between the two bars is the persistent graph at work; anything over 200ms means a boundary to trace.
Metric Cold start (no cache) Warm start (restored cache)
Initial ready time full graph build restored snapshot, markedly faster
Single-file HMR n/a < 50ms typical
HMR payload n/a < 50KB per leaf edit
Recompiled nodes entire reachable graph invalidated subgraph only

Measure ready time from the terminal banner, recompile latency from Compiled in <N>ms, and payload size from WebSocket frames in DevTools. Treat any single-file rebuild over 200ms as a module-boundary smell worth tracing.

The three numbers measure different things and should not be conflated. Ready time is a cold-versus-warm question and is dominated by how much of the graph the cache restored; it tells you whether persistence is working, not whether incremental compilation is fast. Recompile latency is the incremental number that matters day to day, and it is a function of fan-out, not project size — a 5,000-module tree with clean boundaries recompiles a leaf edit as fast as a 500-module one. Payload size is a client-side concern: a large delta on a small edit usually means the edit crossed a Fast Refresh boundary and dragged a route reload with it. Track ready time and median recompile latency as CI-visible metrics if the dev loop is a team priority, because both regress silently — a new barrel file or an accidentally non-deterministic loader shows up as a slow trend long before anyone files a complaint.

Compatibility Matrix

The config-key timeline across Next.js versions Next.js 13 and 14 used experimental.turbo with an alpha then stabilizing dev engine; 15.0 to 15.2 made the turbopack flag stable while still under experimental.turbo; 15.3 and later moved to the top-level turbopack key and deprecated the old namespace. experimental.turbo → stable flag → top-level key 13.x / 14.xexperimental.turboalpha → stabilizing 15.0–15.2flag stablestill experimental key 15.3+turbopack (top-level)old key warns
Figure: the key move at 15.3 is the migration that matters — target it and let the deprecation warning guide older projects forward.
Next.js Config key Node Notes
13.x experimental.turbo 16.14+ Alpha dev engine, frequent cache breakage
14.x experimental.turbo 18.17+ Dev engine stabilizing
15.0–15.2 experimental.turbo 18.18+ --turbopack flag stable
15.3+ turbopack (top-level) 18.18+ experimental.turbo deprecated, warns

The migration people miss is the namespace move at 15.3, not any behavioral change. If you carried a config forward from a 14.x or early-15 project, your Turbopack options are probably still nested under experimental.turbo, where 15.3 and later will read them but emit a deprecation warning on every start. Move the whole block to the top-level turbopack key; the shape of the object is unchanged, only its location moved. Do this in the same commit that pins the Next.js version, so the config and the engine it targets travel together. On the older lines, expect the cache to break across upgrades — 13.x in particular treated the on-disk format as unstable — which is another reason to reach a pinned 15.3-or-later baseline before you start depending on warm starts in CI.

When not to reach for Turbopack

Turbopack earns its keep on large App Router trees where full rebuilds have become the bottleneck. On a small project — a few hundred modules — a conventional bundler’s rebuild is already fast enough that the persistent graph buys little, and you take on the engine’s narrower loader ecosystem for no real gain. It is also the wrong tool for production bundling today: standalone Turbopack is not yet stable for production builds, so your release pipeline still runs the production bundler regardless of what powers next dev. If your bottleneck is production build time rather than dev-loop latency, Turbopack does not address it. And if your project leans heavily on webpack loaders or plugins that have no Turbopack-compatible equivalent, the compatibility gap can cost more than the speedup returns — measure the dev loop under both engines with the same edit before committing, rather than adopting on reputation.

In-Depth Guides