Skip to content

fix: run Cache Components staged renders correctly on workerd - #1318

Open
NathanDrake2406 wants to merge 14 commits into
opennextjs:mainfrom
NathanDrake2406:nathan/cache-components-workers
Open

fix: run Cache Components staged renders correctly on workerd#1318
NathanDrake2406 wants to merge 14 commits into
opennextjs:mainfrom
NathanDrake2406:nathan/cache-components-workers

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Overview

Goal Run Next.js Cache Components (cacheComponents: true) on Workers with the same output Node produces.
Core change Replace Next's staged render scheduler with a workerd implementation, keep the module loading CacheSignal request scoped, and let Cache Components routes bypass cache interception.
Key boundary Next's staged renderer depends on Node event loop internals. The adapter owns the translation to workerd, so the adapter supplies the task boundary and the per-request ownership Next assumes.
Expected impact Cache Components apps stop returning empty, truncated, or 500 responses. Runtime prefetch payloads match next start byte for byte. Apps without the flag are unaffected.

Why

Next renders Cache Components as a pipeline of event loop tasks. Between two tasks, every immediate the previous task queued must run, so React flushes that stage before the next stage unblocks more content. A runtime prefetch aborts the render after its final task and discards every chunk that arrives later, so a flush that lands one task late is lost, not delayed.

Next builds that boundary from two Node behaviours:

  1. createAtomicTimerGroup mutates the private _idleStart field of timer handles so all stages share one timer phase.
  2. DANGEROUSLY_runPendingImmediatesAfterCurrentTask drains the captured immediates from a process.nextTick, which Node runs after the microtask queue is exhausted.

Neither holds on workerd. Timer handles have no _idleStart, so Next disables its own scheduling patch and logs Next.js cannot guarantee that Cache Components will run as expected. process.nextTick is implemented as queueMicrotask:

function nextTick(cb, ...args) { queueMicrotask(() => cb(...args)); }

The drain therefore checks the immediate queue two microtask hops after the task starts. A React task that awaits even once has not scheduled its flush yet, so the queue looks empty, capturing stops, and every later flush becomes an ordinary macrotask behind the next stage.

Concrete case. /deep-shell/[slug] in the experimental example renders a shell of nested async components that each await a "use cache" read. On Next 16.3.1 the same request produces:

Runtime Runtime prefetch body Deepest shell level present
Node (next start) 5247 bytes level 0 (complete)
Workers (before this PR) 4773 bytes level 5 (truncated)
Workers (after this PR) 5247 bytes level 0 (complete)

The same slip makes the document render fail. Next sees a "use cache" read arrive in a later stage than expected and returns 500 with Next.js encountered uncached or runtime data during prerendering. In a deeper application the loss reaches the root, and the prefetch body is the single ~ partial marker with no Flight rows, which is what reviewers reported.

Area Principle / invariant What this PR changes
Staged rendering Between two render tasks, every immediate the previous task queued has run. A workerd scheduler counts the immediates each render causes and waits for that count to reach zero before entering the next stage.
Immediate capture Next's fast-immediate capture is a process-wide slot with one owner at a time. runInSequentialTasks is its only consumer, so the replacement never engages it. Overlapping requests can no longer take the slot from each other.
Module load tracking A timer handle belongs to the request that created it. The module loading CacheSignal and its subscriptions are keyed on the Cloudflare request context.
Cache interception A cached response may only be served when it is complete. Partially prerendered routes bypass interception and reach Next's request handler, which holds the postponed state.

The scheduler works because workerd runs timers and immediates from one ordered macrotask queue and always drains microtasks between two of them. Scheduling an immediate is therefore an exact "everything queued before me has run" signal. Counting is attributed per render through AsyncLocalStorage, so a busy isolate cannot make one render wait on another, and a hop cap keeps a render that never settles from stalling the pipeline.

What changed

Scenario Before After
Runtime prefetch (Next-Router-Prefetch: 2) of a deep shell Body truncated mid shell, or only the ~ partial marker Byte-identical to next start
Document render of a deep Cache Components shell 500 Internal Server Error 200 with the resolved shell and dynamic content
Route and segment prefetch of the same route 500 Internal Server Error 200 with a Flight payload
Overlapping RSC prefetches in one isolate Cannot perform I/O on behalf of a different request, then empty or truncated bodies that outlive the request Every response complete, isolate stays healthy
Cache Components route with enableCacheInterception: true Cached shell returned as a complete page Interception bypassed, Next resumes the postponed render
App without cacheComponents No patches Unchanged. No patches register, and globalThis.setImmediate is not wrapped
Build against a Next release that reshapes the matched code Silent no-op for the scheduler patch Build fails with the Next version in the message, for Cache Components apps only
Maintainer review path
  1. packages/cloudflare/src/cli/templates/cache-components-scheduler.ts for the replacement scheduler and why a macrotask hop is the correct boundary on workerd.
  2. packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts for runInSequentialTasksRule, which now delegates to that module, and for the onResolve hook that binds the generated require to the copied template.
  3. packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts for the behavioural contract the scheduler must hold.
  4. examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx and examples/e2e/experimental/e2e/staged-render.test.ts for the regression fixture and its assertions.
  5. packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts again, for requestScopedModuleLoadingSignalRule and bypassPprCacheInterceptionRule, which are unchanged in intent from the earlier revisions of this PR.
Validation

Regression coverage

  • cache-components-scheduler.spec.ts asserts that each stage's work and its flush land before the next stage, for microtask depths 0, 1, 3, and 12, and that two overlapping renders do not gate each other. Disabling the settle loop fails 5 of the 9 tests, so the suite is load bearing rather than tautological.
  • cache-components.spec.ts covers the rewritten rule, the fixtures of the minified Turbopack schedulers from next@16.2.12 and 16.3.0-canary.105, and the resolution of the generated require to the copied template.
  • staged-render.test.ts asserts that runtime prefetches carry the shell they rendered, that the deep shell reaches the client, that the document and both prefetch variants return 200, and that six overlapping runtime prefetches each keep their own session content.

Differential check against Node

A Next 16.3.1 application was built once and served two ways: next start and wrangler dev. The runtime prefetch of /deep-shell/[slug] matched at 5247 bytes with the fix, against 4773 bytes and a truncated shell without it.

Load

Every response was checked for status, content type, the marker byte on runtime prefetches, the presence of Flight rows, the prerendered shell, and the resolved dynamic content.

Application Concurrency Requests Result
Example, Next 16.2.11 1 550 pass
Example, Next 16.2.11 8 1100 pass
Example, Next 16.2.11 32 1100 pass
Repro app, Next 16.3.1 1 550 pass
Repro app, Next 16.3.1 8 825 pass
Repro app, Next 16.3.1 32 825 pass

The same harness against the previous revision of this branch reported 24 failures in 165 requests, all on /deep-shell/[slug].

Checks

pnpm code:checks passes. The two affected vitest files pass (40 tests). The Playwright suite was not run to completion locally; the assertions in staged-render.test.ts were verified by issuing the same requests against the built worker.

Scheduler trace, before and after

A harness running the real fast-set-immediate.external.js inside workerd, with a producer that models React (a gate resolves, the task awaits, then it schedules its flush through setImmediate):

Node, any depth:        s0 work0 flush0  s1 work1 flush1  s2 work2 flush2  s3 work3 flush3
workerd, before, 1 hop: s0 s1 work0  s2 work1 flush0  s3 work2 flush1  (last stage never runs)
workerd, after, 12 hops: s0 work0 flush0  s1 work1 flush1  s2 work2 flush2  s3 work3 flush3

At depth 0, where the flush is scheduled synchronously from the gate, the previous revision behaved correctly. That is why the example application passed before this PR added a fixture with an awaiting shell.

Risk / compatibility
  • Public API: unchanged. Only the doc comment on enableCacheInterception changes, to record that Cache Components routes now bypass interception while experimental.ppr alone still requires false.
  • Runtime scope: the scheduler module wraps globalThis.setImmediate and globalThis.clearImmediate once, lazily, on the first staged render. Only builds that enable Cache Components contain the module. When no staged render is active the wrapper delegates without bookkeeping.
  • Next internals: runInSequentialTasks is the only consumer of DANGEROUSLY_runPendingImmediatesAfterCurrentTask and expectNoPendingImmediates in Next's distribution. Replacing it leaves Next's fast-immediate capture inert rather than half used.
  • Build output: one new file, .open-next/cloudflare-templates/cache-components-scheduler.js, bundled into the server function.
  • Deliberate failure mode: when a matcher stops matching, the build fails with the Next version in the message. This can only happen for apps that enable Cache Components, where an unpatched scheduler means broken renders in production.
  • Known limit: the settle loop is capped at 1000 macrotask hops per stage. A render that never stops scheduling immediates loses its drain for that stage, which is the previous behaviour, rather than stalling the pipeline.
Non-goals
  • No Next.js or React version bump. next@16.2.11 on main already contains the matched code, and the checked-in fixtures cover 16.2.12 and 16.3.0-canary.105.
  • No change to experimental.ppr with cache interception. That combination still requires enableCacheInterception: false.
  • No attempt to make Next's fast-immediate capture work on workerd. The replacement scheduler removes the need for it.
  • No change to how Next decides stage boundaries. This PR only makes the runtime honour them.

References

Reference Why it matters
app-render-render-utils.ts The runInSequentialTasks contract this PR reimplements
app-render-scheduling.ts createAtomicTimerGroup and the _idleStart assumption
fast-set-immediate.external.ts The capture whose drain depends on Node process.nextTick ordering
workerd internal_process.ts process.nextTick is queueMicrotask
Reviewer reports in this PR The one byte ~ prefetch bodies and the interleaving observation that led to this revision

@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6b78393

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@opennextjs/cloudflare Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@opennextjs/cloudflare@1318

commit: 6b78393

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Dynamic partial prerenders could hang because Next\x27s atomic timer scheduler depends on private Node timer state that workerd does not expose. Cache interception could also finalize a cached PPR shell before Next resumed postponed work.

Preserve staged render ordering with the unpatched immediate scheduler, and bypass interception only for partially prerendered routes so Next produces the complete stream. Cover dynamic params, warm caches, prefetches, and client navigation.
Turbo app-page runtimes strip createAtomicTimerGroup, causing the scheduler patch to silently no-op and Workers PPR requests to hang. Match structural timer mutation and fast-immediate markers across compiled runtimes and generated server chunks, and fail the build if the incompatible path survives.\n\nReal minified fixtures cover stable and canary output.
Application and dependency chunks can contain their own private timer feature checks. Identify Next's atomic timer group by its scheduling invariant and setTimeout structure so unrelated functions remain untouched while the incompatible scheduler still fails closed.
Next.js tracks in-flight dynamic imports on one CacheSignal per process.
On Workers that signal holds immediate/timeout cleanup handles owned by
whichever request scheduled them, so an overlapping request clearing them
dies with "Cannot perform I/O on behalf of a different request" mid
render and the isolate keeps serving truncated responses. Key the signal
on the per-request Cloudflare context instead.
The scheduler and module loading signal matchers target code that ships
in every Next 16.2+ bundle, and they fail the build when they stop
matching. Gate them on the resolved Next config so apps without Cache
Components cannot be broken by upstream reshaping code they never run.
Cache interception alone made the generated middleware bundle's shape
build-critical for apps that never render Cache Components routes.
@NathanDrake2406
NathanDrake2406 force-pushed the nathan/cache-components-workers branch from 457bcbd to 69807b1 Compare August 3, 2026 06:10
Next.js documents that an `import()` promise is commonly cached in user
land, so only the first render executes the instrumented import and every
later render learns about the pending promise from the shared signal.
Per-request signals let a second render's `cacheReady()` resolve while a
module it depends on was still loading.

Keep the signal shared and drop only the request bound timer: the shared
signal has no listeners to notify because `trackPendingModules`
subscribes render signals instead, so the scheduled callback only ever
walked empty arrays. Scheduling still happens when a listener exists, so
a future Next.js that awaits this signal fails loudly rather than hanging.

Also assert at build end that each patch actually matched, narrow the
cache interception docs back to Cache Components, and check RSC responses
against route content instead of a non-empty body.
@PavelProdan

Copy link
Copy Markdown

Hi @NathanDrake2406! Could you give me an approximate timeline for when this is expected to be released? Thanks!

@Denis-Athletix

Copy link
Copy Markdown

Independent verification from a Next.js 16.3.1 / Cloudflare Workers app:

  • Stable @opennextjs/cloudflare@1.20.2 fails deterministically even with one 1 KiB request-bound Suspense boundary: HTTP 500, workerd reports that the Worker will never generate a response, and Next logs the setTimeout() compatibility warning.
  • next@16.3.1-canary.22 with OpenNext 1.20.2 still fails.
  • Wrangler 4.123.0 with stable Next/OpenNext still fails.
  • The immutable PR fix: run Cache Components staged renders correctly on workerd #1318 preview (https://pkg.pr.new/@opennextjs/cloudflare@69807b1) passes the same fixture.

Full differential gate with the PR preview: concurrency 8, 100 requests per scenario, three scenarios (single 1 KiB boundary, single 64 KiB boundary, four parallel 64 KiB boundaries):

Mode Complete responses Failures Worker failure signatures
Cache Components off 300/300 0 0
Cache Components on 300/300 0 0

Every response was checked for HTTP 200, HTML content type, the static shell marker, every streamed boundary start/end marker, and minimum body length. This independently confirms that the scheduler patch in #1318 addresses the primary failure; payloads above 4 KiB are not required to reproduce it.

@kmsomebody

Copy link
Copy Markdown

I'm getting inconsistent behavior with this PR.
Prefetch requests will most of the time have a response payload of only one byte.

You can see multiple page refreshes in the attached screenshot. Of 7 page loads, the browse route was only successfully prefetched 2 times. The route becomes blocking in case the prefetch response is empty.
image

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

I'm getting inconsistent behavior with this PR. Prefetch requests will most of the time have a response payload of only one byte.

You can see multiple page refreshes in the attached screenshot. Of 7 page loads, the browse route was only successfully prefetched 2 times. The route becomes blocking in case the prefetch response is empty. image

Seems like the feature is not getting supported anytime soon. But I'll have a crack at it again when I'm free will update the PR

Overlapping renders share Next.js module-loading CacheSignal state. Its cleanup closures retain request-owned timer handles, so later requests can fail with cross-request I/O and return truncated RSC streams.

Keep only unresolved import promises in module scope. Seed a separate signal for each request, which preserves cached-import waiting without sharing timer handles. Strengthen concurrent response checks for incomplete HTML and Flight payloads.
Overlapping renders can subscribe before another request starts a cached import. A one-time pending-promise snapshot loses that future read and lets the render finish early.

Forward new imports through request-owned promises, gate the rewrite to affected Next 16 releases, and fail closed without depending on the upstream backing identifier.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@kmsomebody can you re-test?

@kmsomebody

kmsomebody commented Aug 19, 2026

Copy link
Copy Markdown

Unfortunately this does not fix the issue.

I did some debugging and noticed 2 things:

  • Multiple runInSequentialTasks() executions can interleave. Preventing one run’s stages from interleaving with another reduces the number of 1-byte ~ responses, but does not eliminate them.
  • Even without interleaving, first() sometimes completes without producing any Flight chunks before the later stages run.

So it looks like both the cross-run interleaving and the scheduling/work performed during first() still need to be investigated/fixed.

Hope this helps.

@NathanDrake2406 NathanDrake2406 changed the title fix: support Cache Components on Workers fix: run Cache Components staged renders correctly on workerd Aug 19, 2026
Exhausting the settle budget entered the next stage anyway, which is the
truncated response this scheduler exists to prevent, only silent. Reject
instead so unsettled work surfaces as an error.

That makes a leaked pending count fatal rather than merely slow, so keep
the count honest on both sides of the pair: settle an immediate only once
its clear has gone through, and drop the count again if the schedule threw.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

Unfortunately this does not fix the issue.

I did some debugging and noticed 2 things:

  • Multiple runInSequentialTasks() executions can interleave. Preventing one run’s stages from interleaving with another reduces the number of 1-byte ~ responses, but does not eliminate them.
  • Even without interleaving, first() sometimes completes without producing any Flight chunks before the later stages run.

So it looks like both the cross-run interleaving and the scheduling/work performed during first() still need to be investigated/fixed.

Hope this helps.

that should do it

@kmsomebody

Copy link
Copy Markdown

I tested the latest commit and it seems that was not all that's necessary to fix this issue. The 1-byte responses are still there, but less frequent than before.

I observed 30 prefetch requests for the same route, of which 11 had the 1-byte ~ response body.

Interestingly, 2 of the 30 requests resulted in an incomplete response body of about 24 kB (uncompressed size). The remaining 17 requests had a 60 kB response body. In a local build, I consistently get the 60 kB response body.

Next awaits the RSC payload before it starts staging, so React resumes much of
the render from promises created outside `runInSequentialTasks`. Attributing
immediates through an AsyncLocalStorage entered around the stage body never saw
that work: the pending count read zero, every stage advanced over a flush still
in flight, and the render slipped a stage - which reaches a runtime prefetch as
a body holding only the partial marker.

Next's own capture is process wide for exactly this reason. Charge immediates
the run does not own to the Cloudflare request context, which wraps the whole
render, and hold a stage until both counts settle. Requests still cannot gate
each other's stages.

Also trim the comments this PR added down to the repo's limit.
@kmsomebody

Copy link
Copy Markdown

The issue remains with the latest commit, with approximately the same frequency as the previous commit.

Potentially unrelated, but something I observed with this PR:
After deploy, I'm getting a 500 Internal Server Error from the route tree prefetch for many routes. After a few page refreshes, the errors do not appear anymore, so this is probably cache-related. This is our cloudflare config:

export default defineCloudflareConfig({
  incrementalCache: withRegionalCache(r2IncrementalCache, {
    mode: "long-lived",
    shouldLazilyUpdateOnCacheHit: true,
  }),
  queue: doQueue,
  tagCache: doShardedTagCache({ baseShardSize: 12 }),
  enableCacheInterception: false,
});

In production we currently use opennextjs-cloudflare version 1.19.10, where this issue does not occur.
Neither does it happen with next start (nor next dev, as suggested by the error message below).
This is from the error log:

GET https://development.bookwalker.com/library/purchases?_rsc=k_2PeNQMRIdxcsrd - Ok @ 20.8.2026, 12:03:01
  (error) Error: Route "/library/purchases": Next.js encountered the unstable value `crypto.randomUUID()` while prerendering.

This value can change between renders, so it must be either prerendered or computed later.

Ways to fix this:
  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call
  - [cache] Prerender and cache the value with `"use cache"`
  - [client] Render the value on the client with `"use client"`

Learn more: https://nextjs.org/docs/messages/blocking-prerender-crypto
  (error) To get a more detailed stack trace and pinpoint the issue, try one of the following:
  - Start the app in development mode by running `next dev`, then open "/library/purchases" in your browser to investigate the error.
  - Rerun the production build with `next build --debug-prerender` to generate better stack traces.
  (error) Error
  (error) ⨯ Error
X [ERROR] Error: 

      at rn (worker.js:57192:94)
      at sK (worker.js:60557:21)
      at async sw (worker.js:59688:163)
      at async l2 (worker.js:113524:26)
      at async ny.handleRevalidate (worker.js:57975:26)
      at async ny.handleGet (worker.js:57953:60)

I can test with version 1.20.2 again once I have time.

React can capture setImmediate before the scheduler installs, and timer groups from one request can interleave. Both paths can advance a stage before queued Flight work flushes, which truncates runtime prefetches or hangs the request.

Install the scheduler before Next evaluates, and serialize staged groups within each request. Keep separate requests independent and release the queue before adopting an asynchronous render result.

Add a large dynamic PPR fixture and stress cold, warm, overlapping, prefetch, and client-navigation paths with cache interception enabled or disabled.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

I think it's fixed. Stressed it hard

@kmsomebody

Copy link
Copy Markdown

I'm still encountering the issues with the latest commit.
Is there any way I can help by providing information? Some debug logging patch maybe?

Next captures the custom promise hook from setImmediate when it initializes node:timers/promises. Workerd does not provide this hook on its global callback API, so promise-based immediates throw and PPR requests fail.

Capture the native promise API before Next initializes. Expose a counted hook that preserves values, cancellation, and staged settlement. Add a hostile PPR fixture with split imports, wide streams, prefetch overlap, and client navigation coverage.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

Could you re-test the latest commit?
If that doesn't work, can you share a repro of the code pattern you're using?

@kmsomebody

Copy link
Copy Markdown

It's still happening. I'll try to set up a reproduction repo tomorrow.

@kmsomebody

Copy link
Copy Markdown

Found a likely culprit while creating the reproduction repository.
Reproduction: https://github.com/kmsomebody/cache-components-repro

When I remove the Sentry.init call in instrumentation.ts, I'm unable to reproduce it.
This raises the question of what Sentry is doing, and whether this is something they should fix on their side or if this should be addressed in @opennextjs/cloudflare. Since this issue does not occur in a local build, there is a difference between next start and @opennextjs/cloudflare, so this might still need some investigation.

Maybe related: getsentry/sentry-javascript#23592

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.

4 participants