vercel / vercel/next.js

router.prefetch redirect loop

Open
#98,345 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Redirects
Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/skurekjakub/next-rsc-prefetch-redirect-loop

To Reproduce
  1. npm install && npx playwright install chromium
  2. Run npm run repro.
    The script builds the project, launches next start, loads /docs/alpha, clicks the button triggering router.prefetch("/docs/changelog", { kind: "full" }) once, and counts incoming _rsc requests. It exits with code 1 because requests keep firing continuously (hundreds per second) 20 seconds later.
  3. Manual reproduction:
    • Run npm run build && npm start.
    • Open http://localhost:3000/docs/alpha in Chrome.
    • Open DevTools Network tab and filter by _rsc.
    • Click the button triggering router.prefetch("/docs/changelog", { kind: "full" }).
    • Watch the counter and network tab flood with requests (~700 req/sec).
  4. Control check:
    • Run npm run repro:control. This invokes router.prefetch("/docs/changelog") without { kind: "full" }. No loop happens.
Current vs. Expected behavior
Current behavior

Triggering router.prefetch(url, { kind: "full" }) once causes the browser tab to fire _rsc requests endlessly until closed (~700 requests/second on localhost).

Each round consists of:

  1. 308 redirect for /docs/changelog
  2. 200 response for /changelog
  3. /_tree requests for every visible <Link> on the page
router.prefetch("/docs/changelog", { kind: "full" })
_rsc requests: 4 at load, 6,722 after 10 s, 13,534 after 20 s
308 responses for /docs/changelog: 1,696
In-page counter: 11,837

Requests by path & status:
  / 200                   1,698
  /changelog 200          3,393
  /docs/alpha 200         1,696
  /docs/beta 200          1,696
  /docs/changelog 308     1,696
  /docs/delta 200         1,662
  /docs/gamma 200         1,696

LOOP RATE: ~681 requests/second between 10s and 20s after a single prefetch call.

In production on our documentation site (Next 16.3.3, self-hosted on Azure App Service), a single user hovering a sidebar link produced ~190,000 requests (5.8 GB transferred) in 100 minutes. Another session recorded 22,000 requests in 20 minutes. The loop only stopped when the user closed the tab.

Expected behavior

The client router should follow the redirect once, record that the destination has a different structure / is a rewrite, and stop. Prefetch retries must have an upper bound. A normal <Link> prefetch for the same destination finishes after one attempt; imperative router.prefetch should do the same.

Provide environment information
Operating System:
  Platform: linux
  Arch: x64
  Node: 24.18.0
  npm: 12.0.1
Relevant Packages:
  next: 16.3.3 (also reproduced on 16.3.4 and 16.4.0-canary.20)
  react: 19.2.8
  react-dom: 19.2.8
  typescript: 5.9.3
Next.js Config:
  cacheComponents: true
  experimental.partialPrefetching: true
Which area(s) are affected? (Select all that apply)

Redirects

Which stage(s) are affected? (Select all that apply)

Other (Deployed), next start (local)

Additional context
  • Also reproduces on next@16.3.4 (711 req/s) and next@16.4.0-canary.20 (714 req/s).
  • Requires both cacheComponents and partialPrefetching.
  • Only occurs on production builds (next start); next dev does not prefetch like this.
  • Workaround: We added a build gate forbidding sidebar links to redirect sources, and avoided passing { kind: 'full' } to router.prefetch.

Root Cause Analysis

Tracing through packages/next/src/client/components/segment-cache/ in next@16.3.3:

  1. No component lifecycle cancellation:
    router.prefetch tasks are not associated with any <Link> element, so standard cancellation via unmount (links.ts) never fires.

  2. Optimistic route matching guesses the layout:
    When prefetching /docs/changelog, matchKnownRoute in optimistic-routes.ts checks existing patterns, sees it matches [collection]/[...slug], and synthesizes an optimistic route entry based on that pattern instead of requesting a /_tree first.

  3. Tree divergence on redirect:
    With partialPrefetching enabled, fetchSegmentPrefetchesUsingDynamicRequest in cache.ts sends an RSC request with the synthetic tree. The server returns a 308 redirect to /changelog. The browser follows it and receives the /changelog response. The client router detects that the /changelog tree diverged from the guessed [collection]/[...slug] tree.

  4. Self-invalidating cache bump:
    In cache.ts (L3313-L3324):

    • The router calls markRouteEntryAsDynamicRewrite(route).
    • It calls invalidateRouteCacheEntries(), which increments currentRouteCacheVersion. (A comment here says "It can't loop", followed by a TODO noting unbounded retries).
    • It rejects pending segment entries.
  5. The loop closes:

    • In cache-map.ts (isValueExpired) and optimistic-routes.ts (readPattern), anything older than currentRouteCacheVersion is expired.
    • Bumping the version discards the dynamic-rewrite note that was just recorded, along with cached trees for visible links.
    • Visible links immediately re-request their /_tree, which re-learns the base [collection]/[...slug] pattern.
    • The active imperative prefetch task retries, makes the exact same optimistic guess, hits the redirect again, and loops infinitely.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the reproduction repository and run npm run repro, then inspect packages/next/src/client/components/segment-cache/cache.ts, cache-map.ts, optimistic-routes.ts, and links.ts. Trace the full prefetch path for router.prefetch("/docs/changelog", { kind: "full" }) and compare it with the control case. Done means the redirect is handled without an unbounded _rsc request loop while normal prefetch behavior remains intact.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nextjs, playwright, react
Domain
frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
50/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.