I spent months looking at x-nextjs-cache: HIT and thinking ISR was working. It wasn't.
I run AI Change Watch, a Next.js App Router site on Cloudflare Workers via OpenNext. It crawls AI vendor docs and pricing pages and publishes what changed. Every page carries export const revalidate = 300.
Background revalidation had never run. Not "ran slowly" — never ran, not once. And the header I checked to convince myself it was fine is structurally incapable of telling me otherwise.
Here is what was actually broken, why the header can't prove what I thought it proved, and the piece that's easiest to forget.
The symptom that isn't a symptom
revalidate = 300 was decorative. Pages did refresh, so nothing looked wrong.
They refreshed because a deploy changes the buildId, and the buildId is part of the R2 key space — so every deploy invalidated the whole cache. This repo deploys several times a day. The bug was covered by deployment frequency.
The logs told the real story:
BEFORE
107 Failed to revalidate stale page (3-day window, earliest 2026-08-03)
0 successful background revalidations
AFTER
196 events
0 errors
33 revalidate runs
33 re-renders that no user request triggered. That is what "working" looks like.
Three pieces have to line up
For Durable Object-backed ISR revalidation in this setup, three separate things need to be configured, and they are three different kinds of thing:
-
Queue configuration —
queue: doQueueinopen-next.config.ts -
A Durable Object binding —
NEXT_CACHE_DO_QUEUE+ its migration, inwrangler.jsonc -
A Worker service binding —
WORKER_SELF_REFERENCE, also inwrangler.jsonc
I had none of them. The binding name and the class name are fixed by the adapter — they are not yours to choose.
The one that's easiest to forget
WORKER_SELF_REFERENCE fails in the most misleading way of the three.
The Durable Object does not render anything itself. It calls back into your Worker to do the render. Without the service binding, the job reaches the queue, but the revalidation worker cannot call back into the Worker:
No service binding for cache revalidation worker
53 occurrences in 8 minutes
The chain is:
request ──> Worker ──> stale entry found
│
└─> enqueue ──> DO (NEXT_CACHE_DO_QUEUE)
│
└─> WORKER_SELF_REFERENCE ──> Worker renders
│
R2 <── writes fresh entry ─┘
Cut the self-reference and it dies at step 3 — but steps 1 and 2 still report success, so nothing surfaces as an error at the request path. Pages stay 200, because a MISS still renders through NextServer. The symptom is silent staleness, not an outage.
Why the header proves nothing
This is the part that cost me the most time.
x-nextjs-cache: MISS still renders through NextServer and still writes a cache entry. So:
- You hammer a page. First response: MISS.
- It renders and writes.
- Every subsequent response: HIT.
You now have a page reading HIT whether or not the revalidation queue exists. Sampling cache state cannot distinguish "the background queue re-rendered this" from "somebody's request repopulated it." Both produce HIT. Both produce fresh-looking content.
I checked HIT across the site and concluded ISR was healthy. It was not, and the header was never going to tell me.
Judge on the logs instead. Filter Workers Logs on $metadata.level = error for failures, and count the revalidate info lines for actual DO-driven re-renders. That's where the 33 above comes from.
enableCacheInterception made it worse
If you don't set queue at all, OpenNext falls back to a dummy queue whose send() throws. Normally that throw is caught inside NextServer, so you get a log line and a stale page.
Then I enabled enableCacheInterception: true for the CPU savings. That serves ISR hits from the routing layer, skipping NextServer — which also moves the same throw outside NextServer's catch, and before the render:
Error in routingHandler
at Object.send (worker.js:116290)
at computeCacheControl (worker.js:120329)
at generateResult (worker.js:120394)
at cacheInterceptor (worker.js:121493)
Two properties made this much worse than a normal bug. It threw before the render, so the entry could never refresh and the 500 was permanent per URL. And it fired only once an entry passed revalidate, so pages went down one at a time over roughly 9 hours — 12 URLs before I reverted it.
The flag is not the villain; the order is. The CPU win is real. Confirm revalidation works first, then turn it on.
The wrong hypothesis: /en/ → 307
Worth recording because it was well-argued and still wrong.
After the fix, Failed to revalidate stale page /en/... still appeared occasionally. Every failing path was under /en/, and middleware.ts issues a 307 from /en/* to the unprefixed canonical. Obvious conclusion: the redirect breaks the revalidation fetch.
Controlled test — hammer 6 /en/ pages and the 6 equivalent /ja/ pages past their revalidate window, 8 minutes:
83 successful revalidations
0 failures
both locales
If the 307 broke revalidation, /en/ would have failed dozens of times. It failed zero. The hypothesis died, and the middleware redirect — which is correct canonicalisation — stayed. Deleting it on a plausible-sounding theory would have bought a duplicate-URL problem for nothing.
What the data actually showed
The residual failures correlate with deploy churn. All four in that window landed 2.0–4.2 minutes after a deploy, during a stretch with four deploys in 22 minutes. They self-heal on the next request (MISS → render → write), never return 5xx, and did not occur at all in a steady period.
The mechanism I believe is behind that: a deploy changes the buildId and with it the R2 key space, so a revalidation enqueued across the switch has nowhere to land. See the caveat at the end — I have not proven this one.
The configuration that actually works
Both files, complete, as they run in production today.
// web/open-next.config.ts
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
export default defineCloudflareConfig({
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
queue: doQueue,
});
// web/wrangler.jsonc (only the parts relevant to ISR)
{
"name": "changewatch-web",
"main": ".open-next/worker.js",
"compatibility_date": "2025-03-25",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
// The incremental cache store itself.
"r2_buckets": [
{ "binding": "NEXT_INC_CACHE_R2_BUCKET", "bucket_name": "awc-web-cache" }
],
// 2. The queue. Name and class are fixed by the adapter.
"durable_objects": {
"bindings": [
{ "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["DOQueueHandler"] }
],
// 3. The self-reference. `service` must equal `name` at the top of this file.
"services": [
{ "binding": "WORKER_SELF_REFERENCE", "service": "changewatch-web" }
]
}
Two notes on this config. new_sqlite_classes is what the adapter's handler expects and is the only option for new Durable Object classes. And mode: 'long-lived' on the regional cache is a separate lever from revalidation — short-lived pins every regional entry at 60s, which on this site was worth 300–380ms of response time. Different problem, same file.
Cost
Three separate meters, none of them your Workers CPU budget:
| Meter | Included | Then |
|---|---|---|
| Durable Object requests | 1M/mo | $0.15/M |
| Durable Object duration | 400k GB-s (hibernating objects not billed) | — |
| R2 Class A operations | 1M/mo | $4.50/M |
Prices are Cloudflare's published rates as of August 2026 — check Durable Objects pricing and R2 pricing for current values.
Worst case here is roughly $0–1/month against ~$5.5–6.5 of Worker CPU. R2 storage does not grow, because revalidation overwrites the same key. If DO requests ever approach the included 1M/mo, raise revalidate — it scales all three meters together.
Checklist
- For this setup, all three pieces need to be configured correctly for background revalidation to work end-to-end:
queue: doQueue, the Durable Object binding with its migration, andWORKER_SELF_REFERENCE. - Never conclude anything from
x-nextjs-cache. A MISS repopulates, so everything reads HIT eventually. - Verify by counting
revalidateruns in the logs, not by sampling headers. -
Failed to revalidateshortly after a deploy may be deploy churn. Don't treat it as an ISR outage unless it persists during a stable deployment period. - Add
enableCacheInterceptiononly after revalidation is confirmed working.
What I still haven't proven
Two things, so nobody takes them from this post as established:
The deploy-churn explanation is a working hypothesis, not a result. The failures correlated strongly with rapid deploys — all four within 2.0–4.2 minutes of one — and the buildId key-space rotation fits that timing. But I have not instrumented the R2 key at enqueue time to show that the key being written is the pre-deploy one. If you see the same pattern during a stable period with no deploys, my explanation doesn't cover your case and it's worth digging.
One failure mode is still open here: Failed to set to cache Error: put: Reduce your concurrent requests — R2 rate-limiting ISR writes during crawler sweeps, 17 occurrences over three days. Unresolved at the time of writing.
Docs worth reading properly rather than skimming: OpenNext Cloudflare caching, Cloudflare service bindings, Durable Objects, Next.js ISR, and Workers Logs for the verification step.
The site this came from tracks AI model and pricing changes across vendors: aichangewatch.com/changes/model. Every page on it is served by the setup above — which is how I found out it was broken.
Top comments (0)