Skip to content

Conversation

@wyattjoh
Copy link
Member

@wyattjoh wyattjoh commented Jan 14, 2026

Summary

Implements an LRU cache with compound keys for the minimal mode response cache to improve cache hit rates during parallel revalidation scenarios.

Problem: The previous single-entry cache (previousCacheItem) keyed by pathname caused cache collisions when multiple concurrent invocations (e.g., during ISR revalidation) accessed the same pathname. Each invocation would overwrite the previous entry, leading to cache misses and redundant work.

Solution: An LRU cache using compound keys (pathname + invocationID) that allows multiple invocations to cache entries for the same pathname independently:

Cache Key Structure
─────────────────────
/blog/post-1\0inv-abc  →  {entry, expiresAt}
/blog/post-1\0inv-def  →  {entry, expiresAt}
/blog/post-1\0__ttl__  →  {entry, expiresAt}  (TTL fallback)
/api/data\0inv-ghi     →  {entry, expiresAt}

Cache Key Strategy

  • With x-invocation-id header: Entries are keyed by invocation ID for exact-match lookups (always a cache hit if the entry exists)
  • Without header (TTL fallback): Entries use a __ttl__ sentinel key and validate via expiration timestamp

Configuration via Environment Variables

Cache sizing can be tuned via environment variables (using NEXT_PRIVATE_* prefix for infrastructure-level settings):

Environment Variable Default Description
NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE 150 Max entries in the LRU cache
NEXT_PRIVATE_RESPONSE_CACHE_TTL 10000 TTL in ms for cache entries (fallback validation)

LRU Cache Enhancement

Added an optional onEvict callback to LRUCache that fires when entries are evicted due to capacity limits. This enables tracking evicted invocation IDs for warning detection without introducing timer-based cleanup.

Eviction Warnings

When a cache entry is evicted and later accessed by the same invocation, a warning is logged suggesting to increase NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE. This helps developers tune cache sizes for their workload.

Additional Changes

  • Renamed header from x-vercel-id to x-invocation-id for clarity
  • Added withInvocationId() test helper for cache testing

Test Plan

  • Existing response cache tests pass with updated header name
  • Unit tests for LRUCache including onEvict callback behavior
  • Updated standalone mode tests to use withInvocationId() helper

@wyattjoh wyattjoh force-pushed the wyattjoh/response-cache-lru branch from 6bbc429 to d9faef8 Compare January 14, 2026 00:37
Copy link
Member Author

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

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Jan 14, 2026

Tests Passed

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Jan 14, 2026

Stats from current PR

🔴 1 regression

Metric Canary PR Change Trend
node_modules Size 460 MB 460 MB 🔴 +718 kB (+0%) ▁▁▁▁▁
📊 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) 507ms 507ms ▁▁▁▁▁
Cold (Ready in log) 489ms 489ms ▁▁▂▁▁
Cold (First Request) 957ms 997ms ▃▁▃▂▁
Warm (Listen) 507ms 508ms ▁▁▁▁▂
Warm (Ready in log) 486ms 486ms ▁▁▁▁▁
Warm (First Request) 372ms 381ms ▁▂▁▁▂
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 456ms 456ms ▁▁▁▁▁
Cold (Ready in log) 440ms 439ms ▁▁▁▁▁
Cold (First Request) 1.832s 1.841s ▁▁▁▁▁
Warm (Listen) 455ms 455ms ▁▁▁▁▁
Warm (Ready in log) 440ms 440ms ▁▁▁▁▁
Warm (First Request) 1.842s 1.867s ▁▁▁▁▁

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 4.966s 4.993s ▁▁▁▁▂
Cached Build 4.956s 4.967s ▁▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 14.049s 14.121s ▃▄▂▂▂
Cached Build 14.173s 14.200s ▃▄▄▂▂
node_modules Size 460 MB 460 MB 🔴 +718 kB (+0%) ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **432 kB** → **432 kB** ✅ -53 B

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

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 764 B 765 B
Total 764 B 765 B ⚠️ +1 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 451 B 449 B
Total 451 B 449 B ✅ -2 B

📦 Webpack

Client

Main Bundles
Canary PR Change
2086.HASH.js gzip 169 B N/A -
2161-HASH.js gzip 5.41 kB N/A -
2747-HASH.js gzip 4.48 kB N/A -
4322-HASH.js gzip 52.7 kB N/A -
ec793fe8-HASH.js gzip 62.3 kB N/A -
framework-HASH.js gzip 59.8 kB 59.8 kB
main-app-HASH.js gzip 252 B 254 B
main-HASH.js gzip 38.7 kB 39.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
1596.HASH.js gzip N/A 169 B -
2658-HASH.js gzip N/A 52.4 kB -
6349-HASH.js gzip N/A 4.46 kB -
7019-HASH.js gzip N/A 5.43 kB -
b17a3386-HASH.js gzip N/A 62.3 kB -
Total 226 kB 226 kB ⚠️ +54 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 193 B
_error-HASH.js gzip 182 B 182 B
css-HASH.js gzip 336 B 335 B
dynamic-HASH.js gzip 1.8 kB 1.8 kB
edge-ssr-HASH.js gzip 256 B 256 B
head-HASH.js gzip 352 B 349 B
hooks-HASH.js gzip 385 B 384 B
image-HASH.js gzip 580 B 580 B
index-HASH.js gzip 259 B 258 B
link-HASH.js gzip 2.5 kB 2.51 kB
routerDirect..HASH.js gzip 319 B 317 B
script-HASH.js gzip 385 B 386 B
withRouter-HASH.js gzip 316 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.97 kB 7.96 kB ✅ -9 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 125 kB 126 kB
page.js gzip 243 kB 240 kB 🟢 3.75 kB (-2%)
Total 368 kB 366 kB ✅ -2.53 kB
Middleware
Canary PR Change
middleware-b..fest.js gzip 617 B 619 B
middleware-r..fest.js gzip 155 B 156 B
middleware.js gzip 33.1 kB 33.3 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 34.7 kB 35 kB ⚠️ +223 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 738 B 739 B
Total 738 B 739 B ⚠️ +1 B
Build Cache
Canary PR Change
0.pack gzip 3.68 MB 3.72 MB 🔴 +39.4 kB (+1%)
index.pack gzip 99.8 kB 100 kB
index.pack.old gzip 102 kB 101 kB
Total 3.88 MB 3.92 MB ⚠️ +39.2 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 305 kB 306 kB
app-page-exp..prod.js gzip 162 kB 163 kB
app-page-tur...dev.js gzip 305 kB 306 kB
app-page-tur..prod.js gzip 162 kB 163 kB
app-page-tur...dev.js gzip 302 kB 302 kB
app-page-tur..prod.js gzip 160 kB 161 kB
app-page.run...dev.js gzip 302 kB 303 kB
app-page.run..prod.js gzip 160 kB 161 kB
app-route-ex...dev.js gzip 68.8 kB 69.4 kB
app-route-ex..prod.js gzip 47.6 kB 48.2 kB 🔴 +535 B (+1%)
app-route-tu...dev.js gzip 68.8 kB 69.4 kB
app-route-tu..prod.js gzip 47.6 kB 48.2 kB 🔴 +534 B (+1%)
app-route-tu...dev.js gzip 68.4 kB 69 kB
app-route-tu..prod.js gzip 47.4 kB 47.9 kB 🔴 +536 B (+1%)
app-route.ru...dev.js gzip 68.4 kB 68.9 kB
app-route.ru..prod.js gzip 47.4 kB 47.9 kB 🔴 +536 B (+1%)
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 41.2 kB 42.4 kB 🔴 +1.26 kB (+3%)
pages-api-tu..prod.js gzip 31.3 kB 32.2 kB 🔴 +988 B (+3%)
pages-api.ru...dev.js gzip 41.1 kB 42.4 kB 🔴 +1.26 kB (+3%)
pages-api.ru..prod.js gzip 31.2 kB 32.2 kB 🔴 +988 B (+3%)
pages-turbo....dev.js gzip 50.8 kB 51.7 kB 🔴 +891 B (+2%)
pages-turbo...prod.js gzip 38.2 kB 38.8 kB 🔴 +542 B (+1%)
pages.runtim...dev.js gzip 50.8 kB 51.7 kB 🔴 +894 B (+2%)
pages.runtim..prod.js gzip 38.2 kB 38.8 kB 🔴 +541 B (+1%)
server.runti..prod.js gzip 62.2 kB 62.3 kB
Total 2.71 MB 2.73 MB ⚠️ +15.7 kB
📝 Changed Files (25 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route.runtime.dev.js
  • app-route.ru..time.prod.js
  • pages-api-tu..ntime.dev.js
  • pages-api-tu..time.prod.js
  • pages-api.runtime.dev.js
  • pages-api.ru..time.prod.js
  • ... and 5 more
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js

Diff too large to display

app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route.runtime.dev.js

Diff too large to display

app-route.ru..time.prod.js

Diff too large to display

pages-api-tu..ntime.dev.js

Diff too large to display

pages-api-tu..time.prod.js

Diff too large to display

pages-api.runtime.dev.js

Diff too large to display

pages-api.ru..time.prod.js

Diff too large to display

pages-turbo...ntime.dev.js

Diff too large to display

pages-turbo...time.prod.js

Diff too large to display

pages.runtime.dev.js

Diff too large to display

pages.runtime.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

@wyattjoh wyattjoh force-pushed the wyattjoh/response-cache-lru branch from d9faef8 to 79e5ce4 Compare January 14, 2026 07:01
@wyattjoh wyattjoh marked this pull request as draft January 15, 2026 01:20
@wyattjoh wyattjoh force-pushed the wyattjoh/response-cache-lru branch 3 times, most recently from 0a9f405 to 1bca000 Compare January 16, 2026 20:58
@nextjs-bot nextjs-bot added the Turbopack Related to Turbopack with Next.js. label Jan 18, 2026
@wyattjoh wyattjoh force-pushed the wyattjoh/response-cache-lru branch from ed2b3fc to 92c785d Compare January 18, 2026 20:27
@codspeed-hq
Copy link

codspeed-hq bot commented Jan 18, 2026

Merging this PR will not alter performance

✅ 17 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing wyattjoh/response-cache-lru (ed2b3fc) with canary (633e274)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@wyattjoh wyattjoh force-pushed the wyattjoh/response-cache-lru branch 2 times, most recently from b357e62 to e09d56b Compare January 20, 2026 04:38
@wyattjoh wyattjoh requested a review from ztanner January 20, 2026 19:40
@wyattjoh wyattjoh force-pushed the wyattjoh/response-cache-lru branch from a3acfbc to cecc2da Compare January 20, 2026 19:45
@wyattjoh wyattjoh marked this pull request as ready for review January 20, 2026 19:46
@wyattjoh wyattjoh requested a review from ztanner January 20, 2026 19:46
@wyattjoh wyattjoh changed the title Add LRU cache for minimal mode response cache feat: two-level LRU cache for minimal mode response cache Jan 21, 2026
@wyattjoh wyattjoh requested a review from ztanner January 21, 2026 19:34
@wyattjoh wyattjoh requested a review from ztanner January 21, 2026 23:00
@wyattjoh wyattjoh changed the title feat: two-level LRU cache for minimal mode response cache feat: implement LRU cache with invocation ID scoping for minimal mode response cache Jan 22, 2026
Replace single-item cache with 50-entry LRU cache in ResponseCache for
minimal mode. Uses actual revalidate TTL instead of hardcoded 1 second,
and properly handles expired entries.
- Add `maxResponseCacheSize` experimental config option to control LRU cache size
- Scope in-memory cache entries by `x-vercel-id` header (invocationID) to ensure
  request isolation in minimal mode
- Add `infra` test utilities for testing cache deduplication with grouped requests
- Update standalone mode tests to use new infra utilities for proper test isolation
With invocation ID scoping, TTL-based expiration is unnecessary since
entries expiring mid-revalidate makes no sense. The LRU cache now handles
eviction purely based on capacity.
Add logging when cache entries are evicted and the same invocation ID
is used again, recommending users increase maxResponseCacheSize.

- Add onEvict callback parameter to LRU cache
- Track evicted invocation IDs in ResponseCache (bounded to 100)
- Warn once per invocation when eviction detected
- Add unit tests for onEvict callback
Add time-based expiration (10s) as fallback when invocationID is undefined.
This handles non-Vercel providers that don't send x-vercel-id header yet.

Changes:
- Add expiresAt field to cache entries for TTL-based expiration
- Use invocationID scoping on Vercel, TTL fallback on other providers
- Increase default maxResponseCacheSize from 5 to 10
- Add JSDoc clarifying remove() doesn't trigger onEvict callback
- Rename header from vendor-specific x-vercel-id to generic x-invocation-id
- Extract FALLBACK_TTL_MS constant with documentation
- Increase default LRU cache size from 5 to 10
- Update all tests to use new header name
- Add peek() method to LRU cache for read-only checks without affecting eviction order
- Set TTL expiration for ALL cache entries (not just those without invocationID)
- Schedule automatic eviction via setTimeout after TTL elapses to reduce memory pressure
- Add experimental.responseCacheTTL config option (default: 10000ms)
- Remove Vercel-specific language from code comments
Replace setTimeout-based proactive eviction with lazy LRU eviction.
Memory is already bounded by maxSize, so timer-based cleanup was
redundant and could accumulate thousands of pending timers in
high-throughput scenarios.

TTL-based cache hit validation is preserved via expiresAt checks.
The peek() method was only used by the timer-based proactive eviction
which was removed in the previous commit.
- Fix typo in request-meta.ts ("it's" → "its")
- Expand FIFO eviction comment with detailed rationale
Ensures user configuration (maxResponseCacheSize, responseCacheTTL) is
always respected by requiring nextConfig at all call sites.
Removes "(standalone deployments)" from config comments since minimal
mode is not equivalent to standalone deployments.
Replace single-level LRU cache with a two-level structure:
- Outer level: keyed by pathname (configurable via minimalModeResponseCacheMaxPaths)
- Inner level: keyed by invocationID (configurable via minimalModeResponseCacheMaxInvocations)

This allows multiple concurrent invocations to cache the same pathname
without overwriting each other's entries, improving cache hit rates
during parallel revalidation scenarios.

Config changes:
- Rename maxResponseCacheSize to minimalModeResponseCacheMaxPaths
- Add minimalModeResponseCacheMaxInvocations (default: 5)

Also extract helper methods to reduce code duplication:
- trackEvictedInvocation(): handles FIFO-bounded eviction tracking
- removeInvocationEntry(): removes invocation and cleans up empty paths
Move hardcoded default values to named constants at module scope:
- DEFAULT_MAX_PATHS (30)
- DEFAULT_MAX_INVOCATIONS_PER_PATH (5)

Consistent with existing DEFAULT_TTL_MS pattern.
Replace experimental config options with environment variables:
- NEXT_PRIVATE_RESPONSE_CACHE_TTL (default: 10000)
- NEXT_PRIVATE_RESPONSE_CACHE_MAX_PATHS (default: 30)
- NEXT_PRIVATE_RESPONSE_CACHE_MAX_INVOCATIONS (default: 5)

This simplifies the API by removing the nextConfig parameter from
getResponseCache() and reading configuration at module load time.
Replace the two-level LRU cache (pathname → invocationID) with a single
LRU cache using compound keys (pathname + null byte + invocationID).

This reduces code complexity while maintaining the same functionality:
- ~50 fewer lines of code
- Single NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE env var (default 150)
- Simpler eviction tracking via compound key parsing
…lper

Replace the `infra` namespace in test utilities with a more explicit
`withInvocationId()` helper per PR feedback. Tests should be very
explicit about when they're passing an x-invocation-id header.

- Add `withInvocationId(opts?)` helper that creates RequestInit with
  unique x-invocation-id header
- Migrate 184 usages across 5 test files to use the explicit helper
- For grouped requests (cache deduplication), use shared opts variable
- Remove createInfraGroup() and infra namespace exports
@wyattjoh wyattjoh force-pushed the wyattjoh/response-cache-lru branch from 49df06b to b07a6d3 Compare January 22, 2026 07:31
- Add parsePositiveInt() helper to validate environment variables,
  falling back to defaults for invalid values (NaN, non-positive)
- Change TTL_SENTINEL to use null byte prefix, preventing collision
  with real invocation IDs since HTTP headers cannot contain null bytes
@wyattjoh wyattjoh merged commit 092458f into canary Jan 22, 2026
160 checks passed
@wyattjoh wyattjoh deleted the wyattjoh/response-cache-lru branch January 22, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

created-by: Next.js team PRs by the Next.js team. tests Turbopack Related to Turbopack with Next.js. type: next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants