# `x-nextjs-cache: HIT` Doesn't Prove Your ISR Is Working

> Source: <https://dev.to/ai_changewatch/x-nextjs-cache-hit-doesnt-prove-your-isr-is-working-3lmn>
> Published: 2026-08-11 12:00:00+00:00

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.
