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
.
For months, background revalidation had never run. Not "ran slowly" β never ran, not once. Every page on the site was reading x-nextjs-cache: HIT
, which is exactly what I checked to convince myself it was fine.
Here is what was actually happening, what the header can and cannot tell you, and the one binding everyone forgets.
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 silently invalidated the whole cache. This repo deploys several times a day. The bug was covered by deployment frequency.
The log window told the real story. Three days of Workers Logs, filtered to errors:
107 x Failed to revalidate stale page
earliest: 2026-08-03
107 failures, zero successful background revalidations.
OpenNext's Cloudflare adapter needs all three of these. I had none of them.
// web/open-next.config.ts
queue: doQueue, // from '@opennextjs/cloudflare/overrides/queue/do-queue'
// web/wrangler.jsonc
"durable_objects": {
"bindings": [{ "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["DOQueueHandler"] }
],
"services": [
{ "binding": "WORKER_SELF_REFERENCE", "service": "changewatch-web" }
]
The binding name and the class name are fixed by the adapter β they are not yours to choose.
** WORKER_SELF_REFERENCE is the one that gets forgotten**, and it fails in the most misleading way possible. The Durable Object does not render anything itself. It calls
No service binding for cache revalidation worker
53 occurrences in 8 minutes
The request path 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 the chain dies at step 3 β but steps 1 and 2 still "succeed", so the queue reports healthy while nothing is ever re-rendered.
queue
unset is worse than it sounds
If you don't set queue
at all, OpenNext falls back to a dummy queue whose send() throws. That throw is normally swallowed by NextServer's catch, so you get a log line and a stale page.
Then I enabled enableCacheInterception: true
for the CPU savings. That moves the same throw outside NextServer's catch, and before the render:
result: permanent HTTP 500 per URL
scope: 12 URLs died one at a time over ~9 hours
timing: each one died the moment it passed its `revalidate` window
A page would serve fine for five minutes, cross revalidate
, and then 500 forever β because the code path that would have refreshed it now threw before rendering anything. Reverted.
The flag is not the villain; the order is. enableCacheInterception
is a real CPU win. Confirm revalidation actually works first, then turn it on.
This is the part that cost me the most time, so it gets its own section.
x-nextjs-cache: MISS
still renders through NextServer and still writes a cache entry. So:
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 the failures, and count the revalidate
info lines for actual DO-driven re-renders. Steady state after the fix:
196 events
0 errors
33 revalidate runs
33 re-renders that no user request triggered. That number is the proof; HIT
is not.
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 web/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. Hypothesis dead, and the middleware redirect β which is correct canonicalisation β stayed.
What the failures actually track is 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. A deploy changes the buildId and with it the entire R2 key space, so a revalidation enqueued across the switch has nowhere to land. It self-heals on the next request (MISS β render β write), never returns 5xx, and does not occur at all in a steady period.
The frequency scales with deploys per hour, not with anything in your config. If you see these right after a deploy, they are benign.
Separating these because the difference matters:
Measured:
Failed to revalidate stale page
in a 3-day window with zero successesNo service binding for cache revalidation worker
in 8 minutes with the DO present but the service binding absentenableCacheInterception
- dummy queueWorking hypothesis (consistent with the data, not proven): the residual post-deploy failures are buildId key-space rotation. It fits the timing of all four, but I have not instrumented the R2 key at enqueue time to prove the enqueued key is the pre-deploy one.
Three separate meters, none of them your Workers CPU budget:
| Meter | Included | Then |
|---|---|---|
| DO requests | 1M/mo | $0.15/M |
| DO duration | 400k GB-s (hibernating objects not billed) | β |
| R2 Class A | 1M/mo | $4.50/M |
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 1M/mo, raise revalidate
β it scales all three meters together.
queue: doQueue
, the Durable Object with its migration, and WORKER_SELF_REFERENCE
.x-nextjs-cache
. A MISS repopulates, so everything reads HIT eventually.revalidate
runs in the logs, not by sampling headers.Failed to revalidate
within ~5 minutes of a deploy is deploy churn. Ignore it.enableCacheInterception
only after revalidation is confirmed working.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 described above β which is how I found out it was broken.