Skip to content

Batch require cache deletion to avoid quadratic scanning#90625

Merged
lukesandberg merged 2 commits intocanaryfrom
lukesandberg/quadratic_require_cache_deletion
Mar 5, 2026
Merged

Batch require cache deletion to avoid quadratic scanning#90625
lukesandberg merged 2 commits intocanaryfrom
lukesandberg/quadratic_require_cache_deletion

Conversation

@lukesandberg
Copy link
Contributor

@lukesandberg lukesandberg commented Feb 27, 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.

@lukesandberg lukesandberg force-pushed the lukesandberg/quadratic_require_cache_deletion branch from be36cf9 to a6dfc86 Compare February 27, 2026 20:04
@lukesandberg lukesandberg marked this pull request as ready for review February 27, 2026 20:04
@lukesandberg lukesandberg force-pushed the lukesandberg/quadratic_require_cache_deletion branch from a6dfc86 to d4c935f Compare March 4, 2026 21:41
@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Mar 4, 2026

Tests Passed

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).
@lukesandberg lukesandberg force-pushed the lukesandberg/quadratic_require_cache_deletion branch from d4c935f to a1f9aa0 Compare March 5, 2026 18:48
Copy link
Contributor Author

lukesandberg commented Mar 5, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@lukesandberg lukesandberg merged commit 3beb466 into canary Mar 5, 2026
158 checks passed
Copy link
Contributor Author

Merge activity

@lukesandberg lukesandberg deleted the lukesandberg/quadratic_require_cache_deletion branch March 5, 2026 19:07
@nextjs-bot
Copy link
Collaborator

Stats from current PR

✅ No significant changes detected

📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 455ms 455ms ▁▁▁▇▇
Cold (Ready in log) 437ms 438ms ▁▂▂▇▇
Cold (First Request) 1.275s 1.272s ▁▃▃▇▇
Warm (Listen) 457ms 456ms ▁▁▁▇▇
Warm (Ready in log) 442ms 442ms ▁▁▁▆▆
Warm (First Request) 349ms 348ms ▁▁▁▆▆
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 456ms 455ms ▁▅▁▁▁
Cold (Ready in log) 451ms 452ms ▅▇▅▂▆
Cold (First Request) 2.062s 2.062s ▄▆▅▁▅
Warm (Listen) 456ms 456ms ▅▁▅▁▁
Warm (Ready in log) 451ms 449ms ▇▇▇▃▆
Warm (First Request) 2.061s 2.062s ▅▅▅▁▅

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 3.818s 3.781s ▂▁▁▆▆
Cached Build 3.879s 3.869s ▂▁▁▆▆
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 14.834s 14.771s ▃▂▂▁▃
Cached Build 14.888s 14.836s ▄▂▂▁▂
node_modules Size 476 MB 476 MB ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **401 kB** → **401 kB** ✅ -14 B

80 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 767 B 764 B
Total 767 B 764 B ✅ -3 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 450 B 452 B
Total 450 B 452 B ⚠️ +2 B

📦 Webpack

Client

Main Bundles
Canary PR Change
5528-HASH.js gzip 5.54 kB N/A -
6280-HASH.js gzip 59.4 kB N/A -
6335.HASH.js gzip 169 B N/A -
912-HASH.js gzip 4.59 kB N/A -
e8aec2e4-HASH.js gzip 62.6 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 256 B 253 B 🟢 3 B (-1%)
main-HASH.js gzip 39.1 kB 39.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
262-HASH.js gzip N/A 4.59 kB -
2889.HASH.js gzip N/A 169 B -
5602-HASH.js gzip N/A 5.55 kB -
6948ada0-HASH.js gzip N/A 62.6 kB -
9544-HASH.js gzip N/A 60.2 kB -
Total 233 kB 234 kB ⚠️ +732 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 194 B
_error-HASH.js gzip 183 B 180 B 🟢 3 B (-2%)
css-HASH.js gzip 331 B 330 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 256 B 256 B
head-HASH.js gzip 351 B 352 B
hooks-HASH.js gzip 384 B 383 B
image-HASH.js gzip 580 B 581 B
index-HASH.js gzip 260 B 260 B
link-HASH.js gzip 2.51 kB 2.51 kB
routerDirect..HASH.js gzip 320 B 319 B
script-HASH.js gzip 386 B 386 B
withRouter-HASH.js gzip 315 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.98 kB 7.98 kB ✅ -1 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 125 kB 125 kB
page.js gzip 254 kB 254 kB
Total 379 kB 379 kB ⚠️ +404 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 616 B 616 B
middleware-r..fest.js gzip 156 B 155 B
middleware.js gzip 43.6 kB 43.6 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 45.2 kB 45.2 kB ⚠️ +42 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 715 B 718 B
Total 715 B 718 B ⚠️ +3 B
Build Cache
Canary PR Change
0.pack gzip 4.03 MB 4.05 MB 🔴 +12.5 kB (+0%)
index.pack gzip 102 kB 102 kB
index.pack.old gzip 102 kB 103 kB 🔴 +1.03 kB (+1%)
Total 4.24 MB 4.25 MB ⚠️ +13.5 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 321 kB 321 kB
app-page-exp..prod.js gzip 170 kB 170 kB
app-page-tur...dev.js gzip 321 kB 321 kB
app-page-tur..prod.js gzip 170 kB 170 kB
app-page-tur...dev.js gzip 318 kB 318 kB
app-page-tur..prod.js gzip 168 kB 168 kB
app-page.run...dev.js gzip 318 kB 318 kB
app-page.run..prod.js gzip 168 kB 168 kB
app-route-ex...dev.js gzip 70.3 kB 70.3 kB
app-route-ex..prod.js gzip 48.6 kB 48.6 kB
app-route-tu...dev.js gzip 70.4 kB 70.4 kB
app-route-tu..prod.js gzip 48.7 kB 48.7 kB
app-route-tu...dev.js gzip 69.9 kB 69.9 kB
app-route-tu..prod.js gzip 48.4 kB 48.4 kB
app-route.ru...dev.js gzip 69.9 kB 69.9 kB
app-route.ru..prod.js gzip 48.4 kB 48.4 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 43.2 kB 43.2 kB
pages-api-tu..prod.js gzip 32.9 kB 32.9 kB
pages-api.ru...dev.js gzip 43.2 kB 43.2 kB
pages-api.ru..prod.js gzip 32.9 kB 32.9 kB
pages-turbo....dev.js gzip 52.6 kB 52.6 kB
pages-turbo...prod.js gzip 38.5 kB 38.5 kB
pages.runtim...dev.js gzip 52.6 kB 52.6 kB
pages.runtim..prod.js gzip 38.5 kB 38.5 kB
server.runti..prod.js gzip 61.8 kB 61.8 kB
Total 2.83 MB 2.83 MB
📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/a1f9aa0b708eeacb9d883850977fd6f1fd6ab072/next

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants