The X-Inertia cache trap: Cloudflare, Vary, and edge-cached HTML

I clicked the logo on one of my sites and the homepage opened inside a modal. Dimmed backdrop, rounded corners, the full page — header, hero, everything — rendered in an overlay on top of the page I was already on.

If you've worked with Inertia.js you may recognize this: it's Inertia's error modal, the one that normally shows you a stack trace in development when an XHR hits a server error. In production, on a page that returned 200 OK. So what happened?

The setup

The site is elprisradar.se, an SEO-focused Laravel 13 + Inertia (React, SSR) app showing Swedish electricity spot prices. The public pages are deliberately cache-friendly:

  • A cookieless middleware group — no session, no CSRF cookie, no Set-Cookie.
  • Request-invariant HTML — the live "right now" price is picked client-side from per-period data, so the server response is identical for every visitor.
  • A computed cache horizon: pages are immutable until the next price publish (~13:00) or midnight, so the app sent Cache-Control: public, max-age=N, s-maxage=N with N often being hours.

The app runs on Laravel Cloud, which fronts everything with Cloudflare. On vanilla Cloudflare, HTML isn't cached by default — and neither is it on Laravel Cloud. But the platform honors your origin Cache-Control beyond its default file-extension rules, so by sending public, s-maxage I had opted my HTML into the edge cache myself. Edge-cached, request-invariant, cookieless HTML for an SEO site — by the book, right?

The trap: one URL, two representations

Inertia's core protocol design is that every page URL serves two different responses:

  • A regular browser request gets the full HTML document.
  • A client-side navigation sends the same GET / with an X-Inertia: true header and gets back a JSON "page object".

Same URL, content negotiated via a request header. HTTP has a spec mechanism for exactly this, and Inertia uses it: the official adapters set Vary: X-Inertia on Inertia page responses, telling Vary-aware caches not to reuse the HTML variant for requests whose X-Inertia header differs.

Browser caches honor Vary. Cloudflare's does not — its docs say it plainly: "Cloudflare does not consider vary values in caching decisions." The cache key is the URL plus a small documented set of inputs, none of which is X-Inertia; practical Vary support is limited to Accept-Encoding.

So the failure sequence is:

  1. Someone visits / normally. Cloudflare caches the HTML document under the key /.
  2. I'm on /elpris/se1 and click the logo. Inertia fires GET / with X-Inertia: true, expecting JSON.
  3. Cloudflare looks up /, finds the HTML, and serves it. cf-cache-status: HIT. The request never reaches my origin.
  4. Inertia receives HTML where JSON should be, declares it a non-Inertia response, and displays it in its error modal.

Every client-side navigation to any edge-warm page was broken. I just happened to notice it on the logo first.

Proving it

Two requests, same URL, the only difference is the header:

1$ curl -sI https://elprisradar.se/ | grep -Ei '^(cache-control|vary|cf-cache-status|age):' 2cache-control: max-age=27680, public, s-maxage=27680 3vary: Accept-Encoding,X-Inertia 4age: 258 5cf-cache-status: HIT 6 7$ curl -sS -D /tmp/h -o /tmp/b -H "X-Inertia: true" https://elprisradar.se/ 8$ grep -Ei '^(cf-cache-status|age|content-type):' /tmp/h 9content-type: text/html; charset=utf-8 10age: 258 11cf-cache-status: HIT 12$ head -c 40 /tmp/b 13<!DOCTYPE html> 14<html lang="en" class="">

The Vary: X-Inertia is right there in the cached response. Cloudflare serves the HTML to the JSON request anyway — same age, same cache entry.

The half-fix I already had (and why it wasn't enough)

I'd actually anticipated part of this. My cache middleware marked every response to an X-Inertia request as no-store, so the JSON variant could never be cached under the document's URL. That covers the scarier inverse failure: cached JSON being served to a browser as a page (or to Googlebot — an SEO site serving raw JSON to a crawler is a bad day).

But no-store on the JSON is a store-side defense. The poisoning here happens at lookup: the edge already holds legitimately-cached HTML and hands it to the XHR before the origin ever sees the request. No response header on the JSON variant can prevent that lookup hit, because the origin never gets a chance to generate the JSON response.

The fix

If your shared cache can't tell the variants apart, the document must not be in a shared cache. The change is one line:

1// before 2return "public, max-age={$ttl}, s-maxage={$ttl}"; 3 4// after 5return "private, max-age={$ttl}";

private keeps Cloudflare (and any other shared cache) out entirely. Browsers still cache the HTML for the full horizon — and browsers implement Vary correctly, so their private caches keep the HTML and JSON variants apart. The Inertia JSON responses stay no-store.

That line has a price: no more edge-cached HTML at all. First-visit TTFB, crawler latency and origin load move back to the application. On Laravel Cloud's standard tier it's the only lever you have — the Cloudflare section further down is for people who own their edge.

Scope the change to the Inertia page routes. Everything with a single representation per URL should keep its shared-cache headers: hashed static assets (the bulk of the bytes), pure-JSON API routes, sitemap.xml, images. My /api/kommuner endpoint keeps its s-maxage=604800 untouched, because it has no HTML/JSON dual identity. Only the Inertia page documents are off-limits.

One deployment gotcha: shipping the fix doesn't evict what's already cached — old s-maxage entries keep poisoning navigations until they expire or you purge. Laravel Cloud purges the edge cache on every deploy by default, so in my case the poisoned entries vanished the moment the fix shipped. On a self-managed zone, purge manually.

If you control your own Cloudflare zone

On Laravel Cloud the edge is managed for you — purge and bypass toggles, no Cache Rules, no Workers. But on your own zone this is solvable on any plan:

  1. Bypass-on-header Cache Rule (works on Free): one rule with an expression like any(http.request.headers["x-inertia"][*] == "true") set to bypass cache, plus HTML cache eligibility for everything else. Browser visits get edge-cached HTML — you keep s-maxage — while Inertia XHRs always pass through to origin. The XHR never reads the cache, so poisoning is impossible. The JSON variant isn't edge-cached, but the win is where it matters: first visit, crawlers, TTFB.
  2. Custom cache key including X-Inertia (Enterprise): both variants cached as separate entries — what a Vary-aware cache would do automatically.
  3. A Worker doing its own caching with a variant-aware key — a heavier hammer; the bypass rule above covers most needs.

And CDNs that respect Vary per spec — Fastly, Varnish, CloudFront with the header in its cache key policy — handle Inertia documents without any of this.

Check your own app in thirty seconds

Warm the edge with a couple of plain requests to a public page, then send one with the Inertia header:

1curl -s -o /dev/null -D - https://your-app.example/some-page # run twice 2curl -s -o /dev/null -D - -H "X-Inertia: true" https://your-app.example/some-page

If that last response says cf-cache-status: HIT with content-type: text/html, you're affected. DYNAMIC, MISS or BYPASS means the request reached your origin, and you're fine.

Takeaways

  • This isn't an SSR problem. The dual-representation protocol exists in client-only Inertia apps too. SSR just renders the first visit's HTML.
  • It isn't really an Inertia bug either. Inertia plays HTTP by the book — same canonical URL, header negotiation, an explicit Vary: X-Inertia. The book just isn't enforced at most CDN edges.
  • Most Inertia apps never hit this because they're session-backed dashboards nobody would edge-cache. It bites exactly the unusual combo I had: Inertia plus aggressively cacheable public pages.
  • The rule: Inertia page HTML must not live in a shared cache that can't key on X-Inertia. Browser caching is fine — browsers follow the spec. Check what your CDN does with Vary before you reach for s-maxage.

And if a full page ever renders inside Inertia's error modal in production, check cf-cache-status before you check your code.