`x-nextjs-cache: HIT` Doesn't Prove Your ISR Is Working A developer running AI Change Watch, a Next.js App Router site on Cloudflare Workers via OpenNext, discovered that its ISR background revalidation had never run for months, despite pages showing x-nextjs-cache: HIT. The issue was a missing WORKER_SELF_REFERENCE service binding and an unset queue, which caused silent failures and, with enableCacheInterception enabled, permanent HTTP 500s. The developer advises checking logs rather than cache headers to verify revalidation health. I run AI Change Watch https://aichangewatch.com , 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 successes No service binding for cache revalidation worker in 8 minutes with the DO present but the service binding absent enableCacheInterception + dummy queue Working 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 https://opennext.js.org/cloudflare/caching , Cloudflare service bindings https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/ , Durable Objects https://developers.cloudflare.com/durable-objects/ , Next.js ISR https://nextjs.org/docs/app/guides/incremental-static-regeneration , and Workers Logs https://developers.cloudflare.com/workers/observability/logs/workers-logs/ for the verification step. The site this came from tracks AI model and pricing changes across vendors: aichangewatch.com/changes/model https://aichangewatch.com/changes/model . Every page on it is served by the setup described above — which is how I found out it was broken.