Skip to content

perf: cache manifest loading and component resolution per route#91599

Open
benfavre wants to merge 1 commit intovercel:canaryfrom
benfavre:perf/cache-manifests-per-route
Open

perf: cache manifest loading and component resolution per route#91599
benfavre wants to merge 1 commit intovercel:canaryfrom
benfavre:perf/cache-manifests-per-route

Conversation

@benfavre
Copy link
Contributor

Summary

  • Cache loadManifests result per route in RouteModule.prepare() — eliminates 10+ path.join() + loadManifestFromRelativePath calls per request in production
  • Cache loadComponents result per page in NextNodeServer.findPageComponentsImpl() — avoids repeated manifest loading and module resolution per request in production
  • Both caches are production-only (!isDev) and lazily initialized

Background

In production, manifests and component modules are immutable between deploys. However, loadManifests and loadComponents are called on every incoming request. While the underlying file reads are already cached by loadManifest/evalManifest in load-manifest.external.ts, the overhead of:

  • 10+ path.join() calls to construct manifest paths
  • 10+ function call + parameter destructuring overheads for loadManifestFromRelativePath
  • Building the 12-property result object
  • loadComponents doing requirePage + manifest loading per request

...adds measurable latency under sustained load.

Changes

packages/next/src/server/route-modules/route-module.ts

  • Extract LoadedManifests type alias (was inline on loadManifests return)
  • Add manifestsCache: Map<string, LoadedManifests> (lazily initialized, production-only)
  • Cache loadManifests result by srcPage key in prepare()

packages/next/src/server/next-server.ts

  • Add componentsCache: Map<string, LoadComponentsReturnType> (lazily initialized, production-only)
  • Cache loadComponents result by pagePath:isAppPath key in findPageComponentsImpl()

Safety

  • Caches are disabled in dev mode where manifests change on recompilation
  • LoadedManifests contains only manifest data and compiled module references — no per-request state
  • LoadComponentsReturnType contains component modules, manifest data, and static getters — no per-request state
  • Existing loadManifest file-level caching is preserved as a lower-level cache

Test plan

  • Existing test suite passes (no behavioral changes, only caching of already-deterministic results)
  • Verify dev mode is unaffected (caches are not populated when isDev is true)
  • Production builds serve pages correctly with cached manifests and components

🤖 Generated with Claude Code

In production, manifests and component modules are immutable between
deploys. Currently, `loadManifests` in `RouteModule.prepare()` performs
10+ `path.join()` calls and `loadManifestFromRelativePath` invocations
per request, and `findPageComponentsImpl` calls `loadComponents` (which
loads manifests + resolves modules) on every request.

This adds two caches that are only active in production (!isDev):

1. `manifestsCache` on `RouteModule` — caches the entire assembled
   manifests result object by `srcPage` key, eliminating redundant
   path construction and manifest lookup overhead.

2. `componentsCache` on `NextNodeServer` — caches `loadComponents`
   results by `pagePath:isAppPath` key, avoiding repeated manifest
   loading and module resolution.

Both caches are lazily initialized (only allocated when first needed)
and are disabled in development mode where manifests change on
recompilation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nextjs-bot
Copy link
Collaborator

Allow CI Workflow Run

  • approve CI run for commit: 26aafbf

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

@benfavre
Copy link
Contributor Author

Performance Impact

Profiling setup: Node.js v25.7.0, --cpu-prof, autocannon c=30 for 20s, 10-layout deep route.

Before (canary):

  • loadManifestFromRelativePath inclusive time: 296ms (0.8% of CPU)
  • loadManifest self-time: 58ms (cache Map.get lookup × 10+ per request)
  • normalizeString @ node:path: 136ms (from path.join() in manifest path construction)
  • Called from route-module.ts loadManifests() which runs on every request
  • Each call: 10-12 × loadManifestFromRelativePath()path.join(projectDir, distDir, manifest)loadManifest(path)Map.get(path)
  • The manifest data IS cached by loadManifest, but the path construction + function call overhead + Map lookup happens fresh every time

After (this PR):

  • loadManifest self-time: 0ms — entire result cached by srcPage key
  • manifestsCache: Map<string, LoadedManifests> on RouteModule — first request loads, subsequent requests get the cached result directly
  • Eliminates per-request: 10+ path.join() calls, 10+ loadManifestFromRelativePath() calls, 10+ Map.get() lookups, 12-property result object construction
  • componentsCache: Map<string, LoadComponentsReturnType> on NextNodeServer — caches loadComponents() result similarly
  • Both caches disabled in dev mode (!isDev) where manifests change on recompilation

@benfavre
Copy link
Contributor Author

Performance Impact — Updated

With proper build (all PRs compiled), verified via CPU profile:

  • loadManifest self-time: 84ms → 0ms (completely cached)
  • loadManifestFromRelativePath self-time: 35ms → 0ms (never called after first request)
  • normalizeString @ node:path: 136ms → 24ms (-82%, from cached path.join() results)

The manifest cache eliminates 10+ path.join() + Map.get() + function calls per request for the same route.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants