Adetayo Akinsanya unkletayo.dev

Why Jest Slows Down at Scale: Dual Module Graphs & ESM Interop Bottlenecks

Deconstructing why Jest transformer pipelines duplicate work already handled by modern build tools.

Adetayo Akinsanya (unkletayo) 2026-09-11

Part 1 in Series — Catch up on the previous article: Vitest Architecture & Modern Testing Systems: Series Introduction (Part 0) before diving into this post.

Jest was designed when CommonJS ruled Node.js and client-side bundlers like Webpack dominated frontend development. To execute ES modules and TypeScript, Jest created a custom module loader (jest-runtime) that intercepts Node’s native require system.

In a modern project running Webpack or Vite alongside Jest, code is transformed twice: once by your build tool for development, and once by Jest’s custom transformers (babel-jest, ts-jest) for testing.

// Legacy Jest execution path
// 1. Read file from disk
// 2. Pass string to Babel/ts-jest
// 3. Transform ESM import to CJS require()
// 4. Evaluate inside VM context via custom require override

The Dual Module Graph Problem

When Node.js introduced native ES modules, Jest’s custom VM contexts ran into severe friction. Node’s native ESM uses asynchronous module graphs where dependencies load before execution. Jest’s synchronous require() hooks couldn’t handle async module resolution without heavy wrappers.

+-------------------------------------------------------+
|                 Application Development               |
|  Source Code ---> Vite/esbuild ---> Browser (ESM)     |
+-------------------------------------------------------+

+-------------------------------------------------------+
|                   Jest Test Runner                    |
|  Source Code ---> ts-jest/Babel ---> CJS Cache ---> VM|
+-------------------------------------------------------+

Because Jest maintains its own file cache in node_modules/.cache/jest, changing a single utility function forces Jest to re-parse and re-transform dependent modules from scratch. In projects with 5,000+ files, module resolution alone accounts for up to 60% of test runtime.

Vitest’s Single Graph Architecture

Vitest eliminates the second module graph. When Vitest runs, it starts an instance of the Vite dev server inside Node.js. When a test imports @/utils/format, Vitest requests that module from Vite’s SSR transform pipeline.

// Vitest execution path
// Reuses the exact same Vite transform pipeline as dev server
const module = await viteDevServer.ssrLoadModule('/src/utils/format.ts');

If esbuild transformed that file 200 milliseconds ago during your dev session, Vite serves the cached AST transform immediately. There are no separate Babel configs, no duplicate cache folders, and no CommonJS transpilation steps.

Up Next in Series →

Part 2: The Vite Module Graph & On-Demand Transformation in Unit Testing

Continue to Part 2 →