A-11 — Build Engine Internals: Turbopack, SWC, and Memory Optimisation
Who this is for: Architects who want to understand what happens between npm run build and the deployable output — how Turbopack replaced Webpack, why SWC is faster than Babel, how the module graph is constructed, and how to diagnose and fix build performance problems that appear when a codebase scales.
Why the Build Stack Changed
For four years, Next.js used Webpack 5 as its bundler and Babel as its JavaScript transformer. Both were fine at small scale. Both had known limitations at large scale:
Webpack's problem: Webpack rebuilds the module graph on every save. In a large application, the graph might have 10,000 modules. Even incremental builds re-analyse significant portions of this graph. Teams with large codebases saw 30-60 second hot reload times.
Babel's problem: Babel is written in JavaScript and processes files one-at-a-time. TypeScript type stripping, JSX transformation, decorator transforms — Babel does each file sequentially. SWC does the same transformations in Rust, parallelised across all CPU cores, 10-100x faster.
The replacement strategy: SWC for JavaScript/TypeScript transformation (already the default in Next.js 12+), Turbopack for bundling (stable in Next.js 15 for development, nearing stability for production).
SWC — The Transformation Layer
SWC (Speedy Web Compiler) is a Rust-based JavaScript/TypeScript compiler. It replaced Babel as Next.js's transform layer in version 12.
What SWC handles:
TypeScript → JavaScript (type stripping, not type checking)
Type checking (that's tsc --noEmit, run separately in CI)
Bundling (that's Webpack or Turbopack)
Custom Babel plugins (Babel is no longer in the chain when SWC is active)
The custom Babel plugin problem: if your project uses a Babel plugin that SWC doesn't have a native equivalent for, you have to keep Babel in the chain — which means losing SWC's speed advantage for those transforms. This is why teams with custom Babel plugins see slower builds than teams that migrated fully.
ts
For styled-components and Emotion, SWC has built-in transforms that are faster than the Babel plugins:
ts
Turbopack — The Bundler Replacement
Turbopack (also written in Rust) is the replacement for Webpack. Its architectural difference: incremental computation with fine-grained caching.
Webpack's model: build the entire module graph, apply transforms, produce bundles. Incremental builds re-analyse changed modules and their transitive dependents.
Turbopack's model: every module and every function on every module is a cacheable unit of computation. When a file changes, only the computation units that depend on that specific file are re-evaluated. The cache is persistent across restarts — a restart after Turbopack has warmed its cache is nearly as fast as a hot reload.
The practical result: a codebase that took 30 seconds for a hot reload with Webpack might take 500ms with Turbopack, because Turbopack doesn't re-evaluate the 9,800 modules that didn't change.
Enable Turbopack for development:
bash
Turbopack is the default for next dev in Next.js 15. For next build (production), Turbopack is still in progress — production builds use Webpack by default until Turbopack production build reaches parity.
Turbopack Configuration
Turbopack configuration lives in next.config.ts under the turbopack key:
ts
The Webpack loader compatibility note: Turbopack cannot use Webpack loaders directly. If your project uses custom Webpack loaders (SVG transforms, MDX loaders, etc.), you need to find or create Turbopack-compatible versions. This is the primary migration blocker for complex projects.
pageExtensions — Customising Which Files Are Routable
Next.js decides what counts as a page, layout, or route handler partly by file extension. The default list is ['tsx', 'ts', 'jsx', 'js'] — any file with one of those extensions in the right location (app/page.tsx, pages/about.js, etc.) is treated as routable. pageExtensions in next.config.ts lets you change that list:
ts
This is a narrower, more deliberate list than the default, and it's the realistic use case for touching this option at all: co-locating test files (or stories, or other non-route files) directly next to the component they belong to, inside the app directory, without Next.js mistaking them for routes.
text
Without a custom pageExtensions, dropping a page.test.tsx file at the route segment level doesn't accidentally become a route anyway, since Next.js's file-based routing only treats specific filenames (page, layout, route, etc.) as special — the risk pageExtensions actually manages is subtler: teams that want an additional required suffix (like requiring every route file to be named *.page.tsx rather than bare page.tsx) to make routable files visually distinct from everything else in the segment folder, or teams supporting a legacy convention where routes were marked with a custom extension pattern during a migration.
The other place this shows up is intentionally supporting multiple frontend flavors from one file-based structure — for example, a codebase mid-migration that wants .page.tsx to mean "new App Router convention" while older .jsx files without that marker are deliberately excluded from routing until they're migrated. Verify the exact default value and matching behaviour against your installed Next.js version before relying on it for anything migration-critical — the interaction between pageExtensions and the special page/layout/route filenames is easy to get subtly wrong.
Bundle Analysis
Before optimising, you need to see what's in your bundle. @next/bundle-analyzer produces a visual map of every module in every chunk:
bash
ts
bash
This opens two HTML files in your browser — one for the client bundle, one for the server bundle. Each file appears as a rectangle sized proportionally to its contribution to the total bundle size.
What to look for:
Large utility libraries used for one function — moment (200KB gzipped) when you only need date formatting. Replace with date-fns (tree-shakeable) or a one-liner.
Duplicate modules — the same library appearing multiple times, often because different parts of the tree import different versions.
The node_modules section — if third-party code dominates, look for lighter alternatives.
Your own code appearing unexpectedly large — usually means a large JSON file or SVG is being bundled inline.
The bundlePagesRouterDependencies Migration Problem
Migrating from Pages Router to App Router often surfaces a bundle size regression: code that was previously Server-only in Pages Router (run only in Node.js) might accidentally be included in the client bundle in the App Router if import boundaries aren't explicit.
The server-only package prevents this at the module level:
ts
If a Client Component accidentally imports from lib/db.ts, the build fails with a clear error. Without server-only, Prisma would end up in the client bundle — you'd see it in the bundle analysis as a mysterious 500KB addition.
Build Performance Profiling
When builds are slow, the first step is measuring where the time goes:
bash
This produces a .next/profile-events.json file you can load in Chrome DevTools' Performance tab or analyse with speedscope. The profile shows which transforms and route compilations take the most time.
Common culprits:
Type checking during build — tsc running as part of next build. Move type checking to a separate CI step: npm run typecheck before npm run build.
Large generateStaticParams outputs — generating 100k static pages at build time. Use ISR or PPR with on-demand generation instead.
Slow MDX/content transforms — transforming thousands of markdown files. Cache the transform output.
Missing barrel file optimisation — importing from index.ts files that re-export hundreds of modules causes the bundler to analyse all of them, even if only one is needed.
ts
optimizePackageImports tells Next.js to import directly from submodules rather than through the barrel index, eliminating the "analyse 500 modules to find the 3 you need" problem.
TypeScript Build Integration
The next build command includes TypeScript checking by default. In large codebases, this can add 60+ seconds to CI build time.
Options:
Keep it (default) — safest, catches type errors before deployment
Ignore during build, check in CI separately:
ts
The correct CI pipeline when using ignoreBuildErrors:
yaml
This runs type checking in parallel with other checks, potentially cutting CI time significantly.
Diagnosing OOM Build Failures
At some point, on a large enough app (a big monorepo, a codebase with hundreds of routes, or heavy use of dynamic imports fanning out into a huge dependency graph), a build that has always worked on your laptop starts dying in CI. Not slow — dead. The process gets killed mid-build.
Why it happens: Both Webpack and Turbopack need to hold a working model of your module graph in memory while they build — every module, its parsed AST, its transformed output, its dependency edges — for the whole compilation to reason about correctly. On a small app this is trivial. On a codebase with tens of thousands of modules (common in monorepos where the build pulls in several internal packages, or apps with a lot of route-level code splitting via dynamic imports), that in-memory graph itself becomes large, and Node's default heap ceiling wasn't sized for it. The build isn't leaking memory in the usual application-bug sense — it's legitimately trying to hold more live data than the heap allows.
The symptom is unambiguous once you've seen it once:
text
...followed by the process exiting non-zero and the build pipeline reporting a failure with no more specific stack trace pointing at your code — because it isn't your code, it's Node's V8 heap hitting its ceiling mid-compilation. This shows up disproportionately often in CI rather than on developer machines, for a mundane reason: CI runners are frequently provisioned with less RAM than a developer's laptop (a common default CI tier sits in the low single-digit gigabytes, versus 16-32GB+ on a typical dev machine), so a build that "just barely" fits locally has no headroom in the pipeline.
Mitigations, roughly in order of how often they actually apply:
Raise Node's heap ceiling for the build command. V8's default old-space limit is conservative relative to what a large Next.js build needs. NODE_OPTIONS lets you raise it for that one process:
json
Treat the megabyte figure as a starting point to tune, not a number to copy verbatim — the right ceiling depends on your graph size and the memory actually available on the box running the build; verify against what your CI runner has available and adjust from there.
Split an enormous monorepo build into per-package builds where feasible. If your CI pipeline builds every app in the monorepo as one giant step, and one Next.js app's module graph is the thing blowing the heap, building that app in isolation (its own CI job, its own memory budget) sidesteps the problem entirely rather than just delaying it. This isn't always feasible — shared build caching or cross-package type-checking can make a unified build step genuinely necessary — but it's worth asking whether the monolithic build step is load-bearing or just how the pipeline happened to get set up.
If you're still on Webpack, reduce parallelism. Webpack's parallel processing (multiple workers transforming modules concurrently) trades memory for speed — each parallel worker holds its own working set. Dropping the worker count (via experimental.workerThreads/experimental.cpus in older Webpack-era configs, or reducing whatever parallelism knob your Webpack setup exposes) trades build time for a lower peak memory footprint. This is a last resort, not a first one — it directly undoes some of the reason Webpack was fast in the first place — but on a memory-constrained CI runner, a build that finishes slowly beats a build that doesn't finish.
If you hit this on Turbopack specifically rather than Webpack, treat it as newer territory: Turbopack's memory characteristics under extreme monorepo scale are still evolving as it moves toward production-build parity, so check the changelog for your installed version before assuming the mitigation playbook above transfers exactly.
Where We Go From Here
A-12 focuses on the user experience side of performance: Core Web Vitals engineering — LCP, INP, CLS, and the specific Next.js patterns that affect each metric. After A-11's understanding of how bundles are built, A-12 explains how those bundles affect the metrics that Google measures and users feel.
Knowledge Check
Why did Next.js transition from Babel to SWC for its JavaScript and TypeScript transformation layer?
How does Turbopack's architectural model differ from Webpack's to achieve significantly faster incremental builds (hot reloads)?
What issue does the server-only package specifically prevent when migrating a large codebase to the Next.js App Router?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// next.config.ts — disable SWC if you have incompatible Babel transformsconst config: NextConfig ={ swcMinify:true,// use SWC for minification (default true in Next.js 13+)// To keep Babel (opt-out of SWC):// Create .babelrc or babel.config.js — Next.js automatically falls back to Babel// when it detects a Babel config file};
app/
dashboard/
page.page.tsx ← the actual route (matches 'page.tsx' extension pattern)
page.test.tsx ← NOT routable — doesn't match 'page.page.tsx' or 'page.page.ts'
Chart.tsx ← a regular component, not in a route-defining filename
Chart.test.tsx
// lib/db.tsimport'server-only';// throws if this module is imported by client codeimport{ PrismaClient }from'@prisma/client';exportconst db =newPrismaClient();
# Profile the build (Next.js 15+)NEXT_PROFILE=true npm run build
// next.config.tsconst config: NextConfig ={ typescript:{// Allows production builds to succeed even if there are TypeScript errors// (use this only if you have a separate typecheck step in CI) ignoreBuildErrors:true,},};
steps:-run: npm run typecheck # npx tsc --noEmit-run: npm run lint
-run: npm run test
-run: npm run build # fast build, no type check
<--- Last few GCs --->
...
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
// package.json{"scripts":{"build":"NODE_OPTIONS='--max-old-space-size=4096' next build"}}