Batch require cache deletion to avoid quadratic scanning#90625
Merged
lukesandberg merged 2 commits intocanaryfrom Mar 5, 2026
Merged
Batch require cache deletion to avoid quadratic scanning#90625lukesandberg merged 2 commits intocanaryfrom
lukesandberg merged 2 commits intocanaryfrom
Conversation
be36cf9 to
a6dfc86
Compare
a6dfc86 to
d4c935f
Compare
Collaborator
Tests Passed |
sokra
reviewed
Mar 5, 2026
packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts
Outdated
Show resolved
Hide resolved
sokra
reviewed
Mar 5, 2026
sokra
reviewed
Mar 5, 2026
sokra
approved these changes
Mar 5, 2026
deleteFromRequireCache() scans the entire require.cache for every single module deletion to clean up parent-child references. When called in a loop for N files, this is O(N * C) where C is the cache size. Rewrite deleteFromRequireCache to accept string[], collecting modules into a Set and doing one scan with O(1) Set.has() lookups. Add deleteCacheBatch() for callers that delete multiple modules. Update all three batch call sites: turbopack hot-reloader (serverPaths loop), manifest-loader (~15 manifest files per writeManifests), and webpack plugin (afterEmit runtime + page entries).
d4c935f to
a1f9aa0
Compare
Contributor
Author
Merge activity
|
Collaborator
Stats from current PR✅ No significant changes detected📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles: **401 kB** → **401 kB** ✅ -14 B80 files with content-based hashes (individual files not comparable between builds) Server Middleware
Build DetailsBuild Manifests
📦 WebpackClient Main Bundles
Polyfills
Pages
Server Edge SSR
Middleware
Build DetailsBuild Manifests
Build Cache
🔄 Shared (bundler-independent)Runtimes
📎 Tarball URL |
sokra
pushed a commit
that referenced
this pull request
Mar 6, 2026
### What? Rewrites `deleteFromRequireCache()` in `packages/next/src/server/dev/require-cache.ts` to accept an array of file paths and perform a single scan of `require.cache`, instead of one full scan per file. Adds a `deleteCacheBatch()` export and updates all three batch call sites to use it. ### Why? During server-side HMR, `deleteCache()` is called in loops — once per server path in the turbopack hot-reloader (5-20 files), up to 15 times per `writeManifests()` in the manifest-loader, and 2 + N times (runtime chunks + page entries) in the webpack plugin's `afterEmit` hook. Each call scans the **entire** `require.cache` to clean up parent-child references, making the overall cost O(N × C) where C is the cache size. In large apps with thousands of cached modules, this becomes a meaningful bottleneck on every HMR update. ### How? **Core change (`require-cache.ts`):** `deleteFromRequireCache` now accepts `string[]`. It resolves all paths upfront, collects target modules into a `Set<NodeModule>`, then does one scan of `require.cache` using `Set.has()` (O(1) lookup) to filter children arrays. For single-item callers (`deleteCache`), the overhead of a 1-element Set is negligible. **Call site updates:** - **`hot-reloader-turbopack.ts`**: Collects files to delete into an array during the `serverPaths` loop, calls `deleteCacheBatch()` once after. `clearModuleContext` stays per-file (separate system). - **`manifest-loader.ts`**: Adds `pendingCacheDeletes` array to `TurbopackManifestLoader`. All ~15 `deleteCache()` calls in `write*` methods become `push()` calls. Flushed at the end of `writeManifests()` with a single `deleteCacheBatch()`. Moving cache deletion to after all `writeFileAtomic` calls is safe (synchronous code, no interleaving) and slightly better (new files on disk before cache is cleared). - **`nextjs-require-cache-hot-reloader.ts`** (webpack): Batches the `afterEmit` hook — collects runtime chunk + page entry paths, calls `deleteCacheBatch()` once. The `assetEmitted` tap callback stays as individual `deleteCache()`. **Theoretical improvement:** | Call Site | Before (cache scans) | After | Improvement | |-----------|---------------------|-------|-------------| | turbopack `clearRequireCache` | N (5-20) | 1 | 5-20× fewer scans | | manifest-loader `writeManifests` | up to 15 | 1 | up to 15× fewer scans | | webpack `afterEmit` | 2 + page count | 1 | up to 50× fewer scans | ### Benchmark Tested with a generated 250-route app with 50 API route handlers importing server external packages (zod, lodash, express, pg, ioredis) plus middleware. API route handlers are key because unlike bundled app-router pages, they create real `require.cache` depth with native Node.js module loading. **Results (steady-state, 18 paths / 7 found / 781 nodes / ~1800 edges):** | Mode | Time per batch | |------|---------------| | **Batched** (1 scan) | **~0.24ms** | | **Unbatched** (18 individual scans) | **~0.70ms** | | **Speedup** | **~3×** | Raw data (representative samples): ``` # Batched (1 scan of require.cache) [PERF] deleteCacheBatch(batch=true): 18 paths (7 found), 781 nodes, 1772 edges, 0.283ms [PERF] deleteCacheBatch(batch=true): 18 paths (7 found), 781 nodes, 1800 edges, 0.250ms [PERF] deleteCacheBatch(batch=true): 18 paths (7 found), 781 nodes, 1912 edges, 0.283ms # Unbatched (18 individual scans) [PERF] deleteCacheBatch(batch=false): 18 paths (7 found), 781 nodes, 1772 edges, 0.671ms [PERF] deleteCacheBatch(batch=false): 18 paths (7 found), 781 nodes, 1800 edges, 0.649ms [PERF] deleteCacheBatch(batch=false): 18 paths (7 found), 781 nodes, 1912 edges, 0.716ms ``` The speedup scales with cache complexity — in production apps with more server externals (ORMs, validation libs, etc.) the cache would be larger and the improvement proportionally greater. With only bundled app-router pages (few edges in require.cache graph), the difference is negligible since most of the graph complexity comes from native Node.js module loading.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What?
Rewrites
deleteFromRequireCache()inpackages/next/src/server/dev/require-cache.tsto accept an array of file paths and perform a single scan ofrequire.cache, instead of one full scan per file. Adds adeleteCacheBatch()export and updates all three batch call sites to use it.Why?
During server-side HMR,
deleteCache()is called in loops — once per server path in the turbopack hot-reloader (5-20 files), up to 15 times perwriteManifests()in the manifest-loader, and 2 + N times (runtime chunks + page entries) in the webpack plugin'safterEmithook. Each call scans the entirerequire.cacheto clean up parent-child references, making the overall cost O(N × C) where C is the cache size. In large apps with thousands of cached modules, this becomes a meaningful bottleneck on every HMR update.How?
Core change (
require-cache.ts):deleteFromRequireCachenow acceptsstring[]. It resolves all paths upfront, collects target modules into aSet<NodeModule>, then does one scan ofrequire.cacheusingSet.has()(O(1) lookup) to filter children arrays. For single-item callers (deleteCache), the overhead of a 1-element Set is negligible.Call site updates:
hot-reloader-turbopack.ts: Collects files to delete into an array during theserverPathsloop, callsdeleteCacheBatch()once after.clearModuleContextstays per-file (separate system).manifest-loader.ts: AddspendingCacheDeletesarray toTurbopackManifestLoader. All ~15deleteCache()calls inwrite*methods becomepush()calls. Flushed at the end ofwriteManifests()with a singledeleteCacheBatch(). Moving cache deletion to after allwriteFileAtomiccalls is safe (synchronous code, no interleaving) and slightly better (new files on disk before cache is cleared).nextjs-require-cache-hot-reloader.ts(webpack): Batches theafterEmithook — collects runtime chunk + page entry paths, callsdeleteCacheBatch()once. TheassetEmittedtap callback stays as individualdeleteCache().Theoretical improvement:
clearRequireCachewriteManifestsafterEmitBenchmark
Tested with a generated 250-route app with 50 API route handlers importing server external packages (zod, lodash, express, pg, ioredis) plus middleware. API route handlers are key because unlike bundled app-router pages, they create real
require.cachedepth with native Node.js module loading.Results (steady-state, 18 paths / 7 found / 781 nodes / ~1800 edges):
Raw data (representative samples):
The speedup scales with cache complexity — in production apps with more server externals (ORMs, validation libs, etc.) the cache would be larger and the improvement proportionally greater. With only bundled app-router pages (few edges in require.cache graph), the difference is negligible since most of the graph complexity comes from native Node.js module loading.