cd /news/developer-tools/x-nextjs-cache-hit-doesn-t-prove-you… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-91910] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

`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.

read6 min views1 publishedAug 11, 2026

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.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @ai change watch 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/x-nextjs-cache-hit-d…] indexed:0 read:6min 2026-08-11 Β· β€”