Profiling Turbopack Builds with the Trace Viewer

When a Turbopack rebuild exceeds the sub-200ms it should take, guessing which module is at fault wastes time — the trace viewer shows exactly which task dominated the recompute. This guide captures a trace with NEXT_TURBOPACK_TRACING, loads it, and reads the hot path. It operationalizes the measurement side of Turbopack incremental compilation; the parent overview is esbuild & Turbopack Workflows.

The reason this problem exists at all is that Turbopack’s whole value proposition is that it does not redo work. It models your project as a graph of small, memoized tasks — read this file, parse it, transform it, resolve its imports, assemble the chunk — and on an incremental rebuild it re-executes only the tasks whose inputs actually changed. When everything is behaving, editing one component invalidates a handful of tasks and the update lands before you’ve moved your eyes back to the browser. When a rebuild is slow, the failure is almost never “Turbopack is slow” in general; it is that one specific task is either genuinely expensive or is being re-run when it should have been served from memory. Those two situations demand opposite fixes — make the task cheaper versus stop it from invalidating — and you cannot tell them apart by staring at source code. You have to look at what the engine actually did.

That is the gap the trace fills. Without it you are reduced to bisecting: comment out an import, save, feel whether the rebuild got faster, repeat. That loop is slow, imprecise, and it lies to you because HMR timing is noisy and your perception of “faster” is worse than a stopwatch. The trace replaces the whole ritual with a recording of every task the build ran, how long each took, and how they nested. In the build pipeline this sits strictly downstream of configuration and upstream of any optimization: you change nothing until the trace has named a specific span, and you verify every change by re-tracing rather than by feel. The rest of this guide is that loop — capture, read, act, confirm — made concrete.

A tracing run produces a file the viewer turns into a flame graph Setting NEXT_TURBOPACK_TRACING records a trace file of the build's tasks, the Turbopack trace viewer renders it as a timeline, and the widest span is the task that dominated the recompute and should be addressed. Record, render, read the widest span TRACING=1records trace file trace viewertimeline of tasks widest spanthe bottleneck the widest span names the module or task to fix
Figure: the trace turns a vague "it's slow" into a named task with a measured duration.

Prerequisites & reproducible setup

# Next.js 15.3.x, Node 20+
npx create-next-app@latest tp-trace-demo --ts --app
cd tp-trace-demo
npm pkg set scripts.dev="next dev --turbopack"

NEXT_TURBOPACK_TRACING=1 writes a trace to .next/trace-turbopack. The trace viewer ships with Next.js: run it against that file. A rebuild worth profiling is one where a single-file edit takes noticeably longer than the sub-200ms baseline.

The value of NEXT_TURBOPACK_TRACING is not a boolean on/off — it is a level filter. 1 records the high-level task spans that answer “which module cost the time”, which is what you want ninety percent of the time and what this guide uses. You can also pass a comma-separated target filter, for example NEXT_TURBOPACK_TRACING=turbo_tasks=info or a more verbose turbopack=trace, when you need to see the internal task boundaries the default level collapses. Start at 1. Only widen the filter once the coarse trace has pointed at a subsystem and you need to see why a span inside it is expensive; the verbose levels produce traces large enough to be their own performance problem to open.

Reproducing a representative slow rebuild matters more than the mechanics of capturing it. A trace of a rebuild that isn’t actually slow tells you nothing, and a trace of a rebuild slowed by something unrelated — a background type-check, a busy machine, a cold OS file cache — sends you chasing a phantom. Before you trace, edit the file a few times with tracing off and confirm the slowness is reproducible and lives in that one edit. Close other heavy processes. You want the trace to capture the build doing the expensive thing and as little else as possible, because every unrelated span is a candidate for misreading as the culprit.

Tracing writes a file the viewer reads The tracing environment variable makes the Turbopack dev server write a trace file under the .next directory, and the trace viewer that ships with Next.js loads that file to render the task timeline. TRACING=1env var .next/trace-turbopacktrace file trace viewerrenders it the env var is the whole capture step
Figure: setting the env var is all it takes to capture — the viewer does the rest.

How the trace is produced under the hood

The trace is a byproduct of the same task graph that makes incremental compilation work. Turbopack is built on turbo-tasks, a memoizing execution engine: every unit of work — reading a file, parsing a module, running a loader, resolving a specifier, producing a chunk — is a function whose result is cached and keyed by its inputs. When you edit a file, the engine marks the tasks that read that file as dirty, then walks the graph re-executing only dirty tasks and any task that transitively depends on a dirty task’s changed output. A task whose inputs are unchanged is never re-run; its cached value is reused. This is why a warm rebuild is fast at all, and it is exactly the structure the trace records.

With tracing on, each task execution emits a span carrying its name, its start and end timestamps, and its parent, so the recorded file is a nested tree that mirrors the shape of the graph walk for that one rebuild. A span’s total time is wall-clock from its start to its end, which includes everything it waited on; its self-time is total minus the time spent inside child spans. That distinction is the single most important thing to internalize before opening the viewer. A parent span like “build chunk” will always look enormous because it contains the whole subtree, but the time is not spent there — it is spent in whichever leaf is fat. Sorting by total time surfaces the region of the build to look in; sorting by self-time surfaces the task that actually burned the CPU. You act on self-time.

Two spans reading the same name across two rebuilds are the same graph node observed twice, which is what makes before/after comparison meaningful. If a span with a given name has a large self-time in the first trace and a small one after your change, the node you targeted got cheaper. If a span reappears at all for a file you did not touch, that node was invalidated when it should have been cached — a memoization miss, which is a completely different bug from an expensive-but-correct task and is covered under gotchas below.

Diagnosis workflow

  1. Capture a slow rebuild. Start the dev server with tracing, edit the file that triggers the slow rebuild, then stop the server so the trace flushes.
  2. Open the viewer. Load .next/trace-turbopack in the Turbopack trace viewer and sort spans by duration.
  3. Read the hot task. The widest span names the dominant task — often a single expensive transform, a non-deterministic loader forcing recompute, or an unexpectedly large module.

Each step has a failure mode worth spelling out. In the capture step, the trap is triggering the wrong rebuild: if you save a file with no actual change, Turbopack may short-circuit and the trace records nothing interesting; if you save the file that has the type checker running against it, you may capture the checker rather than the bundler. Make one real, minimal edit — change a string literal, add a console.log — to a module you know participates in the slow path, and make exactly one, so a single edit maps to a single invalidation front you can reason about. The open step’s trap is reading the tree at the wrong altitude. The viewer opens on the total-time flame graph, where the root spans are always the widest simply because they contain everything; do not act on those. Flip to self-time sorting immediately so the leaves that actually consumed CPU float to the top.

The read step is where judgment happens, and the span name is the whole payload. Turbopack names spans after the operation and its subject, so “transform app/dashboard/heavy-chart.tsx” is not just telling you a transform was slow — it is naming the exact file to open. Three shapes dominate real traces. A single leaf with a huge self-time is an expensive task: one module whose transform genuinely costs milliseconds, usually because the module is large, generated, or runs a heavy loader. A cloud of small spans that all reappear on an untouched file is a memoization miss: nothing is individually expensive but the graph is redoing work it should have cached. And a wide “resolve” span is a resolution blowup: a dynamic or glob import forcing the resolver to enumerate a directory on every rebuild. The rest of this guide maps each shape to its fix.

Sort spans by duration to find the bottleneck In the trace viewer sorting spans by duration surfaces the widest span, which is usually a single expensive transform, a non-deterministic loader forcing recompute, or an unexpectedly large module, and that span names what to fix. span A widest — the bottleneck span B span C the longest bar is where the time went
Figure: the longest bar is the answer — everything else is noise until it is addressed.

Annotated solution config

# Capture a trace of a slow rebuild (Next.js 15.3.x).
# 1. Start with tracing on.
NEXT_TURBOPACK_TRACING=1 next dev --turbopack

# 2. In another terminal, touch the file whose edit is slow.
#    Then make a real one-line change in the editor to trigger a rebuild.

# 3. Stop the dev server (Ctrl-C) so the trace flushes to disk.

# 4. Open the trace in the viewer bundled with Next.
npx next internal turbo-trace-server .next/trace-turbopack
#    then open the printed localhost URL and sort spans by self-time.
// Common findings and their fixes (read the widest span's name):
// - "transform "        → split or lazy-load the module
// - "loader "             → make the loader deterministic (no Date.now)
// - "resolve "  → replace a dynamic glob with static imports

The turbo-trace-server subcommand starts a local HTTP server and prints a URL; it does not open a file directly, and it keeps the trace in memory, so a very large verbose trace can take a few seconds to become interactive. Leave it running while you iterate — you will re-open several traces during one session, and pointing the same server at successive files is faster than restarting it. If the printed page is blank, the trace was almost certainly not flushed; see the gotchas section.

Mapping a finding to a fix is worth making concrete, because the same span name has different right answers depending on what the module is. Take the most common case, a fat “transform” span on a route that imports a heavy client-only widget. The wrong instinct is to optimize the widget’s code. The right fix is to stop that widget from being on the synchronous path of the route’s rebuild at all, by loading it dynamically so its transform becomes its own lazily-compiled task rather than a child of the route’s rebuild:

// app/dashboard/page.tsx — Next.js 15.3.x
// Before: the heavy chart is a static import, so its transform is a child
// span of every rebuild of this route — it shows up as the widest leaf.
//   import { HeavyChart } from "@/components/heavy-chart";
//
// After: the chart is loaded dynamically. Its transform becomes a separate
// task that no longer blocks (or widens) the route's incremental rebuild.
import dynamic from "next/dynamic";

const HeavyChart = dynamic(
  () => import("@/components/heavy-chart").then((m) => m.HeavyChart),
  { ssr: false, loading: () => <div className="chart-skeleton" /> },
);

export default function DashboardPage() {
  return (
    <section>
      <HeavyChart />
    </section>
  );
}

Re-trace after this change and the “transform heavy-chart” span no longer sits under the route rebuild — it either disappears from the warm-rebuild trace entirely (because you did not touch the chart) or moves onto its own path where it stops inflating the edit you actually care about. That is the pattern for every “expensive transform” finding: don’t make the expensive task faster, take it off the critical path of the edit you make most often.

Capture with tracing, then serve the trace The workflow starts the dev server with tracing, triggers the slow rebuild with a real edit, stops the server to flush the trace, and serves the trace file with the bundled turbo-trace-server for inspection. TRACING devstart edit filetrigger rebuild stop serverflush trace trace-serverinspect stop the server to flush before inspecting
Figure: the flush step matters — stop the server before opening the trace or it may be incomplete.

Verification

# Next.js 15.3.x — after acting on the hot span, re-trace and compare.
NEXT_TURBOPACK_TRACING=1 next dev --turbopack
# Edit the same file; the rebuild should now be under ~200ms and the
# previously-widest span should be shorter or gone in the new trace.

After splitting the fat module or fixing the non-deterministic loader, a fresh trace shows the previously-dominant span shrunk and the single-file rebuild back under the ~200ms baseline. If the same span is still widest, the change did not address the actual bottleneck — re-read the trace.

Verification by re-tracing rather than by feel is not a nicety; it is the only way to distinguish a real fix from a coincidence. Rebuild timings vary run to run because of OS scheduling, file-cache warmth, and background load, so a single “it feels faster” observation is inside the noise. Compare the named span across the two traces, not your stopwatch. The clean outcomes are: the span’s self-time dropped substantially (you made the task cheaper), or the span is absent from the warm trace (you took it off the edit’s critical path). Either one, confirmed against the same span name, means the fix landed.

There are two misleading non-outcomes to recognize. First, the total rebuild got faster but your target span is unchanged — you fixed something, but not the thing you were reasoning about, which usually means there were two bottlenecks and you happened to remove a different one; keep going. Second, the target span shrank but the rebuild is still slow — the span you fixed was real but not dominant, and a new widest span has taken its place. Re-sort by self-time on the new trace and repeat the loop. Profiling is iterative by nature: each fix reveals the next bottleneck that the previous one was hiding, and you stop when the widest self-time span is small enough that the rebuild is back under the baseline.

Re-trace to confirm the hot span shrank A second trace after the fix should show the previously-widest span shorter or gone and the rebuild back under the 200 millisecond baseline; if the same span is still widest the change missed the real bottleneck. hot span shrankrebuild < 200ms same span widestmissed the bottleneck the trace itself confirms whether the fix landed
Figure: the follow-up trace is the verification — a shrunk span means the right fix.

Gotchas & edge cases

Four Turbopack profiling edge cases Tracing adds overhead so do not leave it on; the first cold build is dominated by full compilation not incremental work so trace a warm rebuild; a non-deterministic loader shows as perpetual recompute; and the trace must be flushed by stopping the server before it is complete. tracing overhead→ turn it off after profiling the cold build→ trace a warm rebuild perpetual recompute→ deterministic loader incomplete trace→ stop server to flush
Figure: profiling the cold build is a common mistake — the incremental rebuild is what the trace should isolate.
  • Tracing has overhead. The symptom is that every rebuild feels slightly slower than it did before you started profiling, and absolute numbers in the trace read higher than the numbers you are trying to fix. The root cause is that recording a span for every task is itself work: the engine writes timing data the whole time it runs. This is fine — you are reading relative span widths, not absolute times, so the overhead is a constant tax that does not change which span is widest. The fix is simply to leave NEXT_TURBOPACK_TRACING unset for normal development and only export it in the shell where you are actively profiling. Confirm you got it off by checking that no new .next/trace-turbopack file appears after a rebuild.
  • Do not profile the cold build. The symptom is a trace dominated by hundreds of “transform” spans of roughly equal size with no obvious hot leaf. The root cause is that you traced the first build after starting the server, which compiles the entire module graph from scratch — that work is supposed to be large and tells you nothing about incremental behaviour. The fix is to start the server, let the first build finish and the cache warm, then make your one-line edit and trace the warm rebuild. Confirm you captured the right build by checking that the trace contains a small number of spans (the invalidated subgraph), not the whole project.
  • Perpetual recompute. The symptom is a span that reappears with meaningful self-time on every rebuild, including rebuilds of files that have nothing to do with it. The root cause is a memoization miss: a task’s output is non-deterministic — a loader that calls Date.now(), reads a mutable global, or emits a fresh timestamp — so its cached result never matches and every dependent task is forced to re-run. The fix is to make the loader a pure function of its declared inputs; remove wall-clock reads, randomness, and ambient state. Confirm it by re-tracing an edit to an unrelated file: the previously-perpetual span should now be absent, because its inputs did not change.
  • Flush the trace. The symptom is a trace that opens blank, is truncated, or is missing the span you were sure you triggered. The root cause is that the trace buffer is written on process shutdown, so a running dev server has not yet committed the tail of the recording to disk. The fix is to stop the dev server with Ctrl-C and let it exit cleanly — do not kill -9 it — before pointing the viewer at the file. Confirm the file is complete by checking that its modification time is after you stopped the server, not before.

CI integration

The trace viewer is an interactive tool, but the capture step is scriptable, which is enough to catch rebuild-time regressions before they merge. The pattern is not to open a flame graph in CI — nobody is watching — but to record a trace of a scripted warm rebuild and fail the job if a known-hot span exceeds a threshold. In practice you script the dev server start, wait for the ready signal, programmatically touch a representative file to force one rebuild, stop the server, and then parse .next/trace-turbopack for the self-time of the span you care about. Because the trace is a structured file rather than terminal output, a small parser can assert transform of the route stayed under N ms and turn a subjective “the dev loop feels sluggish lately” into a red build on the commit that caused it. Keep the threshold generous — CI machines are noisier than laptops and you want to catch a doubling, not a five-percent jitter.

When not to reach for the trace viewer

The trace viewer answers one question — where did an incremental rebuild’s time go — and reaching for it outside that question wastes effort. If your complaint is production bundle size rather than dev rebuild speed, the trace is the wrong tool; you want the bundle analyzer, because a span’s duration says nothing about the bytes it emits. If your complaint is the cold start being slow, remember that a cold build is supposed to be large and the trace will only confirm that everything compiled; the levers there are caching and parallelism, not any single span. And if the rebuild is already under the baseline and merely feels slow, the bottleneck is likely the browser applying the HMR update or your framework re-rendering, neither of which appears in a Turbopack trace at all — profile the browser instead. Open the trace viewer when a warm, incremental, single-file rebuild is measurably slow and you need to know which task to blame; for everything else, a different instrument fits the question better.