{"slug": "x-nextjs-cache-hit-doesn-t-prove-your-isr-is-working", "title": "`x-nextjs-cache: HIT` Doesn't Prove Your ISR Is Working", "summary": "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.", "body_md": "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`\n\n.\n\nFor months, background revalidation had never run. Not \"ran slowly\" — never ran, not once. Every page on the site was reading `x-nextjs-cache: HIT`\n\n, which is exactly what I checked to convince myself it was fine.\n\nHere is what was actually happening, what the header can and cannot tell you, and the one binding everyone forgets.\n\n`revalidate = 300`\n\nwas 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.\n\nThe log window told the real story. Three days of Workers Logs, filtered to errors:\n\n```\n107 x  Failed to revalidate stale page\nearliest: 2026-08-03\n```\n\n107 failures, zero successful background revalidations.\n\nOpenNext's Cloudflare adapter needs all three of these. I had none of them.\n\n```\n// web/open-next.config.ts\nqueue: doQueue,   // from '@opennextjs/cloudflare/overrides/queue/do-queue'\n// web/wrangler.jsonc\n\"durable_objects\": {\n  \"bindings\": [{ \"name\": \"NEXT_CACHE_DO_QUEUE\", \"class_name\": \"DOQueueHandler\" }]\n},\n\"migrations\": [\n  { \"tag\": \"v1\", \"new_sqlite_classes\": [\"DOQueueHandler\"] }\n],\n\"services\": [\n  { \"binding\": \"WORKER_SELF_REFERENCE\", \"service\": \"changewatch-web\" }\n]\n```\n\nThe binding name and the class name are fixed by the adapter — they are not yours to choose.\n\n** 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\n\n```\nNo service binding for cache revalidation worker\n53 occurrences in 8 minutes\n```\n\nThe request path is:\n\n```\n  request ──> Worker ──> stale entry found\n                 │\n                 └─> enqueue ──> DO (NEXT_CACHE_DO_QUEUE)\n                                   │\n                                   └─> WORKER_SELF_REFERENCE ──> Worker renders\n                                                                      │\n                                            R2 <── writes fresh entry ─┘\n```\n\nCut 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.\n\n`queue`\n\nunset is worse than it sounds\nIf you don't set `queue`\n\nat 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.\n\nThen I enabled `enableCacheInterception: true`\n\nfor the CPU savings. That moves the same throw *outside* NextServer's catch, and before the render:\n\n```\nresult: permanent HTTP 500 per URL\nscope:  12 URLs died one at a time over ~9 hours\ntiming: each one died the moment it passed its `revalidate` window\n```\n\nA page would serve fine for five minutes, cross `revalidate`\n\n, and then 500 forever — because the code path that would have refreshed it now threw before rendering anything. Reverted.\n\n**The flag is not the villain; the order is.** `enableCacheInterception`\n\nis a real CPU win. Confirm revalidation actually works *first*, then turn it on.\n\nThis is the part that cost me the most time, so it gets its own section.\n\n`x-nextjs-cache: MISS`\n\n**still renders through NextServer and still writes a cache entry.** So:\n\nYou 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.\n\nI checked HIT across the site and concluded ISR was healthy. It was not, and the header was never going to tell me.\n\n**Judge on the logs instead.** Filter Workers Logs on `$metadata.level = error`\n\nfor the failures, and count the `revalidate`\n\ninfo lines for actual DO-driven re-renders. Steady state after the fix:\n\n```\n196 events\n  0 errors\n 33 revalidate runs\n```\n\n33 re-renders that no user request triggered. That number is the proof; `HIT`\n\nis not.\n\nWorth recording because it was well-argued and still wrong.\n\nAfter the fix, `Failed to revalidate stale page /en/...`\n\nstill appeared occasionally. Every failing path was under `/en/`\n\n, and `web/middleware.ts`\n\nissues a `307`\n\nfrom `/en/*`\n\nto the unprefixed canonical. Obvious conclusion: the redirect breaks the revalidation fetch.\n\nControlled test — hammer 6 `/en/`\n\npages and the 6 equivalent `/ja/`\n\npages past their revalidate window, 8 minutes:\n\n```\n83 successful revalidations\n 0 failures\n both locales\n```\n\nIf the 307 broke revalidation, `/en/`\n\nwould have failed dozens of times. It failed zero. Hypothesis dead, and the middleware redirect — which is correct canonicalisation — stayed.\n\nWhat 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.\n\n**The frequency scales with deploys per hour, not with anything in your config.** If you see these right after a deploy, they are benign.\n\nSeparating these because the difference matters:\n\n**Measured:**\n\n`Failed to revalidate stale page`\n\nin a 3-day window with zero successes`No service binding for cache revalidation worker`\n\nin 8 minutes with the DO present but the service binding absent`enableCacheInterception`\n\n+ 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.\n\nThree separate meters, none of them your Workers CPU budget:\n\n| Meter | Included | Then |\n|---|---|---|\n| DO requests | 1M/mo | $0.15/M |\n| DO duration | 400k GB-s (hibernating objects not billed) | — |\n| R2 Class A | 1M/mo | $4.50/M |\n\nWorst 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`\n\n— it scales all three meters together.\n\n`queue: doQueue`\n\n, the Durable Object with its migration, and `WORKER_SELF_REFERENCE`\n\n.`x-nextjs-cache`\n\n. A MISS repopulates, so everything reads HIT eventually.`revalidate`\n\nruns in the logs, not by sampling headers.`Failed to revalidate`\n\nwithin ~5 minutes of a deploy is deploy churn. Ignore it.`enableCacheInterception`\n\nonly 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.\n\nThe 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.", "url": "https://wpnews.pro/news/x-nextjs-cache-hit-doesn-t-prove-your-isr-is-working", "canonical_source": "https://dev.to/ai_changewatch/x-nextjs-cache-hit-doesnt-prove-your-isr-is-working-3lmn", "published_at": "2026-08-11 12:00:00+00:00", "updated_at": "2026-08-11 12:16:06.673334+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["AI Change Watch", "Cloudflare Workers", "OpenNext", "Next.js"], "alternates": {"html": "https://wpnews.pro/news/x-nextjs-cache-hit-doesn-t-prove-your-isr-is-working", "markdown": "https://wpnews.pro/news/x-nextjs-cache-hit-doesn-t-prove-your-isr-is-working.md", "text": "https://wpnews.pro/news/x-nextjs-cache-hit-doesn-t-prove-your-isr-is-working.txt", "jsonld": "https://wpnews.pro/news/x-nextjs-cache-hit-doesn-t-prove-your-isr-is-working.jsonld"}}