How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
Most bundler comparisons stop at benchmark numbers. Turbopack loads modules in Xms, webpack takes Yms, done. That framing misses the more interesting question: why is Turbopack fast, and under what conditions does that architecture hold up in production?
The answer is not “it’s written in Rust.” esbuild is written in Go and is still faster than Turbopack for cold builds. The actual answer is the Turbo engine: a runtime for incremental, function-level memoization that makes recomputation proportional to what changed rather than proportional to the size of the graph. Understanding that model explains both the wins and the current constraints.
The Turbo Engine: Incremental Computation as a Runtime
Turbopack is built on top of the Turbo engine, a general-purpose incremental computation framework written in Rust. The engine is not bundler-specific; it is a system for defining computations that cache themselves and track their own dependencies automatically.
The core primitive is the Vc<T> type (short for “value cell”). A Vc<T> represents a lazily computed, cached value of type T. When a function annotated with #[turbo_tasks::function] is called, the runtime does not immediately execute it. Instead, it checks whether the inputs to that function have changed since the last call. If nothing changed, the cached output is returned immediately. If something changed, the function re-executes and the new output is stored.
This is not coarse-grained caching at the file level. Every function in the bundler pipeline is individually cached. Module resolution is a cached function. Transformation via SWC is a cached function. Asset emission is a cached function. The invalidation graph mirrors the call graph: when a source file changes, only the functions that transitively depend on that file are re-executed.
// This is a conceptual TypeScript approximation of how
// Turbo engine task dependencies work. The actual implementation
// is in Rust with the #[turbo_tasks::function] macro.
type TaskId = string;
type Version = number;
interface TaskCache<T> {
output: T;
inputVersions: Map<TaskId, Version>;
}
class TurboEngineSimulation<T> {
private cache = new Map<TaskId, TaskCache<T>>();
private versions = new Map<TaskId, Version>();
invalidate(taskId: TaskId): void {
// Bump version, propagating to dependents
this.versions.set(taskId, (this.versions.get(taskId) ?? 0) + 1);
}
compute(
taskId: TaskId,
deps: TaskId[],
fn: () => T
): T {
const cached = this.cache.get(taskId);
const currentVersions = new Map(
deps.map((d) => [d, this.versions.get(d) ?? 0])
);
if (cached) {
const unchanged = deps.every(
(d) => cached.inputVersions.get(d) === currentVersions.get(d)
);
if (unchanged) return cached.output;
}
const output = fn();
this.cache.set(taskId, { output, inputVersions: currentVersions });
return output;
}
}
The real implementation tracks dependencies at runtime through read interception rather than explicit declaration. When a function reads a Vc<T>, the engine records that dependency automatically. You write functions that look like ordinary reads; the engine builds the dependency graph implicitly.
Module Resolution Pipeline
Turbopack’s module resolution runs as a Turbo task graph. When a module imports ./utils, the resolver:
- Resolves the import to an absolute path using the TypeScript or Node.js module resolution algorithm, respecting
tsconfig.jsonpaths,package.jsonexports fields, and any custom aliases configured in Next.js. - Reads the file contents, represented as a
Vc<FileContent>. - Passes those contents through the transformation pipeline.
- Returns an
AssetContenttask representing the final module output.
Each of these steps is a separately cached task. If utils.ts changes, only steps 2, 3, and 4 for that module re-execute. If app/page.tsx imports from utils.ts, the page module re-executes only to the extent that it depends on the changed export. Unchanged re-exports flow through without re-executing the parent module.
The resolution algorithm itself is cached per (specifier, context directory) pair. If 50 modules import react, the resolution of react executes once and is cached for the remaining 49 lookups.
SWC Integration: Transformation Without Spawning Processes
Turbopack uses SWC (Speedy Web Compiler) for TypeScript and JSX transformation. SWC is also written in Rust, and Turbopack embeds it directly rather than calling it as a subprocess. This eliminates the serialization cost that tools like Babel-based pipelines pay when passing ASTs between processes.
The transformation task receives a Vc<FileContent> and a Vc<TransformOptions> and produces a Vc<TransformedModule>. Both inputs are versioned by the Turbo engine. If a file changes but the transform options do not (which is the common case during development), only the file-specific part of the task re-executes.
// next.config.ts — SWC transform options are stable across file changes
// Turbopack only re-transforms files whose content actually changed
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
turbopack: {
// Custom SWC transforms can be added here
// Each transform option is versioned separately
},
},
};
export default nextConfig;
One practical consequence: Babel plugins that rely on custom transform logic are not directly compatible with Turbopack. If your project uses Babel plugins (emotion, styled-components with full Babel support, custom macros), you need either SWC equivalents or you stay on webpack for now. This is the primary compatibility blocker most teams hit during migration.
HMR: Granular Invalidation, Not Full Rebundling
Hot Module Replacement in webpack works by rebuilding affected modules and pushing a new module graph to the browser runtime. For large graphs, even “affected modules only” can mean dozens of modules because webpack’s graph is coarse: a change bubbles up through the import chain until it hits an acceptance boundary.
Turbopack’s HMR is faster for a different reason than most explanations suggest. The invalidation boundary is the individual Turbo task, not the module. When Button.tsx changes:
- The
transform(Button.tsx)task is invalidated and re-executes. - Tasks that read the output of
Button.tsxare checked for actual output changes. - If
Button.tsxexports did not change in shape (only internal implementation changed), dependent modules are not re-executed. - The HMR update payload contains only the changed module output, not the full chunk.
The browser runtime receives a minimal patch. CSS modules get even better treatment: a CSS change can be applied without any JavaScript re-execution at all, because style application is handled as a separate task from module evaluation.
The practical result: HMR latency in Turbopack scales with the size of what changed, not with the size of the application. A 500-module app and a 50-module app have nearly identical HMR latency for a leaf module change.
Persistent Caching Across Builds
The Turbo engine’s task cache is serializable. Turbopack can write the full task graph and all cached outputs to disk between builds. On the next next dev invocation, the cache is rehydrated and only tasks whose inputs changed since the last session re-execute.
The cache format uses content-addressed storage: each cached value is keyed by a hash of its inputs, not by file path or timestamp. This makes the cache correct by construction. A cache hit means the inputs are bit-for-bit identical; there is no TTL, no invalidation heuristic.
// Verifying your Turbopack cache is being used
// Check for cache hit indicators in the build output
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
turbopack: {},
},
};
// On cold start: .next/cache/turbopack/ will be populated
// On warm start: you'll see significantly fewer transform tasks logged
The cache lives in .next/cache/turbopack/. In CI environments, restoring this directory between runs (using GitHub Actions cache or equivalent) gives you warm-start performance on every run, not just local development. The cache is stable across machines if the Node.js version, Turbopack version, and file contents are identical.
One sharp edge: the cache is not automatically pruned. Long-running projects accumulate cache entries from deleted modules. Manual cleanup or a periodic rm -rf .next/cache/turbopack is necessary to prevent unbounded disk growth.
Tree Shaking and Code Splitting
Turbopack’s approach to tree shaking is module-level dead code elimination during the transformation phase, with chunk-level splitting determined by a separate task that analyzes the import graph.
Tree shaking works at the ESM static analysis level: unused named exports are omitted from the output if they are not referenced by any live import. Dynamic imports (import(...)) create split points that Turbopack tracks as separate chunk roots, each with their own cached task subgraph.
// Static exports -- Turbopack can eliminate unused exports at build time
// Only `formatDate` is used at the import site; `formatCurrency` is not included in the chunk
// lib/format.ts
export function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
// app/page.tsx
import { formatDate } from "@/lib/format";
// formatCurrency is excluded from the page chunk
Dynamic import boundaries are first-class in the task graph. Each import() call creates a new async chunk whose computation is isolated from the parent. Changes to a lazy-loaded route do not invalidate the parent route’s cached output.
Code splitting strategy in the Next.js App Router is largely automatic: route segments, layouts, and loading states become natural chunk boundaries. Turbopack respects these boundaries and caches each chunk independently.
Architecture Decisions Versus webpack, Vite, and esbuild
webpack’s architecture is a single-pass compilation pipeline with a plugin system built on tapable hooks. The plugin system is expressive but synchronous in design: each hook fires in order, and plugins modify shared mutable state. This makes webpack universally extensible but hard to parallelize. Every build walks the full graph; incremental builds exist but are opt-in and imperfect.
Vite solves the development speed problem differently: it does not bundle at all in dev mode. It serves ESM modules directly from the browser, with esbuild handling dependency pre-bundling. This is fast for dev but creates a divergence between dev and production (where Vite uses Rollup). Large apps with thousands of modules see slow network waterfalls in dev because browsers issue hundreds of HTTP requests.
esbuild is a Go-based bundler optimized for cold build throughput. It parallelizes module processing aggressively and avoids any form of incremental caching. It is extremely fast for initial builds but does not benefit from prior work on recompilation. There is intentionally minimal plugin support.
Rspack is a webpack-compatible Rust implementation. It keeps webpack’s plugin API (partially) and mental model while replacing the compilation engine with a parallel Rust implementation. If you have a webpack config you cannot migrate away from, Rspack is the path of least resistance. The tradeoff is carrying forward webpack’s architectural constraints into a faster runtime.
Turbopack’s bet is that function-level incremental computation is the right abstraction for long-lived development sessions. The cold build is slower than esbuild because of the overhead of building and serializing the task graph. The warm build (after cache hydration) approaches esbuild speed for unchanged files and is faster than webpack on incremental changes.
Tradeoffs Comparison
| Dimension | Turbopack | webpack 5 | Vite 5 | esbuild | Rspack |
|---|---|---|---|---|---|
| Compilation model | Incremental task graph with function-level caching | Full graph walk, optional incremental | Dev: unbundled ESM; Prod: Rollup | Parallel cold compilation, no incrementalism | Parallel Rust engine, webpack-compatible graph |
| HMR speed | Proportional to changed tasks only | Proportional to affected module subgraph | Near-instant (native ESM, no bundling) | Not applicable (no dev server) | Faster than webpack, similar model |
| Cold build speed | Slower (task graph overhead) | Slow (JS single-threaded) | Fast (esbuild pre-bundle) | Fastest | Faster than webpack |
| Warm build speed | Near-instant (persistent cache) | Moderate (filesystem cache) | Fast | No warm cache concept | Moderate |
| Plugin ecosystem | Limited (SWC plugins + Next.js only) | Mature (thousands of plugins) | Rich (Rollup-compatible) | Minimal by design | Growing (webpack plugin compat) |
| Production readiness | Stable for Next.js 15+, experimental otherwise | Battle-tested | Battle-tested | Used as sub-tool | Maturing |
| Language | Rust | JavaScript | JavaScript + Rust (esbuild) | Go | Rust |
| Babel plugin support | No (SWC plugins only) | Yes | Yes (via vite-plugin-babel) | No | Partial |
| Sweet spot | Next.js 15+ projects, large apps with hot dev sessions | Legacy projects, complex plugin chains | Non-Next.js SPA and SSR, Vite-native frameworks | Build tooling, CI pipelines, sub-tools | webpack migrations needing speed without full rewrite |
Production Considerations
Migration from webpack. Turbopack does not parse webpack configs. Custom loaders, aliases, and plugins written for webpack must be translated. The most common blockers are: custom Babel transforms (convert to SWC transforms or drop them), webpack resolve aliases (rewrite as turbopack.resolveAlias in next.config.ts), and custom asset loaders (use Turbopack’s built-in asset handling or file-system conventions). Start with next dev --turbopack before switching next build.
As of Next.js 15, Turbopack is stable for next dev and production next build. The next build --turbopack flag enables Turbopack for production builds, with persistent cache carrying over from dev sessions.
Cache invalidation in monorepos. In a Turborepo-managed monorepo, Turbopack’s task-level cache is distinct from Turborepo’s task cache. They do not conflict, but you need to include .next/cache/turbopack in your CI cache configuration separately from Turborepo’s .turbo cache.
# GitHub Actions: cache both Turborepo and Turbopack caches
- uses: actions/cache@v4
with:
path: |
.turbo
**/.next/cache/turbopack
key: ${{ runner.os }}-turbo-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-turbo-
Debugging build issues. Turbopack does not have the same error message surface as webpack yet. Module resolution failures sometimes produce less context than webpack’s detailed trace. When a module fails to resolve, check: (1) the turbopack.resolveAlias config, (2) whether the package has an exports field that excludes the import condition Turbopack uses, and (3) whether a Babel plugin is being silently skipped.
The TURBOPACK_LOG=debug environment variable emits task-level tracing. The output is verbose but shows exactly which tasks are executing and which are cache hits, which is useful for identifying why a particular file is not being treated as cached.
TURBOPACK_LOG=debug next dev --turbopack 2>&1 | grep "cache miss"
CSS handling. Turbopack handles CSS Modules natively. Global CSS imports work as in webpack. PostCSS is supported through the standard postcss.config.js. The limitation is CSS-in-JS libraries that require runtime extraction (like older versions of styled-components in SSR mode). Libraries with SWC transforms (the modern styled-components SWC plugin) work correctly.
Memory characteristics. The in-memory task graph grows with application size. For very large apps (500+ routes), watch RSS during next dev. The persistent cache reduces memory by allowing eviction of old task outputs to disk, but the active graph for the current session is always in memory. If you see memory climbing over a long dev session, a restart rehydrates from disk cache cleanly.
Closing Insight
The Turbo engine’s design makes a specific bet: that the bottleneck in large-app development is not CPU throughput on cold builds but latency on the hot path during active development. For that workload, function-level incremental caching with automatic dependency tracking is structurally correct. The cost is a more constrained extensibility model and slower cold starts versus tools optimized purely for throughput.
If you are running Next.js 15 and your team spends hours per day in active dev, enabling Turbopack is straightforward and the HMR improvement compounds over time. If you have a webpack plugin chain that represents years of accumulated configuration, or if your primary concern is CI build time rather than dev session latency, the calculus is different.
The architecture is sound. The migration story is still maturing.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.
How Next.js Works Internally: The Compilation Pipeline, RSC Protocol, and Caching Architecture From Request to Render
A deep dive into the internal architecture of Next.js App Router. Covers the SWC/Turbopack compilation pipeline, the RSC wire protocol, the four-layer caching architecture, rendering strategies, middleware execution, Server Actions, and the self-hosted vs managed deployment tradeoffs.