Segment Cache: Sibling PPR Route Prefetches Are Permanently Dropped After Same-Tick Batch
Batching router.prefetch calls for multiple slugs of a PPR route in the same tick causes the first-scheduled sibling to be deferred by the 4-request bandwidth cap. When bandwidth frees, the sibling incorrectly resolves against the fallback route entry populated by other completed prefetches instead of fetching its own per-URL segment. Subsequent prefetches become permanent no-ops, forcing full dynamic navigation for the affected slug.
The segment cache scheduler resolves a deferred per-URL prefetch task against a fallback route entry (segmentPath === null) for the same route key, after that fallback entry was populated by sibling prefetches; this bypasses the specific segment fetch and leaves the cache in a state where later explicit prefetches are suppressed.
1. Create a partially-prerendered route /products/[slug] with cacheComponents: true, partialPrefetching: true, and 5 generateStaticParams slugs.
2. Build and start Next.js 16.3.x.
3. In the browser, call router.prefetch for all 5 sibling URLs in the same tick.
4. Observe that only 4 network requests are issued initially, and no request for the first-scheduled slug (alpha) appears even after bandwidth frees.
5. Wait and call router.prefetch('/products/alpha') again; still zero network.
6. Navigate to /products/alpha; content requires a full dynamic round trip instead of using prefetched segment.
Fixing Code Block
function resolveTask(task) {
const cacheEntry = task.cache.get(task.routeKey);
if (!cacheEntry) {
task.needsFetch = true;
return false;
}
// Critical: a fallback entry (segmentPath === null) must not satisfy a task
// waiting on a specific per-URL segment path (segmentPath !== null).
// Sibling PPR responses can populate the fallback entry while this task is
// deferred for network bandwidth; resolving against it drops the fetch and
// makes later prefetches permanent no-ops.
if (task.segmentPath !== null && cacheEntry.segmentPath === null) {
task.needsFetch = true;
return false;
}
if (cacheEntry.isValid(task)) {
task.resolveWithCache(cacheEntry);
return true;
}
task.needsFetch = true;
return false;
}
The scheduler's resolveTask previously accepted a cache entry as long as the route key matched, ignoring the difference between a per-URL segment (segmentPath non-null) and a fallback entry (segmentPath null). The patch inserts a guard before the normal validity check: if the task requests a specific segment path but the cache entry is fallback, the task is forced to issue a network fetch. This ensures the slug-specific tree/page segment is fetched when bandwidth frees, prevents permanent no-op prefetches, and restores the expected behavior.
Edge Case Audit
This patch assumes any cache entry with segmentPath === null is a fallback entry that must not satisfy a per-URL segment task. If a valid per-URL shell ever has a null segmentPath in a future refactor, the guard would bypass cache and increase prefetch traffic. Rollback should be done by reverting to the previous resolveTask; note that client caches from the unfixed version may continue exhibiting no-op prefetches until a full page reload. Test against dynamic routes without generateStaticParams, streaming fallback states, and concurrent prefetches to avoid introducing duplicate fetches or cache thrashing.