Four Caches, One Bug: Debugging Cloudflare and SvelteKit

Tools
back
April, 2026
5 min read

I thought I had one cache. I actually had four.

I recently migrated my personal blog from Vercel to Cloudflare Pages + Workers. That migration deserves its own post, but the short version is: I realised I didn't need two platforms when Cloudflare could handle CI/CD, DNS, edge compute, and caching in one place.

The site started life on Gatsby, moved to Next.js, and was deployed to Vercel. It was great: a repo push, a few clicks, and my site was live. Later, I added Cloudflare in front as an edge CDN, then moved my domains there as well. Then I moved to SvelteKit, and that was super awesome (and still is!). Over time, it felt weird to have Vercel and Cloudflare both doing bits of the same job, so I decided to consolidate everything onto Cloudflare.

That's where the fun started.

Why I added KV in front of Notion

I use Notion as my CMS. Posts live in a Notion database, and I fetch them via the Notion API with the @notionhq/client. Notion has rate limits, and my publishing model is simple:

  • Once a post is published, it almost never changes.
  • There is no reason to hit Notion on every page view (which is also slow).

So I introduced a 4‑layer caching model:

  1. Cloudflare KV – 30‑day TTL for Notion responses (lists and posts).
  2. Cloudflare CDN HTTP cache – initially enabled, later disabled for dynamic pages.
  3. SvelteKit navigation cache – client-side __data.json used for SPA navigations and prefetch.
  4. Browser cache – normal HTTP caching semantics.

The idea:

  • First visitor to a given list or post → Worker calls Notion, then writes the result into KV.
  • Subsequent visitors → Worker reads from KV at the edge, no Notion call.
  • SvelteKit's prefetch makes navigation feel instant: hover a menu item, and it preloads __data.json in the background.

On paper, it looked perfect: a fast stack where the Notion API is mostly untouched, and both lists and posts are served from the edge.

The bug: new posts that refused to appear

Then I published a test post, the KV cache cleared as expected, Cloudflare's cache said everything was fresh, but the live site showed only old posts.

So, the Home page sometimes showed the new post after a hard refresh. My News archive would stubbornly show only the old list. I refreshed the archive, and it showed the new post, but as soon as I navigated away and back, it showed the old list only. On mobile, even refreshing sometimes didn't help.

At this point, I had the classic feeling: "one of these four caches is lying to me".

I tried the obvious things first:

  • Headers: Cache-Control: no-store, ETag based on a content version, CDN-Cache-Control tweaks.
  • Cloudflare: explicit purging of KV and all relevant URLs (/, /news-archive, their __data.json variants).
  • SvelteKit flags: depends('content:lists') on the relevant routes plus invalidation flows.
  • Prefetch off: disabling prefetch for /news-archive links so hovering wouldn't preload stale __data.json.

Some combinations worked sometimes, but never reliably. The most annoying pattern:

1
Open archive → 155 posts
2
Hard refresh → 156 posts
3
Navigate away and back → 155 again

At that point, it was clear the bug wasn't "just one header missing."

Two separate problems, not one

After a lot of curl and browser devtools:

  1. Explicit purge of archive URLs on cache clear.
  2. CDN-Cache-Control: no-store for the dynamic pages, so the Worker + KV is the only cache of record.
  3. After that, curl https://shvarcs.com/news-archive/__data.json was actually correct, and cf-cache-status was DYNAMIC.

The server side was fine.

The remaining bug was client-side: SvelteKit's navigation/prefetch cache kept reusing an old __data.json in memory, even though a fresh one was available.

That second issue is subtle:

  • SvelteKit fetches __data.json per route URL and stores it in an internal cache.
  • That cache is per URL, not fully governed by HTTP headers.
  • depends(...) + invalidate(...) help with currently active routes, but they don't magically clear every prefetched or previously loaded route entry in other tabs or old sessions.

So even though the Worker and KV were serving the correct list, the client sometimes showed an old snapshot it already had.

Versioned navigation + URL cleanup

At this point, I have already created a handy "admin panel" to clear the KV stored version for a specific post or a list of posts. Probably in the future I should automate that, but for my once-in-two-weeks posting, I like to feel in charge.

The behaviour I needed was:

  • When content changes, treat /news-archive as a new route identity, so SvelteKit can't reuse the old data.
  • Keep the URL clean (no visible ?v=123).
  • Don't depend on users doing a hard refresh.

The pattern I ended up with:

  1. On the client – a versioned navigation helper:
    • Prevent default navigation.
    • Use SvelteKit's goto to navigate to /news-archive?v=<timestamp>.
    • Once navigation completes, immediately call history.replaceState to remove ?v= from the URL so users only see /news-archive.
  2. For modified clicks (Cmd+click / middle‑click):
    • Let the browser handle it normally so you can still open new tabs.

Effectively:

  • When content changes, and I bump the version in KV, every new navigation uses a different internal URL and therefore a fresh route data payload.
  • SvelteKit treats /news-archive?v=1775980740809 as a different URL than /news-archive?v=1775980123456, so it must fetch a fresh __data.json instead of reusing old cache entries.
  • The user and their history still see /news-archive.

It's a little bit clever, but it's just one utility function and some onclick handlers.

Final architecture

At this point, the live system looks like:

4-layer caching system:

  1. Cloudflare KV – Stores Notion API responses (30 days).
  2. Cloudflare CDN – Effectively disabled for dynamic HTML/JSON via CDN-Cache-Control: no-store + explicit purging.
  3. SvelteKit navigation cache – Kept, but now versioned via URL so it can't serve stale archive data after a content change.
  4. Browser cache – Standard HTTP caching; ETags keyed by content version.

Publishing flow:

  1. Write a new post in Notion, set Publish = true.
  2. Open my Admin panel.
  3. Click "New post" → backend clears list caches in KV and bumps content version in KV; also purges Cloudflare edge URLs.
  4. Next user navigation to /news-archive uses internal /news-archive?v=<timestamp> and fetches fresh data; URL is cleaned back to /news-archive.

Result:

  • Notion API is hit rarely.
  • Dynamic pages stay extremely fast from KV.
  • New posts show up reliably, even on mobile, without hard refreshes.

Lessons learned

  • Caching is easy to add, hard to reason about.
    Adding KV, CDN, and SvelteKit's prefetch gave me blazing-fast responses – until I wanted fresh responses.
  • You probably have more caches than you think.
    In my case: Notion → Workers KV (30 day cache) → CDN (edge cache) → SvelteKit navigation cache → browser cache. Staleness can come from any of them.
  • "Just set Cache-Control" isn't enough for in-memory SPA caches.
    SvelteKit's client-side navigation cache doesn't fully obey HTTP headers, so you sometimes need to change route identity (URL + query) or explicitly invalidate.
  • Hide cleverness behind small utilities.
    Versioned navigation + replaceState looks fancy, but it lives in one helper and a few onclicks. When SvelteKit eventually gives us better cache controls, I can rip it out in one place.

The actual bug wasn't "Cloudflare is bad" or "SvelteKit is broken." It was that my mental model ("one cache") didn't match reality ("four caches, each with slightly different rules").

Andris Švarcs

Somehow, I've survived over 15 years as a web developer without losing my interest in the craft. Quite the opposite, with so many great improvements in the Web standards, what was nearly impossible now is easy to make.

My career has been a wild ride through small agencies and big corporations, building everything from finance apps to health dashboards.

I'm that annoying person who needs to understand products beyond just slinging code. I ask questions like 'Why is this feature important?' and 'How will this improve the customer journey?' – you know, the kind of questions that make project managers reach for the pint aspirin. This curiosity has led me down the rabbit holes of design, accessibility, and SEO. Because apparently, making websites pretty, usable, and findable wasn't challenging enough on its own.

P.S. If this bio sounds too polished, blame my evil AI twin. I'm still working on teaching it sarcasm.

Copyright © since 2021, Andris Švarcs. All rights reserved.

Lets connect

bluesky

youtube

linkedin