Skip to content

[BUG] bypassTagCacheOnCacheHit serves stale entries after on-demand revalidateTag #1295

Description

@gaauwe

Describe the bug

We're running into an issue where a page is still serving old data after revalidation. We're observing the following on a page (/download) whose data is tagged releases:

  1. Initial request → served from the regional cache (x-opennext-cache: HIT).
  2. We call revalidateTag("releases", { expire: 0 }).
  3. Next request → no x-opennext-cache header and x-powered-by: Next.js (so cache interception fell through to NextServer), but x-nextjs-cache: HIT and the old release version is still served. It stays stale on subsequent requests too.

Setting bypassTagCacheOnCacheHit: false makes revalidation work correctly, so it's specifically tied to that option. But since the bypassTagCacheOnCacheHit significantly improves our performance, we prefer to keep that on.

We've met what the docs describe as the requirements for using it on Next 16+ (cache purge configured with Enterprise plan, and only ever using revalidateTag(tag, { expire: 0 }) rather than SWR-style revalidation), which is why we're unsure whether this is a bug or whether we're using regional cache / bypassTagCacheOnCacheHit incorrectly.

I've analyzed the code with an agent, which pointed to the following:

const rawEntry = await this.store.get(key, cacheType);
const { value, lastModified } = rawEntry ?? {};
if (!value || typeof lastModified !== "number") return null;
debugCache("RegionalCache", `get ${key} -> put to cache`);
// Update the locale cache after retrieving from the store.
getCloudflareContext().ctx.waitUntil(
this.putToCache({ key, cacheType, entry: { value, lastModified } })
);

On a regional cache miss this unconditionally re-populates the Cache API from the underlying store (R2). After revalidateTag, the store still holds the previous render (revalidate only marks the tag + purges the regional/CDN copy — it doesn't rewrite the store or regenerate), so this refill re-seeds a stale value. With bypassTagCacheOnCacheHit: true, the next regional hit is returned with shouldBypassTagCache: true, so the tag cache is never re-checked and the stale entry is served as fresh — and since the handler reports a fresh hit, NextServer never regenerates, so the store stays stale until the regional TTL expires. With enableCacheInterception it's near-deterministic: the interceptor's miss re-seeds the cache right before NextServer's own read hits it.

To verify this I created the following patch which resolves it in our local testing:

// PATCH(regional-cache stale refill): only the bypass-trusted Cache API can
// serve a stale entry as "fresh", so don't re-seed it with a value the tag
// cache already considers revalidated. When bypass is off, hits re-check tags
// anyway, so a stale refill is harmless and we skip the extra read.
let isRevalidated = false;
if (this.opts.bypassTagCacheOnCacheHit) {
    try {
        const tags = getTagsFromCacheEntry({ value, lastModified }) ?? [key];
        isRevalidated = await hasBeenRevalidated(key, tags, { value, lastModified });
    }
    catch (e) {
        error("Failed to check revalidation on regional cache miss", e);
    }
}
if (isRevalidated) {
    debugCache("RegionalCache", `get ${key} -> stale, skipping regional refill`);
}
else {
    debugCache("RegionalCache", `get ${key} -> put to cache`);
    // Update the locale cache after retrieving from the store.
    getCloudflareContext().ctx.waitUntil(this.putToCache({ key, cacheType, entry: { value, lastModified } }));
}
Sequence — current behavior (stale served)
sequenceDiagram
    participant C as Client
    participant I as Interceptor
    participant RC as RegionalCache
    participant CA as "Cache API"
    participant R2 as "R2 store"
    participant TC as "Tag cache (D1)"
    participant NS as NextServer

    Note over RC,CA: releases revalidated + regional entry purged
    C->>I: GET /download
    I->>RC: get("/download")
    RC->>CA: match -> MISS
    RC->>R2: get -> STALE value
    RC-->>CA: re-seed (putToCache) STALE
    Note right of CA: POISON - stale re-seeded
    RC-->>I: stale (no bypass flag)
    I->>TC: hasBeenRevalidated -> true
    I-->>NS: fall through (no x-opennext-cache)
    NS->>RC: get("/download")
    RC->>CA: match -> HIT (poisoned stale)
    RC-->>NS: stale + shouldBypassTagCache=true
    Note over NS: tag check skipped -> treated as fresh
    NS-->>C: OLD data, x-nextjs-cache HIT (no regeneration)
Loading
Sequence — with the patch (regenerates fresh)
sequenceDiagram
    participant C as Client
    participant I as Interceptor
    participant RC as RegionalCache
    participant CA as "Cache API"
    participant R2 as "R2 store"
    participant TC as "Tag cache (D1)"
    participant NS as NextServer

    Note over RC,CA: releases revalidated + regional entry purged
    C->>I: GET /download
    I->>RC: get("/download")
    RC->>CA: match -> MISS
    RC->>R2: get -> STALE value
    RC->>TC: hasBeenRevalidated -> true
    Note right of RC: stale -> skip refill (no poison)
    RC-->>I: stale (no bypass flag)
    I->>TC: hasBeenRevalidated -> true
    I-->>NS: fall through
    NS->>RC: get("/download")
    RC->>CA: match -> MISS (still empty)
    RC->>TC: hasBeenRevalidated -> true (skip refill)
    RC-->>NS: stale (no bypass flag)
    Note over NS: handler re-checks tags -> regenerate
    NS->>NS: regenerate /download (fresh)
    NS->>RC: set fresh
    NS-->>C: FRESH data
Loading

Is this indeed a bug in the miss-path refill when bypassTagCacheOnCacheHit is enabled, or are we misconfiguring / misunderstanding how the regional cache works? If it is a bug, I'd be happy to open a PR but I wanted check in about the expected behavior first.

Steps to reproduce

export default defineCloudflareConfig({
  incrementalCache: withRegionalCache(r2IncrementalCache, {
    mode: "long-lived",
    bypassTagCacheOnCacheHit: true,
  }),
  queue: doQueue,
  tagCache: withFilter({ tagCache: d1NextTagCache, filterFn: softTagFilter }),
  enableCacheInterception: true,
  cachePurge: purgeCache({ type: "direct" }),
});
  1. Deploy a Next 16 app with the config above (regional cache + bypassTagCacheOnCacheHit: true + enableCacheInterception + cachePurge).
  2. Have an ISR/SSG page whose content comes from data tagged releases. Request it so it's cached (x-opennext-cache: HIT).
  3. Call revalidateTag("releases", { expire: 0 }).
  4. Request the page again → it's served stale (x-nextjs-cache: HIT, old content) and does not regenerate.

Expected behavior

After revalidateTag(tag, { expire: 0 }), the next request to a page using that tag should regenerate and serve fresh content (as it does with bypassTagCacheOnCacheHit: false, and as self-hosted Next / Vercel do).

@opennextjs/cloudflare version

1.19.10

Wrangler version

4.91.0

next info output

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:15 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6041
  Available memory (MB): 36864
  Available CPU cores: 14
Binaries:
  Node: 22.22.2
  npm: 10.9.7
  Yarn: N/A
  pnpm: 9.15.9
Relevant Packages:
  next: 16.2.6 // There is a newer version (16.2.9) available, upgrade recommended!
  eslint-config-next: N/A
  react: 19.2.4
  react-dom: 19.2.4
  typescript: 5.3.3
Next.js Config:
  output: N/A

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions