Skip to content
chornous.dev

Type two or more letters. Esc closes.

Index of sheets
Theme

Sheet 03 · Writing

All notes

Inside Turbopack: speed from doing less

On January 20, 2026, the Turbopack team explained its incremental engine: value cells that track reads like signals, dirty marking that stops when output stays equal, demand-driven recompute and an aggregation graph over millions of results. Your import graph decides how much of that it can skip.

Pl. 47 · pipeline drawing generated from the slug “turbopack-incremental-computation-notes”

Anthony Shew, Benjamin Woodruff and Tobias Koppers published "Inside Turbopack: Building Faster by Building Less" on January 20, 2026, a month after Next.js 16.1 made Turbopack's disk cache the default for next dev. The post explains the engine under that cache. The goal is a build whose cost tracks the size of your change, not the size of your app.

The authors admit the tradeoff up front. Incremental systems add complexity, bugs, extra CPU and extra memory for tracking, and a poor cache can make a build slower than no cache at all. They took it on because Next.js runs some of the largest web apps around, and Fast Refresh has to feel instant on all of them.

Where the design comes from

The team cites more than a decade of prior work: their own experience adding caching to webpack, Salsa (the engine behind rust-analyzer and Ruff), Parcel, the Rust compiler's query system and Adapton.

The contrast case is GNU Make. Make caches at the file level, and you declare dependencies by hand. That's coarse and error-prone, and Make can't cache the compiler's internal data. A bundler makes it worse: tree shaking and chunking create many-to-many links between source files and output files, so no hand-written graph could keep up.

Value cells: a spreadsheet inside the bundler

Turbopack wraps almost all of its data in value cells, Vc<T>: a file on disk, a parsed AST, a module's import and export metadata, chunking information. Each time a tracked function reads a cell, Turbopack records the dependency. The team compares it to signals in SolidJS.

The key word is reads. A function might receive an object holding many cells, and it depends only on the cells it awaits. Classic memoization keys on the whole argument and reruns when any part of it changes. Turbopack reruns only when a cell it read changes.

A sketch of the idea in TypeScript, my illustration rather than Turbopack's Rust API:

// Each read registers a dependency on that cell alone.
async function exportsOf(module: ModuleCells) {
  const ast = await module.ast; // depends on ast
  return collectExports(ast); // module.css never read, never tracked
}

What happens when you hit save

On the first run, Turbopack builds a graph. Roots are the outputs you asked for, leaves are source files, and the middle holds ASTs, metadata, transformed modules and chunk plans.

  1. The file watcher sees a change and marks each function that read that file's cell as dirty.
  2. A dirty function recomputes and writes new intermediate results into its cells.
  3. If a new value equals the old one, Turbopack skips the update, and propagation stops there.
  4. Changed cells dirty their readers, and the process climbs toward the roots.

Step 3 does the heavy lifting. Change a function body without touching its exports, and the export metadata comes out equal, so functions that read only that metadata stay clean.

Recompute is also demand-driven. Turbopack leaves dirty functions alone until an active query needs them. In development that's an open page with hot reload, and in a build it's the full app. Edit a component used only on /settings while you look at /, and the settings work waits.

The aggregation graph

Some questions span large parts of the graph: which nodes are dirty under this route, what errors and warnings it has, whether it finished computing. With hundreds of thousands or millions of cached results, walking the graph for each question would cost too much.

Turbopack keeps a second, layered structure on top. Each aggregation node summarizes a region of the dependency graph and stores data you query often, such as errors. Higher layers cover more nodes at lower resolution. Each route or entry point is a root that, at the top layer, summarizes all of its descendants. Adding a root can force a reorganization, and the team calls that an infrequent operation.

cached intermediate results in a large graph
Millions
of work to persist the cache to disk
1+ year

Until 16.1, all of this lived in memory, and a restart threw it away. The file system cache writes the dependency graph, the aggregation graph and the cell contents to disk, so next dev resumes warm. The team promises a separate engineering post on that work.

What this means for your codebase

The engine skips work at the level of cells read. My reading, not the post's: your module structure decides how small those reads can be.

  • A barrel file that re-exports fifty modules gives many routes one shared dependency. A change behind it dirties more functions than a direct import would.
  • Tree shaking can't drop modules with top-level side effects, so more of the graph stays live for each route.
  • Equal-output cutoffs reward edits that leave interfaces alone. Changing a function body is cheaper than changing its exports.

If a one-line edit triggers a slow refresh, take it as a signal about your import graph before you blame the bundler.

This week

  1. Restart next dev on 16.1 or later and compare the first compile with the cold number. That gap is the cache at work.
  2. Find your largest barrel file and replace imports from it on one hot route with direct paths. Measure Fast Refresh before and after.
  3. Read the post's diagrams of the cold, dirty and propagate phases. They make the mental model stick faster than the prose.

Volodymyr Chornous