Speed Up `next build` 10× with Next.js Turbopack Build Cache — Pitfalls of Experimental Flags and CI Integration Strategies
When next build in CI starts exceeding three minutes, the stress quietly accumulates. The time you spend waiting in front of the deployment pipeline every time you open a PR — enough to finish an entire cup of coffee. I ran into the same problem on an e-commerce project where webpack builds were crossing 180 seconds, and I spent a good while thinking "how can I cut this down?" The waiting time was frustrating every time I checked the build locally before a staging deployment, and the same was true in CI. Turbopack's filesystem cache is a feature designed exactly for that problem.
By the end of this article, you'll be able to apply the configuration that brings a 3-minute CI build under 30 seconds right away. The core idea is simple — recompile only the files that changed, and pull everything else straight from disk. However, the build cache is still in an experimental stage, so there are pitfalls. The important caveats are covered alongside the speed gains.
Core Concepts
What Is Incremental Building?
Traditional webpack builds recalculate the entire dependency graph (the structure that tracks import relationships between all files) from scratch every time. Changing a single file means re-bundling the whole app. Turbopack's FileSystem Cache flips that model.
Build artifacts (the dependency graph, intermediate ASTs (the structure that parses source code into a tree), transformed modules, etc.) are persisted in the .next directory, and the next build only recomputes the parts affected by changed files. This is incremental build caching.
It is currently split across two flags:
| Flag | Target | Status |
|---|---|---|
turbopackFileSystemCacheForDev |
next dev |
Stable and enabled by default as of Next.js 16.1 release notes |
turbopackFileSystemCacheForBuild |
next build |
Currently an opt-in experimental flag |
Enabling it is straightforward:
// next.config.ts
const nextConfig = {
experimental: {
turbopackFileSystemCacheForBuild: true,
},
};
export default nextConfig;TurboEngine — How Cell-Level Caching Works
Digging a little deeper reveals an interesting structure. Inside Turbopack runs TurboEngine, a Rust-based incremental memoization framework. When I first encountered this structure, I thought "isn't it just file hash comparison?" — but in reality it's far more sophisticated.
TurboEngine manages function call results as a "value cell" graph. When a source file changes, only the cells affected by it are marked dirty, and if a cell's content is identical to before, propagation up the graph is short-circuited early.
For example, suppose you modify a single file utils/format.ts — TurboEngine traces only the chain of modules that reference this file and recomputes it. If the output of that chain is identical to before, modules higher up in the graph skip the rebuild. Because tracking happens at the function result cell level rather than the file level, cache invalidation is far more granular.
Being Rust-based also has a direct impact on speed. Because Rust manages memory without GC (garbage collection), the overhead when processing large dependency graphs is significantly reduced compared to JavaScript-based tools.
Differences from webpack 5 Persistent Cache
If you're coming from webpack, the cache: { type: 'filesystem' } option won't be unfamiliar. The concept is similar, but there are differences:
| Comparison | webpack 5 Persistent Cache | Turbopack FileSystem Cache |
|---|---|---|
| Tracking unit | File and chunk level (output unit bundling multiple modules) | Function result cell level |
| Implementation language | JavaScript | Rust |
| Invalidation accuracy | Conservative (may trigger excessive rebuilds) | Fine-grained dependency tracking |
| Cache invalidation control | Developer manually configures buildDependencies, etc. |
Automatic tracking, no manual configuration needed |
Practical Application
Example 1: Optimizing Local Iterative Builds
This is the scenario where you'll feel the biggest difference. If you frequently verify builds locally before staging deployments, just add a single flag to next.config.ts.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
turbopackFileSystemCacheForBuild: true,
},
}
export default nextConfigAfter enabling it, run the build twice and you'll feel the difference immediately:
# First build (cold) — no cache
$ pnpm build
# ✓ Compiled successfully in 15.2s
# Second build (warm) after modifying some files — using cache
$ pnpm build
# ✓ Compiled successfully in 1.1sBelow are official benchmarks measured by Vercel through over a year of dogfooding on their own apps. The improvement ratio tends to increase with larger apps:
| App | Cold Build | Cached Build | Improvement |
|---|---|---|---|
| react.dev | 3.7s | 380ms | ~10× |
| nextjs.org | 3.5s | 700ms | ~5× |
| Large internal app | 15s | 1.1s | ~14× |
Numbers will vary depending on project size. For medium-sized apps, ~5× is a realistic expectation.
Example 2: GitHub Actions CI/CD Integration
Cache that only lives locally cuts the benefit in half. Caching the .next directory in CI as well can speed up the entire pipeline.
# .github/workflows/build.yml
name: Build
on:
push:
branches: [main, develop]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9 # adjust to match your project version
- uses: actions/setup-node@v4
with:
node-version: 22 # adjust to match your project version
cache: 'pnpm'
- name: Cache .next directory
uses: actions/cache@v4
with:
path: .next
key: ${{ runner.os }}-next-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-next-${{ hashFiles('**/pnpm-lock.yaml') }}-
${{ runner.os }}-next-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm buildThere is one key point in the cache key design. When pnpm-lock.yaml changes (dependency update), the cache is discarded; when TypeScript files inside src/ change, a partial hit is attempted via restore-keys. A partial hit is far better than a complete cache miss. Setting the scope too broadly like **/*.ts can include type files inside node_modules in the hash, causing unnecessary cache key churn — narrowing it to src/**/*.ts is more practical.
Managing the flag in next.config.ts is the standard approach. Controlling it via CI environment variables is not separately documented in the official docs, so operating from the configuration file is recommended.
Example 3: Resetting the Cache When Corruption Occurs
In practice, you may encounter situations where "the build succeeds but runtime behavior is strange," or "the build itself fails with an inexplicable error." This is when you should suspect cache corruption.
# Full cache reset — recommended to delete in this order
rm -rf .next
rm -rf node_modules/.cache
rm -rf .turbo
# Clean build
pnpm buildIt may feel wasteful to discard the cache, but this is the most reliable approach in this situation. The subsequent build will be a cold build, but if the issue was caused by cache corruption, it will be resolved immediately.
Pros and Cons Analysis
Honestly, looking at the list of advantages alone makes you want to push it to production right away — but having actually gone through this myself, the downsides stung harder. Here's a candid breakdown of both.
Advantages
| Item | Details |
|---|---|
| Repeated build speed | Warm builds: from tens of seconds down to hundreds of milliseconds. A tangible improvement |
| Fine-grained cache invalidation | Cell-level tracking instead of file-level prevents excessive rebuilds |
| Automatic tracking | Developers don't need to manually manage cache keys or dependencies |
next dev restart improvement |
Resumes immediately from prior compilation state, no warm-up time |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Build cache is experimental | Cache invalidation accuracy is still being validated | Thorough testing recommended before production deployment |
| Cache corruption risk | Cases of build errors from cache corruption have been reported in the Vercel community | Rebuild after deleting .next, node_modules/.cache, and .turbo |
| CI overhead | Upload/download time grows as the .next cache gets larger. Benefits diminish if the cache hit rate (the ratio of builds that skip recomputation) falls below 80% |
Fine-tune cache key strategy |
| Phantom module reference bug | Cases where the cache still references deleted files occur intermittently (GitHub Discussion #89669) | Resolvable with a full cache reset |
| Environment variable invalidation | Verify that the cache is properly invalidated when build environment variables change | Recommend a clean build for the first deployment after changing environment variables |
The Most Common Mistakes in Practice
- Comparing a webpack cold build to a Turbopack warm build. You need to compare under identical conditions (both cold or both warm) to get an accurate baseline difference. Comparing the first build without cache between the two will show you the real difference.
- Setting CI cache keys too broadly. It's recommended to include the lock file hash in the key so the cache is discarded when dependencies change. Otherwise, corrupted cache can keep getting restored.
- Mistaking cache corruption symptoms for code bugs. When a build succeeds but runtime behavior is strange, or changes you clearly made don't seem to be reflected, try clearing the cache first.
Closing Thoughts
Turbopack FileSystem Cache is a practical technology that improves repeated build speeds by several to tens of times, but since the build cache is still in an experimental stage, the smart approach is to adopt it while sufficiently validating cache invalidation accuracy.
That 3-minute build wait you faced every time you opened a PR — it can be different now. Here are 3 steps you can start with right away:
- Add the flag and measure the difference — Add
experimental: { turbopackFileSystemCacheForBuild: true }tonext.config.ts, runpnpm buildtwice, and measure the cold/warm build time difference yourself. - Connect CI caching — Cache the
.nextdirectory withactions/cache@v4, and include thepnpm-lock.yamlhash in the cache key so it automatically refreshes when dependencies change. - Verify with a clean build before deployment — Making it a habit to compare
rm -rf .next && pnpm buildoutput against a cached build to confirm the outputs match can proactively prevent unexpected issues from cache corruption.
References
- next.config.js: turbopackFileSystemCache | Next.js Official Docs (cited in article)
- Next.js 16 Official Release Notes
- Next.js 16.1 Official Release Notes (cited in article)
- Inside Turbopack: Building Faster by Building Less (cited in article)
- Turbopack File System Caching Feedback · vercel/next.js Discussion #87283
- Turbopack Phantom Module References Causing Panic · Discussion #89669 (cited in article)
- Turbopack Persistent Caching — Tobias Koppers (JSNation)
- Next.js 16.2 Turbopack: The Anatomy of a 400% Improvement — DEV Community
- Turbopack corrupted build cache — Vercel Community (cited in article)
- next.config.js: turbopackPersistentCaching | Next.js 15 Docs