Configuring Turbopack Cache for Next.js Projects

Turbopack’s caching layer is a deterministic state machine that persists incremental compilation results to disk, and how you manage it directly dictates dev-server latency, memory footprint, and hot-reload fidelity. This guide covers the cache directory layout, the Next.js integration points you can actually control, lifecycle recovery, and CI restoration; for the graph engine that produces these cache entries in the first place, read Turbopack Incremental Compilation first.

Turbopack in Next.js is activated with next dev --turbopack (the --turbopack flag is stable as of Next.js 15). All cache artifacts are stored under .next/cache/turbopack by default.

The problem this cache solves is the cost of the transform graph. Every module a Next.js app touches — TypeScript sources, CSS modules, SVG imports run through a loader, the barrel files that re-export dozens of components — has to be parsed, resolved, transformed to JavaScript, and stitched into a dependency graph before the dev server can answer the first request. On a cold start with no cache, Turbopack pays that cost for the entire reachable graph. On a warm start, it should pay it only for the modules whose inputs actually changed. The persistent cache is what makes the second start fast: it is the on-disk memoization table for the transform graph, keyed so that an unchanged input never recompiles.

When the cache is misconfigured the failure is rarely loud. You do not get an error; you get a dev server that feels cold every morning, a CI job whose build step never drops below its worst-case time, or — more dangerously — a hot reload that silently serves a stale module because an artifact was reused when its input had in fact changed. Each of those is a different fault in the same machine: cold-every-time is a persistence problem, never-fast is a keying problem, and stale-serve is an invalidation problem. The sections below separate them so you can tell which one you have before you reach for the destructive fix of deleting the cache.

Where this sits in the pipeline matters. The cache is downstream of resolution and loader configuration and upstream of the module graph the dev server walks. That ordering is why the turbopack block in next.config.js — loaders, aliases, resolved extensions — is not a cosmetic setting: those values are hashed into the cache key, so changing a loader or an alias legitimately invalidates every entry that flowed through it. Treat the config as part of the key, not as a layer bolted on top.

Turbopack content-addressed cache lookup A source module is hashed with compiler flags and env digest; a matching key replays the cached output, a miss recompiles and writes a new entry. Content-addressed cache: key = hash(source + flags + env) Source module + flags + env Content hash key lookup Hit: replay cached output no transformation Miss: recompile write new entry .next/cache/turbopack persisted entries restored on warm start and across CI runs via actions/cache
Figure: each module is keyed by a content hash over source, compiler flags, and environment; a hit replays cached output while a miss recompiles and persists a new entry.

How it works under the hood

Turbopack’s cache is content-addressed. Each unit of work — resolving a specifier, transforming a source file, producing a chunk — is a node in a graph, and the identity of a node is a hash over its inputs, not its file path. The inputs that go into that hash are the source bytes, the compiler flags and options in effect (target, JSX runtime, the loader chain that applies to the file), and an environment digest covering the parts of the toolchain that change output: the Next.js and Turbopack versions, and any environment variables the transform reads. Two starts with identical inputs produce identical keys, so the second start finds every node already present and replays the stored output instead of recomputing it. That is the whole performance story: a warm start is a graph walk that mostly resolves to hits.

Because the key is a hash of inputs rather than a timestamp, invalidation is precise and local. Edit one component and only the nodes whose inputs changed — that module and the small set of nodes that depend on its output — get new keys; everything else keeps its old key and stays a hit. This is why a correct cache, after a one-line edit, adds a handful of entries rather than rewriting the directory. It is also why non-determinism is so corrosive: if any input to the hash varies between otherwise-identical runs (a Date.now() baked into a loader, an environment variable that flips), the key changes, the lookup misses, and you recompile work that never actually changed.

Invalidation propagates along the dependency edges. When a module’s output hash changes, every node that consumed that output sees a changed input and is itself recomputed, and so on up to the chunks the dev server serves. The propagation stops as soon as a downstream output hash comes out identical — if a change to a comment does not alter the emitted JavaScript, dependents that only care about the emitted JavaScript remain hits. This is the mechanism behind Turbopack feeling instant on trivial edits and doing real work on a signature change: the cost is proportional to how far the hash change propagates, not to how many files you have.

The cache is a memoization table, which has one important consequence: it is safe to delete. Deleting .next/cache/turbopack never produces wrong output; it only forces the next start to recompute from scratch. That property is what makes “clear the cache” a legitimate last-resort fix — you trade warm-start speed for correctness, and you can always earn the speed back. Reach for it only when you have evidence the cache itself is the problem, because the recomputation is exactly the cost the cache exists to avoid.

Prerequisites & Reproducible Setup

This walkthrough uses Next.js 15.x with Node 20. Reproduce a clean baseline:

From scaffold to a populated cache directory Scaffold a Next.js app with create-next-app, set the dev script to next dev turbopack, then the first npm run dev populates the cache under .next/cache/turbopack. create-next-app--ts --app set dev scriptnext dev --turbopack npm run devpopulates cache the first start is the cold build that seeds .next/cache/turbopack
Figure: the cache exists only after the first dev run — every measurement below compares against that seeded state.
# Next.js 15.x / Node 20+
npx create-next-app@latest tp-cache-demo --ts --app --no-tailwind
cd tp-cache-demo
npm pkg set scripts.dev="next dev --turbopack"
npm run dev    # populates .next/cache/turbopack on first start

The first dev run is the cold build, and it is the only run that has to construct the full graph. Time it, then stop the server and start it again: the second start is your warm baseline. Every measurement in this guide is a comparison between those two numbers, so record both. If you skip the cold run and start comparing warm starts to each other, you lose the reference point that tells you whether the cache is doing anything at all. Note also that next build and next dev maintain separate cache state under .next/cache; the warm-start behavior discussed here is the dev-server cache, and a production build does not warm it.

Diagnosis Workflow

Work from cheapest signal to most destructive fix:

Escalate from cheapest signal to most destructive fix First confirm the cache exists and is writable, then compare warm versus cold ready time, then capture a tracing log when starts are slow, and only as a last resort clear the cache directory when hot reload stops reflecting edits. cheapest signal first — delete last 1 ls cacheexists + writable 2 warm vs coldready time 3 trace logTRACING=1 4 clear cachelast resort
Figure: deleting the cache (step 4) throws away every warm-start gain — reach for it only after the trace rules everything else out.
  1. Confirm the cache exists and is writable. Run ls -lh .next/cache/turbopack. An empty or missing directory after a dev run means the cache is never being written — usually a permissions problem or a build that runs in a fresh working directory each time. A directory that exists but whose contents reset between runs points at ephemeral storage rather than a keying fault. This check costs nothing and rules out the entire class of “the cache isn’t there” before you start reasoning about hit rates.

  2. Compare warm and cold ready time. Stop the server, start it again, and read the ready time Next.js prints on boot. A warm start that lands within noise of the cold start is the signature of a cache that is present but not being reused: the keys are missing on every lookup. If the warm start is meaningfully faster, the cache is working and any remaining slowness is real recompilation, not a cache defect. This is the single most diagnostic measurement, because it separates a persistence problem from a keying problem without touching the trace.

  3. Capture a trace when starts are slow. Run NEXT_TURBOPACK_TRACING=1 next dev --turbopack 2>&1 | tee turbopack-trace.log. The trace records which nodes were recomputed and why, so a warm start that still recompiles a large subgraph will show exactly which modules missed. Diffing the trace of two identical-source starts is how you find a drifting input: any node that recompiles when nothing changed is being fed a non-deterministic key. Keep the log — it is the evidence you need before deciding the cache itself is at fault.

  4. Clear the cache only as a last resort. If hot reload stops reflecting edits, or you see Error: Failed to read cache entry, the on-disk state is corrupt and you delete .next/cache/turbopack to force a clean rebuild. This always fixes a corruption symptom because the cache is pure memoization — but it also throws away every warm-start gain, so it is the most expensive action in the list and the one you reach for only after the first three have ruled out the cheaper causes. Confirm the fix by restarting: the next start is cold by design, and the one after that should be warm again.

The cache directory path is not exposed as a user-configurable option in the stable API — Next.js manages it under .next/cache. To relocate the whole Next.js cache, set NEXT_CACHE_DIR, or point your CI cache at .next/cache. Do not try to point Turbopack at a custom subpath; the layout under .next/cache/turbopack is an implementation detail Next.js owns, and coupling tooling to it will break on a minor upgrade. Treat the directory as opaque: persist it, restore it, or delete it wholesale, but do not reach inside it.

Solution Configuration

The turbopack block controls loaders, aliases, and resolution order (the inputs that participate in cache keys); the cache lifecycle itself is driven by where you persist .next/cache. This next.config.js is complete and runnable on Next.js 15.3+.

Three levers over cache correctness and persistence The turbopack config controls the loaders and aliases that feed cache keys and must stay deterministic; actions/cache restores .next/cache across CI runners; and a pre-commit hook clears the cache when a dependency manifest changes so stale graphs never linger. keys must be deterministic · persistence spans runners · invalidate on dep change turbopack configloaders + aliaseskeep deterministic actions/cachepath: .next/cachesurvives cold runners pre-commit hookmanifest changed?rm cache dir
Figure: config keeps keys stable, actions/cache persists across runners, and the hook stops a stale graph surviving a dependency bump.
// next.config.js — Next.js 15.3.x / Node 20+
/** @type {import('next').NextConfig} */
const nextConfig = {
  turbopack: {
    // Loaders feed into the cache key — keep them deterministic.
    rules: {
      '*.svg': { loaders: ['@svgr/webpack'], as: '*.js' },
    },
    resolveAlias: {
      '@/lib': './src/lib',
    },
    resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
  },
};

module.exports = nextConfig;

Every field in the turbopack block feeds the cache key, which is both the point and the trap. rules maps a file pattern to a loader chain; because the loader identity is hashed, swapping @svgr/webpack for a different SVG loader — or even bumping its version in a way that changes output — correctly invalidates every SVG-derived entry. resolveAlias and resolveExtensions change which file a bare specifier resolves to, so they participate in the resolution nodes upstream of every transform. Keep these values stable and deterministic: an alias that resolves differently depending on an environment variable, or a loader that emits a timestamp, will churn the cache on every start. The rule of thumb is that anything you put in this block should be a pure function of the repository’s committed state, never of the machine or the clock.

Restore the cache across CI runs so fresh runners do not pay a full cold build:

# .github/workflows/build.yml — actions/cache v4
- name: Cache Next.js Turbopack artifacts
  uses: actions/cache@v4
  with:
    path: .next/cache
    key: ${{ runner.os }}-nextjs-turbopack-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-turbopack-

The keying here is deliberate. The primary key includes a hash of the lock files, so a dependency change produces a new key and a fresh cache rather than reusing artifacts built against the old dependency graph — which would be a correctness risk, not just a performance one. The restore-keys prefix is the fallback: when the exact key misses (because dependencies did change), the action restores the most recent cache whose key shares the prefix, giving the new run a warm starting point it can incrementally update instead of a cold directory. Without restore-keys, every dependency bump would pay a full cold build; with it, the bump pays only for the modules the dependency change actually touched. Scope the hashFiles glob to the lock files that genuinely affect this app — a ** glob in a monorepo pulls in unrelated packages and invalidates the key far more often than the code warrants.

Invalidate the local cache automatically when dependencies change so stale graphs never linger:

# .husky/pre-commit — clear cache when a manifest changes
#!/bin/sh
if git diff --cached --name-only | grep -qE 'package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml'; then
  echo "Dependency manifest changed. Clearing Turbopack cache..."
  rm -rf .next/cache/turbopack
fi

In principle Turbopack already folds the toolchain version and resolved dependencies into its keys, so a dependency change should invalidate the affected entries on its own. The hook exists for the residual cases the key does not cover cleanly — a transitive dependency whose on-disk contents changed without a corresponding key input, a lock file resolved differently on another developer’s machine, or a partially-written cache from an interrupted install. Clearing the Turbopack subdirectory on a manifest change is cheap insurance against those states, and because deleting the cache is always safe, the hook can never make output wrong; the worst case is one extra cold start. If you would rather not couple this to Git, the same check works as a standalone script your postinstall runs:

# scripts/reset-turbo-cache.sh — run from postinstall after a dependency change
#!/bin/sh
# Node 20+ / Next.js 15.3+
CACHE_DIR=".next/cache/turbopack"
if [ -d "$CACHE_DIR" ]; then
  echo "Resetting Turbopack cache after install: $CACHE_DIR"
  rm -rf "$CACHE_DIR"
fi

Verification

Three signals that the cache is genuinely reused A restored CI run prints Cache restored from key with a ready time well below the cold baseline, a local one-component edit adds only new cache entries while the bulk of the directory is untouched, and a second identical-source trace shows near-zero recompiled nodes. reuse looks like this across CI, disk, and trace CI logCache restored from keyready ≪ cold disk after editonly new entriesbulk untouched second tracenear-zero recompilednodes
Figure: three independent confirmations — a log line, a disk diff, and a trace — that the cache is actually replaying, not silently rebuilding.

After wiring CI caching, confirm reuse with the action log: a restored run prints Cache restored from key: <os>-nextjs-turbopack-<hash> and the build’s ready time should fall well below the cold baseline. The log line alone is not proof the cache helped — it only tells you the directory was restored, not that Turbopack found hits in it — so pair it with the ready time. A restore followed by a ready time equal to the cold baseline means the restored entries missed on lookup, which sends you straight back to the keying diagnosis: the key that produced the cache differs from the key the run computes.

Locally, prove keying works by editing one component and watching ls .next/cache/turbopack — only new entries appear; the bulk of the directory is untouched. This is the disk-level confirmation that invalidation is local rather than global. If a one-line edit rewrites a large fraction of the directory, either the edit touched something more central than you thought (a widely-imported barrel file, a type used everywhere) or an input is drifting and defeating the hash. A trace comparison (NEXT_TURBOPACK_TRACING=1) between two identical-source starts should show near-zero recompiled nodes on the second run; any node that recompiles across two byte-identical starts is a non-determinism bug, and the trace names it.

Performance considerations

The cache trades disk and memory for compute, and that trade is usually decisive in its favor — but it is worth understanding what it costs. On disk, the directory grows roughly with the size of the reachable graph, and it accumulates entries for source states you have visited, not just the current one. Over a long-lived branch with churn it can reach hundreds of megabytes; this is normal and not a leak, but it is why the CI key should be scoped and why an occasional wholesale clear is healthy rather than alarming. The cache does not aggressively garbage-collect old entries within a session, so if directory size becomes a problem in a constrained CI environment, a periodic clear on a scheduled job is more predictable than trying to prune individual entries.

The warm-start win is largest on big graphs and smallest on trivial apps, because the fixed overhead of walking the graph and checking hashes is paid regardless of hit rate. For a small app the cold build is already fast and the cache saves little in absolute terms; for a large App Router project with many route segments and a deep component tree, the warm start can be several times faster than cold. This is why the cache matters most exactly where builds hurt most, and why the effort of getting CI restoration right scales with the size of the codebase — a small project can ignore it, a large one cannot afford to.

When not to persist the cache

Persisting the cache across CI runs is not free of risk, and there are cases where a cold build is the right choice. A release or publish job that must be reproducible byte-for-byte is better run cold, so its output depends only on committed source and pinned dependencies, never on whatever partial state a previous runner happened to leave behind. Likewise, if you cannot scope the cache key tightly — a sprawling monorepo where any change touches the shared lock file — a restored cache that always misses adds restore-and-save time on top of a full rebuild, making the job slower than if you had never cached at all. Measure before you assume caching helps: the break-even point is when the time saved by hits exceeds the time spent restoring and saving the directory, and on small or highly-churning projects that inequality can run the wrong way.

Gotchas & Edge Cases

Four cache pitfalls in Next.js projects Perpetual misses come from hashing a frequently-changing lock file so scope the key to the app's own lock; EACCES means a Docker ownership mismatch fixed with chown; ephemeral container storage needs a persistent volume mount; and non-deterministic hashes come from a timestamp in config or a loader. perpetual misses→ scope key to app lock file EACCES in Docker→ chown -R node:node /app/.next ephemeral storage→ mount volume at .next/cache non-deterministic hash→ audit Date.now() in config/loader
Figure: two are environment problems (Docker ownership, ephemeral storage); two are keying problems (lock-file scope, non-determinism).

Perpetual cache misses despite a restored cache. The symptom is a CI log that prints Cache restored from key on every run yet never gets faster. The root cause is almost always a key that hashes a frequently-changing lock file — a monorepo root package-lock.json bumped by packages this app never imports — so the exact key misses on every run and the restore falls back to a prefix match against a cache built for a different dependency graph. Fix it by scoping the key to the app’s own lock file: hashFiles('apps/my-app/package-lock.json') with a restore-keys fallback. Confirm the fix by running the job twice with no source change; the second run’s exact key should hit and its ready time should drop.

EACCES: permission denied on the cache directory. The symptom is a hard error on start rather than a slow build. In Docker the .next directory is often created at image-build time by root, or by a bind mount owned by the host user, while next dev runs as an unprivileged user that cannot write it. Fix ownership in the Dockerfile: RUN chown -R node:node /app/.next then USER node, so the process that writes the cache owns the directory. Confirm by exec-ing into the container and checking the directory is writable by the runtime user before the app even starts.

Ephemeral container storage. The symptom is a cache that exists during a run but is empty on the next start, so every start is cold despite the config being correct. The root cause is that the default cache path sits on the container’s writable layer or a tmpfs, both of which are discarded when the container stops. Mount a persistent volume at /app/.next/cache so the directory survives the container lifecycle. Confirm by stopping and restarting the container and checking that .next/cache/turbopack still holds the entries from the previous run.

Non-deterministic output hashes on identical source. The symptom is a warm start that recompiles nodes even though no file changed. The root cause is an input to the hash that varies between runs — a Date.now(), a random seed, a wall-clock timestamp, or an environment variable read inside next.config.js or a custom loader — which changes the key and forces a miss. Audit the config and every custom loader for anything that is not a pure function of the source, then diff two traces captured under NEXT_TURBOPACK_TRACING=1 from byte-identical starts; the node that recompiles in both traces names the transform whose input is drifting.