Skip to content

fix: detect dynamic API usage in route handlers to prevent stale ISR cache hits#613

Closed
southpolesteve wants to merge 2 commits intomainfrom
fix/route-handler-dynamic-detection-v2
Closed

fix: detect dynamic API usage in route handlers to prevent stale ISR cache hits#613
southpolesteve wants to merge 2 commits intomainfrom
fix/route-handler-dynamic-detection-v2

Conversation

@southpolesteve
Copy link
Collaborator

Summary

Route handlers that access request.headers or request.nextUrl.searchParams are dynamic and should not be served from the ISR cache. This PR adds runtime dynamic detection matching Next.js behavior.

Changes

  1. __proxyRouteRequest: Wraps the Request passed to route handlers in a Proxy. Accessing .headers or .nextUrl.searchParams calls markDynamicUsage(). Methods are bound to the original target to preserve Web API internal slots (.json(), .text(), etc.).

  2. __dynamicRouteHandlers: A module-level Set that remembers route patterns whose handlers used dynamic APIs. The ISR cache read condition checks this set and skips the cache for known-dynamic handlers.

  3. dynamicUsageCount: Replaces the boolean dynamicUsageDetected flag with a counter, enabling future counter-based bracketing of handler execution.

Why not query-string-in-cache-key?

James pointed out that including query strings in the cache key diverges from Next.js, which uses pathname-only keys (source). In Next.js, handlers that read searchParams are detected as dynamic and never cached at all. This PR matches that behavior.

Proxy granularity

Access Dynamic? Reason
request.method No Same for all ISR requests
request.url (string) No Can't track downstream usage
request.nextUrl.pathname No Pathname is the route
request.nextUrl.searchParams Yes Varies per request
request.headers Yes Varies per request

The one gap (same as Next.js): new URL(request.url).searchParams bypasses the Proxy.

…cache hits

Route handlers that access request.headers or request.nextUrl.searchParams
are dynamic and should not be served from the ISR cache. Previously, the
ISR cache had no mechanism to skip the cache read for handlers that use
dynamic APIs, which could serve one user's cached response to another.

Changes:

1. __proxyRouteRequest: wraps the Request passed to route handlers in a
   Proxy. Accessing .headers or .nextUrl.searchParams calls
   markDynamicUsage(). Methods are bound to the original target to
   preserve Web API internal slots (.json(), .text(), etc.).

2. __dynamicRouteHandlers: a module-level Set that remembers route
   patterns whose handlers used dynamic APIs. The ISR cache read
   condition checks this set and skips the cache for known-dynamic
   handlers.

3. dynamicUsageCount: replaces the boolean dynamicUsageDetected flag
   with a counter, enabling future counter-based bracketing of handler
   execution. consumeDynamicUsage() now checks count > 0 and resets
   to 0.

This matches Next.js behavior where dynamic route handlers are never
cached. Static handlers continue to use pathname-only cache keys.
@southpolesteve
Copy link
Collaborator Author

/bigbonk review this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 20, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@613

commit: e0b9bee

@github-actions
Copy link

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good approach overall. The proxy-based dynamic detection matches Next.js semantics well, and the internal-slots-aware method binding is a nice touch. A few observations:

Unused export: getDynamicUsageCount()

getDynamicUsageCount() is exported from headers.ts but nothing in this PR (or the codebase) calls it. The PR description mentions "enabling future counter-based bracketing" — if there's no immediate consumer, consider removing it to avoid dead code. It's trivial to add back when needed.

__dynamicRouteHandlers is never cleared

The Set grows monotonically for the process lifetime. Once a handler is marked dynamic, it stays dynamic forever. This is correct for handlers that always read headers/searchParams, but consider an edge case: a handler that conditionally reads request.headers only for certain request patterns. After one dynamic hit, it's permanently evicted from cache.

In practice this matches Next.js (which determines dynamism statically at build time, so conditional access doesn't matter). But it's worth a comment on the Set noting this is intentional.

Missing test for proxy-based detection

The existing test at tests/app-router.test.ts:1726 covers headers() (the shim import) preventing caching, but there's no test that verifies a route handler reading request.headers directly (without importing from next/headers) is detected as dynamic. This is the core new behavior — a dedicated test fixture with a handler like export async function GET(request) { const auth = request.headers.get('authorization'); ... } would give confidence the proxy works end-to-end.

Similarly, no test covers request.nextUrl.searchParams triggering dynamic detection.

request.cookies is not trapped

The PR description mentions tracking "headers, cookies, searchParams" but the proxy only intercepts .headers and .nextUrl.searchParams. Standard Request objects don't have .cookies, so this is fine as long as vinext passes a standard Request (not NextRequest) to route handlers — which it does (line 2386 passes the original request through the proxy). The cookies() shim from next/headers already calls markDynamicUsage() separately. No action needed, just noting for completeness.

}

export function getDynamicUsageCount(): number {
return _getState().dynamicUsageCount;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is exported but has no callers in the codebase. If the counter-based bracketing pattern isn't landing in this PR, consider deferring this export to avoid dead code.

// dynamic usage. On subsequent requests, we skip the cache read for handlers
// in this set, matching Next.js behavior where dynamic handlers are never cached.
/** @type {Set<string>} */
const __dynamicRouteHandlers = new Set();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Set is append-only and never cleared. Worth adding a brief comment noting this is intentional (matches Next.js static analysis behavior) — if a handler ever accesses dynamic APIs, it's permanently marked dynamic for the process lifetime.

Edge case to consider: a handler that conditionally reads request.headers (e.g., only for authenticated requests) will be permanently evicted from cache after its first dynamic hit, even if subsequent requests wouldn't access headers. This matches Next.js but is a subtle behavioral contract.

let _nextUrl;
return new Proxy(req, {
get(target, prop, receiver) {
if (prop === "headers") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: receiver is declared but never used. Could simplify to get(target, prop) since Reflect.get(target, prop) on line 533 doesn't pass receiver either (intentionally, to avoid the Proxy-as-receiver problem with internal slots).

return new Proxy(req, {
get(target, prop, receiver) {
if (prop === "headers") {
markDynamic();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every .headers access increments the counter. In a handler that reads headers in a loop or accesses it multiple times (common pattern: request.headers.get('auth') + request.headers.get('content-type')), this fires multiple times. Functionally harmless since consumeDynamicUsage() checks > 0, but if the counter is intended for future bracketing purposes, the multiple increments from a single "conceptual" dynamic access could be surprising. A markDynamic-once pattern (boolean guard in the closure) would be more precise:

let _marked = false;
const markOnce = () => { if (!_marked) { _marked = true; markDynamic(); } };

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

Review posted successfully on PR #613. Here's a summary of the feedback:

Overall: The proxy-based dynamic detection is a solid approach that correctly matches Next.js semantics. The internal-slots-aware method binding on the Request proxy is well thought out.

Key feedback points:

  1. Unused getDynamicUsageCount() export — exported from headers.ts but nothing calls it. Should be deferred until there's a consumer.

  2. __dynamicRouteHandlers Set is never cleared — append-only for the process lifetime. Matches Next.js (which determines dynamism statically), but a handler that conditionally reads request.headers gets permanently evicted from cache after one dynamic hit. Worth a comment noting this is intentional.

  3. Missing tests for proxy-based detection — the existing test at app-router.test.ts:1726 tests the headers() shim import, but there's no test verifying that reading request.headers directly (without next/headers) triggers dynamic detection via the new proxy. This is the core new behavior and needs test coverage. Same gap for request.nextUrl.searchParams.

  4. Minor nits — unused receiver parameter in the Proxy get trap; markDynamic() fires on every .headers access (harmless but a mark-once pattern would be more precise for future counter-based bracketing).

github run

@southpolesteve
Copy link
Collaborator Author

Superseded by v3 branch with minimal diff (no counter rename).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant