writing / post

Fourteen minutes of text/x-component

How one test request made Cloudflare serve a Next.js RSC payload as my /writing page for about 14 minutes, and why my bypass rule lost.

published
2026-10-03
reading
05 min

What happened

On 3 October 2026, a plain GET https://goksen.ing/writing returned content-type: text/x-component with cf-cache-status: HIT. That is the React Server Component (RSC) payload of the page, the data that the Next.js router uses for client navigation, not the HTML. A browser cannot show it as a page.

It lasted from about 15:56:56 to 16:11:31 UTC, about 14.5 minutes, in the one Cloudflare data center that I could see (Amsterdam). I cannot say how many visitors got it: the cache works per data center, and a response that is not HTML carries no analytics beacon.

This is a blameless postmortem. The request that caused it was my own validation check.

The setup

goksen.ing runs on Vercel. Cloudflare sits in front of it for DNS, the proxy, an HTML cache, and cookieless analytics. At the time, three Cache Rules ran in this order:

Cache Rules at the incidenttext
1  Bypass ACME challenge   path starts with /.well-known/acme-challenge/
                           -> bypass cache
2  Bypass RSC / prefetch   query contains _rsc, or header rsc exists,
                           or header next-router-prefetch exists
                           -> bypass cache
3  Cache HTML              host goksen.ing, GET, not /_next/image,
                           not opengraph-image -> eligible for cache,
                           ignore cache-control, 1 month; 200 -> 1 month

Next.js serves two responses at one URL. A normal request gets HTML. A request with the header rsc: 1 gets the RSC payload. The response tells caches so: Vercel sends vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch.

Timeline

timeline · UTC · 3 October 2026text
15:12:59  production deploy; the purge workflow fails: no secrets yet
15:53:04  the CLOUDFLARE_ZONE_ID secret is set, with a wrong value
15:56:54  validation request: GET /writing, RSC: 1
          -> text/x-component, MISS; Cloudflare stores it
15:57:20  plain GET /writing -> text/x-component, HIT
15:57:35  the purge workflow, run again, fails: 10000
16:03:52  single-URL purge of /writing; at 16:04:25 still the payload
16:09     Cache HTML moves first; 300 and up are not stored
16:11:31  Purge Everything; at 16:11:44 /writing is HTML again
16:22:58  the zone ID secret is corrected
16:23:04  the purge workflow: "Purged the zone."

Cause

Three things lined up.

Cloudflare ignores Vary by default. It uses Vary for compression and, on paid plans, for image formats. Since July 2026, a Cache Rule can turn on a Vary setting, but mine did not. So the HTML and the RSC payload of /writing had one cache key: the URL.

Cache Rules stack, and the last match wins. Every matching rule applies, in order. When two rules set the same thing, the later rule wins. My request matched rule 2 (bypass) and rule 3 (cache), and rule 3 came last. The bypass rule never had a chance. The same check showed it for the other bypass rules too: /?_rsc=abc gave HIT, and the ACME path stored its 404.

How /writing got poisoneddiagram

Only a hand-made request could reach the plain key. The Next.js client adds a _rsc query to every RSC fetch: a hash of the router headers. So a browser's RSC request has its own cache key, /writing?_rsc=…, and cannot overwrite the page. My validation request had rsc: 1 and no _rsc. Next.js has a guard for this case: it redirects an RSC request whose _rsc does not match. On Vercel, the guard did not run for my request; the answer was a 200 with the payload.

Cache Rules order, before and afterdiagram

Two more problems from the same check

A 404 could stay cached for up to a month. The Cache HTML rule ignored cache-control, and it had one status code row: 200 -> 1 month. A 404 matched no row, so it took the rule's one-month TTL. /nope went MISS, then HIT. A page that I add later could have stayed a 404 at that data center until the entry expired or a purge removed it.

The purge on deploy failed with 10000. A GitHub Actions workflow purges the zone after each production deploy. It failed with 10000 Authentication error. My first guess was the token type. It was the zone ID: the secret held a wrong value. The error does not tell you which part is wrong, because a valid token also gets 10000 for a zone outside its resources. The proof was simple: the same token with the correct zone ID purged one unused URL from my laptop, and the next run, with only the zone ID changed, purged the zone. One more trap on the way: an account-owned token verifies at /accounts/<account_id>/tokens/verify, not at /user/tokens/verify.

The fix

  • Cache HTML moved first. The bypass rules now come after it, so they win when they match.
  • Only a 200 is stored. Two status code rows: >= 300 -> no store and 201–299 -> no store.
  • Purge Everything. The single-URL purge did not clear /writing. Cloudflare's docs warn that it may not work when a Cache Rule matches only GET requests, and mine does. That is the probable reason.
  • The zone ID corrected. The purge has run on every production deploy since.
  • A second layer: Vary. The Cache HTML rule now turns on the Vary setting, so rsc is part of the cache key even if the rule order breaks again.
Cache Rules nowtext
1  Cache HTML              as before; 200 -> 1 month,
                           >= 300 and 201–299 -> no store;
                           Vary: rsc and default -> raw header values
2  Bypass ACME challenge   -> bypass cache
3  Bypass RSC / prefetch   -> bypass cache
.github/workflows/purge-cache.yml (core)yaml
on: deployment_status
 
jobs:
  purge:
    if: >-
      github.event.deployment_status.state == 'success' &&
      github.event.deployment.environment == 'Production'
    runs-on: ubuntu-latest
    steps:
      - name: Purge the Cloudflare zone
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
        run: |
          response=$(curl --silent --show-error --retry 3 --request POST \
            --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
            --header "Content-Type: application/json" \
            --data '{"purge_everything":true}' \
            "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache")
          jq -e '.success' <<<"$response" > /dev/null

What I changed in how I work

  • Every probe goes to a throwaway URL. I run these checks by hand and with AI agents, and the rule is the same for both: a request with RSC headers goes only to a URL with a unique query, never to a real page.
  • GET, not curl -I. My cache rule matches GET only, so a HEAD request always shows DYNAMIC and tells you nothing.
  • I read the rule list as a program. It runs from top to bottom, and the last write wins. The order is part of the logic.
  • On 10000, I check the zone too. The error names the credential, but the cause can be the resource.
probes that cannot poison a pagesh
# A unique query is its own cache key.
url="https://goksen.ing/writing?probe=$RANDOM$RANDOM"
curl -s -o /dev/null -D - -H 'RSC: 1' "$url" | grep -i cf-cache-status
 
# A plain GET of the real page: is it still HTML?
curl -s -o /dev/null -D - https://goksen.ing/writing |
  grep -iE 'content-type|cf-cache-status'

If you run Next.js behind Cloudflare

  1. Put the HTML cache rule first, and the bypass rules after it. The last matching rule wins.
  2. Keep RSC requests out of the HTML key: bypass the rsc header, turn on the Cache Rules Vary setting for it, or both.
  3. Store only a 200. Set every other status code to no store.
  4. Purge on every production deploy, and watch the first real run of that workflow.
  5. Probe with GET on throwaway URLs. Never send RSC headers to a real page through the cache.