Skip to content

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

Open
southpolesteve wants to merge 3 commits intomainfrom
fix/route-handler-dynamic-detection-v3
Open

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

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.

Minimal diff: only changes app-rsc-entry.ts (and snapshots). No changes to headers.ts or the dynamic usage flag type.

Changes

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

  2. __dynamicRouteHandlers: A Set that remembers route patterns whose handlers used dynamic APIs. The ISR cache read skips known-dynamic handlers. The Set grows monotonically for the process lifetime (intentional, matches Next.js which determines dynamism statically).

  3. Cache read guard: !__dynamicRouteHandlers.has(handler.pattern) added to the ISR cache read condition.

  4. Background regen: Synthetic request is also wrapped in the Proxy, and dynamic detection adds to the Set.

@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@614

commit: 60b524f

@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

…cache hits

Route handlers that access request.headers or request.nextUrl.searchParams
are dynamic and should not be served from the ISR cache.

Adds __proxyRouteRequest (Proxy wrapper with method binding for Web API
internal slots), __dynamicRouteHandlers (Set tracking known-dynamic
patterns), cache read guard, and dynamic set addition in both primary
and background regen paths.

No changes to headers.ts or the dynamic usage flag type. The counter
rename is deferred to a separate PR.
@southpolesteve southpolesteve force-pushed the fix/route-handler-dynamic-detection-v3 branch from 8e1bb5b to 43c65ae Compare March 20, 2026 21:26
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.

Review: Dynamic API Detection for Route Handlers

The approach is sound — runtime dynamic detection via a request Proxy plus a monotonically-growing Set to remember known-dynamic handlers is a reasonable way to prevent stale ISR cache hits. The implementation covers the core flow correctly: primary handler execution, background regeneration, and the cache-read guard all integrate consistently.

However, there are several issues ranging from a significant coverage gap to minor correctness concerns.

1. Incomplete dynamic signal coverage (high severity)

Next.js's proxyNextRequest() in packages/next/src/server/route-modules/app-route/module.ts detects significantly more properties as dynamic signals. The current proxy only detects request.headers and request.nextUrl.searchParams. Missing:

On the request object:

  • request.cookies — Very common in route handlers. A handler doing request.cookies.get('token') would be silently cached as static.
  • request.url — Extremely common. new URL(request.url) to read query params is the standard Web API pattern and is already used in test fixtures (tests/fixtures/app-basic/app/nextjs-compat/api/query/route.ts). This would be incorrectly cached.
  • request.body, request.blob(), request.json(), request.text(), request.arrayBuffer(), request.formData() — Less critical since these are typically POST-only (and non-GET is already excluded from ISR), but should be tracked for completeness.

On nextUrl:

  • search, href, url, toString(), toJSON(), origin — Next.js marks all of these as dynamic on the nextUrl proxy.

The cookies and url gaps are the most impactful — these are common patterns that would result in user-facing bugs (stale data served from cache).

2. First-request cache-poisoning window (medium severity)

The __dynamicRouteHandlers Set is empty on cold start. The first request to a route handler with revalidate > 0 will attempt the ISR cache read before the handler executes and can signal dynamism. If a previous deployment (or a previous process that handled this route statically) wrote a cache entry, the first request will serve stale cached data before the handler ever runs to detect dynamic usage.

This is inherent to the "learn at runtime" approach and is acknowledged in the PR description ("grows monotonically... matches Next.js which determines dynamism statically"). But it's worth noting that Next.js actually determines this statically at build time — it doesn't have this cold-start window. A comment in the code explaining this tradeoff and why it's acceptable would be helpful.

3. Proxy receiver parameter is accepted but unused (low severity)

get(target, prop, receiver) {

The receiver is accepted but never passed to Reflect.get. For most Request properties this is fine (they're own data properties), but for consistency with the fallback path:

const value = Reflect.get(target, prop);

This should be Reflect.get(target, prop, receiver) to correctly handle getters on the prototype chain. However, since Request uses internal slots and the .bind(target) pattern already handles methods, this is low risk.

4. Error reporting accesses request.headers on the original request (nit)

At line 2494:

{ path: cleanPathname, method: request.method, headers: Object.fromEntries(request.headers.entries()) },

This accesses request.headers on the original request (not __proxiedRequest), which is fine since it's in the catch block and dynamic detection has already been consumed. But it creates an inconsistency — if someone later refactors this to use the proxied request, it would spuriously mark the handler as dynamic on every error. A comment or using the original request explicitly would clarify intent.

5. nextUrl creates a new URL object, not an actual NextURL

const realUrl = new URL(target.url);

This creates a plain URL, not a NextURL instance. Next.js's NextRequest.nextUrl returns a NextURL which has additional properties like basePath, locale, etc. If a route handler accesses request.nextUrl.basePath, it will get undefined instead of the correct value. This may be a pre-existing limitation, but the proxy is a good place to note it.

Summary

The core mechanism (Proxy + Set + cache guard) is well-designed and the integration across primary execution, background regen, and cache reads is consistent. The main issue is the incomplete property coverage — request.cookies and request.url are very common patterns that would bypass dynamic detection and serve stale cache data. I'd recommend expanding the proxy to cover at minimum cookies, url, and the additional nextUrl properties before merging.

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.

Next.js's proxyNextRequest() also detects cookies, url, body, blob, json, text, arrayBuffer, and formData on the request object. The most impactful gaps here are cookies and url:

  • request.cookies.get('token') is a very common pattern in route handlers
  • new URL(request.url) is the standard Web API way to read query params (and is used in your own test fixtures at tests/fixtures/app-basic/app/nextjs-compat/api/query/route.ts)

Both of these would silently bypass dynamic detection and serve stale cached data.

_nextUrl = new Proxy(realUrl, {
get(urlTarget, urlProp) {
if (urlProp === "searchParams") markDynamic();
const val = Reflect.get(urlTarget, urlProp);
Copy link
Contributor

Choose a reason for hiding this comment

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

Next.js also marks these nextUrl properties as dynamic: search, href, url, toString(), toJSON(), origin. Only detecting searchParams means a handler doing request.nextUrl.search or request.nextUrl.href would miss detection.


/** @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.

Worth adding a comment explaining the cold-start tradeoff: this Set is empty on process start, so the first request can still serve a stale cache entry written by a previous process/deployment before the handler runs and detects dynamism. Next.js avoids this by determining dynamism statically at build time. This is an acceptable tradeoff for vinext's runtime approach, but documenting it prevents future confusion.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

The review has been posted on PR #614. Here's a summary of the feedback:

REQUEST_CHANGES with 5 points:

  1. Incomplete dynamic signal coverage (high) — The proxy only detects request.headers and request.nextUrl.searchParams, but Next.js's proxyNextRequest() detects many more properties. The most impactful gaps are request.cookies and request.url — both extremely common patterns that would silently bypass dynamic detection and serve stale cached data.

  2. First-request cache-poisoning window (medium) — The __dynamicRouteHandlers Set starts empty on cold start. Unlike Next.js which determines dynamism statically at build time, this runtime approach has a window where the first request can serve stale cached data before the handler runs and signals dynamism. Should be documented.

  3. Proxy receiver parameter unused (low)Reflect.get(target, prop) should pass receiver for correctness with prototype getters, though low risk in practice.

  4. Error reporting accesses original request.headers (nit) — Line 2494 uses the original request rather than __proxiedRequest in the catch block. Fine now but could cause confusion during refactoring.

  5. nextUrl creates plain URL, not NextURL (nit) — Missing basePath, locale etc. properties that NextURL provides.

Three inline comments were also posted on the specific code locations for items 1-3.

github run

consumeDynamicUsage() picks up flags set by middleware and headers
context setup. Without clearing before the handler, static handlers
are incorrectly marked dynamic on first request, poisoning the
__dynamicRouteHandlers set and preventing cache reads on all
subsequent requests.
@southpolesteve
Copy link
Collaborator Author

/bigbonk review this PR. This is the clean v3 with minimal diff (only app-rsc-entry.ts changes, no headers.ts rename). Added consumeDynamicUsage() clearing before handler execution to prevent pipeline contamination.

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.

Review

The core design is good — Proxy-based detection, a monotonic Set for memoizing dynamism, and consistent application across primary execution and background regen. The consumeDynamicUsage() call before handler execution (second commit) is a necessary fix that correctly isolates handler-level signals from pipeline noise.

However, there's a significant gap in request.url detection that will cause real-world bugs, and a couple of things worth addressing.

1. Missing request.url detection (high — will cause stale cache bugs)

The proxy intercepts request.headers and request.nextUrl.searchParams, but not request.url. The standard Web API pattern for reading query params in route handlers is:

const url = new URL(request.url);
const q = url.searchParams.get("q");

This is extremely common — it's how route handlers work with standard Request objects (not NextRequest). Your own test fixture at tests/fixtures/app-basic/app/nextjs-compat/api/query/route.ts uses exactly this pattern:

export async function GET(request: Request) {
  const url = new URL(request.url);
  const ping = url.searchParams.get("ping");
  return Response.json({ ping });
}

If this handler had export const revalidate = 60, it would be incorrectly cached as static because accessing request.url doesn't trigger markDynamic().

Note on request.cookies: The previous review flagged this, but I disagree that it's a practical gap here. The request passed to route handlers is a plain Request, not a NextRequest — it doesn't have a .cookies property. Route handlers that need cookies use either cookies() from next/headers (which already calls markDynamicUsage() internally) or request.headers.get('cookie') (which is already detected by the headers trap). So cookies detection via the proxy isn't needed for the plain Request case.

request.url is the one that needs to be added. In Next.js, accessing request.url on a route handler's request is a dynamic signal. Add it to the proxy alongside headers:

if (prop === "url") {
  markDynamic();
  return target.url;
}

2. Cold-start cache-poisoning window needs a code comment

The PR description acknowledges that __dynamicRouteHandlers starts empty and "grows monotonically." Next.js avoids this by determining dynamism statically at build time. With the runtime approach, the first request after a cold start can serve a stale cache entry written by a previous deployment before the handler runs and detects dynamism.

This is an acceptable tradeoff, but it should be documented in the code (not just the PR description) so future maintainers understand it. A brief comment above the Set declaration would suffice:

// NOTE: This Set is empty on cold start. The first request may serve a stale
// ISR cache entry before the handler runs and signals dynamism. Next.js avoids
// this by determining dynamism statically at build time; vinext learns it at
// runtime. After the first execution, the pattern is remembered for the process
// lifetime.

3. No dedicated test for the new proxy detection

The existing route handler ISR: dynamic handler (reads headers()) is not cached test exercises dynamic detection via headers() / cookies() from next/headers, which was already working before this PR. There's no test that validates the new proxy-based detection — i.e., a route handler that accesses request.headers directly (not via headers()) with export const revalidate = 60.

Adding a test fixture and test case for the specific scenario this PR fixes would prevent regressions and validate the behavior end-to-end.

Minor notes

  • receiver unused in Reflect.get: Reflect.get(target, prop) at line 522 doesn't pass receiver. Low risk since Request properties are own data properties and methods are already bound, but Reflect.get(target, prop, receiver) is more correct for the general case.

  • Error path uses original request: Line 2497 accesses request.headers on the original (unwrapped) request in the catch block. This is fine — the error path should not influence dynamic detection — but a brief comment would prevent someone from "fixing" it to use __proxiedRequest later.

}
return _nextUrl;
}
const value = Reflect.get(target, prop);
Copy link
Contributor

Choose a reason for hiding this comment

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

request.url should also trigger markDynamic(). This is the most common Web API pattern for reading query params in route handlers (new URL(request.url).searchParams.get(...)). Your own test fixture tests/fixtures/app-basic/app/nextjs-compat/api/query/route.ts uses exactly this pattern. Without this, a handler with revalidate = 60 that reads request.url would be incorrectly served from the ISR cache.

if (prop === "url") {
  markDynamic();
  return target.url;
}

? console.debug.bind(console, "[vinext] ISR:")
: undefined;

/** @type {Set<string>} */
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 empty on cold start, so the first request to a dynamic handler can still serve a stale ISR cache entry written by a previous process/deployment. Next.js avoids this by determining dynamism statically at build time.

This is an acceptable tradeoff for vinext's runtime approach, but please add a code comment here explaining it — the PR description mentions it but that context will be lost after merge.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

The review has been posted on PR #614 with REQUEST_CHANGES status and two inline comments. Here's a summary:

Review posted: REQUEST_CHANGES with 3 main points and 2 minor notes:

  1. Missing request.url detection (high) — The proxy intercepts request.headers and request.nextUrl.searchParams but not request.url, which is the most common Web API pattern for reading query params in route handlers (new URL(request.url).searchParams.get(...)). The project's own test fixture uses this exact pattern. A handler with revalidate = 60 that reads request.url would be incorrectly cached.

  2. Cold-start tradeoff needs a code comment — The __dynamicRouteHandlers Set is empty on cold start. The first request can serve stale cached data before the handler signals dynamism. This is acceptable but should be documented in the code, not just the PR description.

  3. No dedicated test for the new proxy-based detection — existing tests exercise headers()/cookies() from next/headers, not direct request.headers access.

I also pushed back on the previous review's request.cookies concern — route handlers receive a plain Request (not NextRequest), so .cookies doesn't exist on it. Handlers that need cookies use cookies() from next/headers (already detected) or request.headers.get('cookie') (detected by the headers trap).

github run

Use a dedicated __handlerAccessedDynamicApi flag set only by the Proxy
trap, not by markDynamicUsage() from pipeline stages. The Set is only
populated when the handler itself accessed request.headers or
request.nextUrl.searchParams through the proxy. The existing
consumeDynamicUsage() flow for cache write decisions is unchanged
from main.
@southpolesteve
Copy link
Collaborator Author

Responding to bigbonk's request for request.url detection:

This is the known gap documented in the PR description:

The one gap (same as Next.js): new URL(request.url).searchParams bypasses the Proxy since it operates on the raw URL string.

request.url returns a string. Once the handler has the string, we can't track whether they parse it for searchParams or just extract the pathname. Trapping request.url would mark ALL route handlers as dynamic (every handler that checks the URL for any reason), which would disable ISR caching for essentially all route handlers.

Next.js has the same limitation. They solve it at build time (running the handler during prerender) rather than at runtime. We have issue #605 tracking build-time analysis as a follow-up.

The proxy catches the two most common dynamic access patterns:

  1. request.headers (directly reading headers)
  2. request.nextUrl.searchParams (the documented Next.js API for searchParams)

/bigbonk please approve. The request.url gap is intentional and matches Next.js behavior.

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.

Review: Dynamic API Detection for Route Handlers (v3)

The core design is solid. The Proxy-based detection + monotonic Set + cache-read guard is a clean approach, and the third commit's separation of proxy-level detection (__handlerAccessedDynamicApi) from pipeline-level markDynamicUsage() is a good fix that prevents middleware/header-setup from poisoning the Set.

I agree with the second review's pushback on request.cookies — route handlers receive a plain Request, not NextRequest, so there's no .cookies property to trap.

Three issues remain, one of which is a real bug that will cause stale cache hits in production.


1. Missing request.url detection (high — will serve stale data)

Next.js explicitly treats request.url as a dynamic signal in proxyNextRequest() (packages/next/src/server/route-modules/app-route/module.ts:1083). The standard Web API pattern for reading query params in route handlers is:

export const revalidate = 60;
export async function GET(request) {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  return Response.json({ q });
}

This is the most common pattern for reading request-specific data in route handlers — it's how you work with standard Request objects without relying on NextRequest. Without trapping url, this handler would be incorrectly cached by ISR, serving the first request's results for all subsequent queries.

vinext's own test fixture uses this exact pattern (tests/fixtures/app-basic/app/nextjs-compat/api/query/route.ts).

Fix:

if (prop === "url") {
  markDynamic();
  return target.url;
}

For completeness, Next.js also traps body, blob, json, text, arrayBuffer, and formData on the request, and search, href, url, toString, toJSON, origin on nextUrl. These are lower priority since body-reading methods are typically POST-only (already excluded from ISR) and the additional nextUrl properties are less commonly used. But request.url is critical.

2. Cold-start tradeoff needs a code comment (medium)

The __dynamicRouteHandlers Set starts empty on cold start. The first request to a dynamic handler can serve a stale ISR cache entry from a previous deployment before the handler runs and signals dynamism. This is an inherent tradeoff of runtime detection vs Next.js's build-time static analysis.

The PR description documents this well, but that context is lost after merge. A 3-line comment above the Set declaration would save future maintainers from wondering about this behavior. Suggested:

// NOTE: This Set is empty on cold start. The first request may serve a stale
// ISR cache entry before the handler runs and signals dynamism. Next.js avoids
// this by determining dynamism statically at build time; vinext learns it at
// runtime. After the first execution, the pattern is remembered for the process
// lifetime.
const __dynamicRouteHandlers = new Set();

3. No dedicated test for proxy-based detection (medium)

The existing test at tests/app-router.test.ts:1726 ("dynamic handler reads headers()") exercises headers() / cookies() from next/headers, which was already working before this PR. There's no test that validates the new behavior this PR introduces — a route handler accessing request.headers directly (not via the next/headers shim) with export const revalidate = 60 should not be cached.

Adding a test fixture and test case for the specific scenario this PR fixes would prevent regressions and validate the proxy is working end-to-end.


Minor notes

  • receiver unused in outer Reflect.get (line 522): Reflect.get(target, prop) should be Reflect.get(target, prop, receiver) for correctness with prototype getters. Low risk since Request uses internal slots and methods are already bound, but it's a one-character fix.

  • Error path uses original request (line 2498): The catch block accesses request.headers on the unwrapped request, not __proxiedRequest. This is correct — error reporting shouldn't influence dynamic detection — but a brief comment would prevent someone from "fixing" it to use the proxied version later.

}
return _nextUrl;
}
const value = Reflect.get(target, prop);
Copy link
Contributor

Choose a reason for hiding this comment

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

request.url must also trigger markDynamic(). This is the most common Web API pattern for reading query params in route handlers:

const url = new URL(request.url);
const q = url.searchParams.get("q");

Next.js explicitly treats request.url as a dynamic signal in proxyNextRequest(). Without this trap, a handler with revalidate = 60 that reads request.url will be incorrectly cached by ISR — the first response gets served for all subsequent requests regardless of query params.

Your own test fixture at tests/fixtures/app-basic/app/nextjs-compat/api/query/route.ts uses exactly this pattern.

if (prop === "url") {
  markDynamic();
  return target.url;
}

: undefined;

/** @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.

Please add a code comment explaining the cold-start tradeoff. The PR description documents it well, but that context is lost after merge. Suggested:

// NOTE: This Set is empty on cold start. The first request may serve a stale
// ISR cache entry before the handler runs and signals dynamism. Next.js avoids
// this by determining dynamism statically at build time; vinext learns it at
// runtime. After the first execution, the pattern is remembered for the process
// lifetime.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

Review posted on PR #614 with REQUEST_CHANGES status. Here's a summary:

3 main issues found:

  1. Missing request.url detection (high) — The proxy traps request.headers and request.nextUrl.searchParams but not request.url, which Next.js explicitly treats as a dynamic signal. new URL(request.url).searchParams.get(...) is the most common Web API pattern for reading query params in route handlers. Without this trap, handlers with revalidate that read request.url will be incorrectly cached. vinext's own test fixture (tests/fixtures/app-basic/app/nextjs-compat/api/query/route.ts) uses exactly this pattern.

  2. Cold-start tradeoff needs a code comment (medium) — The __dynamicRouteHandlers Set starts empty, so the first request can serve stale data. This is documented in the PR description but will be lost after merge.

  3. No dedicated test for proxy-based detection (medium) — Existing tests exercise headers()/cookies() from next/headers (already working before this PR), but nothing validates the new proxy-based detection of direct request.headers access.

2 minor notes: unused receiver parameter in Reflect.get, and the error path correctly using the unwrapped request (deserves a comment for clarity).

2 inline comments were posted on the specific code locations for issues #1 and #2.

github run

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