Skip to content

docs: Fix the documented contract for custom cache handlers - #98039

Open
unstubbable wants to merge 1 commit into
canaryfrom
hl/fix-cache-handler-docs-and-types
Open

docs: Fix the documented contract for custom cache handlers#98039
unstubbable wants to merge 1 commit into
canaryfrom
hl/fix-cache-handler-docs-and-types

Conversation

@unstubbable

Copy link
Copy Markdown
Contributor

The documented in-memory handler stored the resolved entry and returned it from every get. A stored stream keeps the request that produced the entry reachable. The second cache hit also fails, because it reads a stream that another reader already consumed. The render then reports "Invalid state: ReadableStream is locked".

The "Handling Streams" section described a second, milder mistake. It told a handler to tee the stored stream in get. One branch goes back into the store, and the other serves the current read. That pattern serves every hit correctly. The built-in handler used it until #97941, so the page described the framework's own implementation. The stored branch retains the same request, and it also holds the source stream and its buffered chunks. The size a store accounts for covers none of that. The page now states that cost, because a reader who thinks of teeing would otherwise read the rule as inapplicable.

get told a handler to drop an entry once it is past revalidate. Next.js compares timestamp against expire on every read, and it treats a too-old entry as a miss. It applies the same check to revalidate when the result goes into another server cache. A handler therefore needs no age check at all. The built-in handler still drops at revalidate, as a deliberate policy for an in-memory cache, and the page now presents that as a policy instead of a rule.

Something still has to reclaim entries, so the page states that eviction belongs to the handler. Next.js never deletes from it. An age check at serve time is no substitute, because it only reaches the keys that a reader still asks for.

The page now follows an order in which no rule appears after the code that depends on it. The stream rules sit in one section ahead of the API reference. CacheEntry Type moves above the methods that reference it. set precedes get, because a handler writes an entry before it reads one. The tag and error sections move above the examples.

types.ts described refreshTags as periodic, and the page described it as a call before each request. Neither is accurate. It runs once per request, before the first cache read for its kind. A request that reads nothing from a handler never calls it.

Neither types.ts nor the page documented the revalidate: -1 signal, which the built-in handler relies on. A negative value always lies in the past, so it serves an entry with a stale tag one more time while Next.js generates a fresh one.

types.ts now states these rules as well, so a handler author no longer has to read the built-in implementation to find them. It also covers the pendingEntry promise, which a handler must not retain either.

streamFromBuffer now records why it has to stay a default stream. A byte stream transfers the buffer of every chunk it receives. That would detach the buffer an in-memory handler serves many reads from. The comment in tiered-cache-handler.ts about cancelling a teed stream now says that the backing handler is user-configured, because the built-in one no longer returns such a branch.

The handler test from #97941 now records a limit of its own. It only fails on Node 20 and 22, whose AsyncLocalStorage attaches the active store to every promise. CI runs Node 20.9, so a regression fails there. A local run on a newer Node passes.

Nothing in this change affects runtime behavior.

The documented in-memory handler stored the resolved entry and returned
it from every `get`. A stored stream keeps the request that produced the
entry reachable. The second cache hit also fails, because it reads a
stream that another reader already consumed. The render then reports
"Invalid state: ReadableStream is locked".

The "Handling Streams" section described a second, milder mistake. It
told a handler to tee the stored stream in `get`. One branch goes back
into the store, and the other serves the current read. That pattern
serves every hit correctly. The built-in handler used it until #97941,
so the page described the framework's own implementation. The stored
branch retains the same request, and it also holds the source stream and
its buffered chunks. The size a store accounts for covers none of that.
The page now states that cost, because a reader who thinks of teeing
would otherwise read the rule as inapplicable.

`get` told a handler to drop an entry once it is past `revalidate`.
Next.js compares `timestamp` against `expire` on every read, and it
treats a too-old entry as a miss. It applies the same check to
`revalidate` when the result goes into another server cache. A handler
therefore needs no age check at all. The built-in handler still drops at
`revalidate`, as a deliberate policy for an in-memory cache, and the
page now presents that as a policy instead of a rule.

Something still has to reclaim entries, so the page states that eviction
belongs to the handler. Next.js never deletes from it. An age check at
serve time is no substitute, because it only reaches the keys that a
reader still asks for.

The page now follows an order in which no rule appears after the code
that depends on it. The stream rules sit in one section ahead of the API
reference. `CacheEntry Type` moves above the methods that reference it.
`set` precedes `get`, because a handler writes an entry before it reads
one. The tag and error sections move above the examples.

`types.ts` described `refreshTags` as periodic, and the page described
it as a call before each request. Neither is accurate. It runs once per
request, before the first cache read for its kind. A request that reads
nothing from a handler never calls it.

Neither `types.ts` nor the page documented the `revalidate: -1` signal,
which the built-in handler relies on. A negative value always lies in
the past, so it serves an entry with a stale tag one more time while
Next.js generates a fresh one.

`types.ts` now states these rules as well, so a handler author no longer
has to read the built-in implementation to find them. It also covers the
`pendingEntry` promise, which a handler must not retain either.

`streamFromBuffer` now records why it has to stay a default stream. A
byte stream transfers the buffer of every chunk it receives. That would
detach the buffer an in-memory handler serves many reads from. The
comment in `tiered-cache-handler.ts` about cancelling a teed stream now
says that the backing handler is user-configured, because the built-in
one no longer returns such a branch.

The handler test from #97941 now records a limit of its own. It only
fails on Node 20 and 22, whose `AsyncLocalStorage` attaches the active
store to every promise. CI runs Node 20.9, so a regression fails there.
A local run on a newer Node passes.

Nothing in this change affects runtime behavior.
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 36b4594

@unstubbable
unstubbable marked this pull request as ready for review August 28, 2026 18:38
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