At studio:sc®, we build bespoke websites, web applications, and headless commerce solutions for clients who expect two things that are often in tension: rock-solid data integrity, and load times that feel instant no matter where in the world someone opens the page. Over the last year, we've settled on an architectural pattern that gives us both — and I want to walk through how it works, why we built it this way, and what we learned along the way.
A single-region database and a global audience.
Our stack varies by project, but there's a strong default we reach for: SvelteKit as the framework, Supabase for database and auth, and Cloudflare's edge network for hosting, with R2 for object storage. Supabase's Postgres instance lives in a single AWS region — in our case, eu-west-2. That's fine for a UK-based audience, but the moment you have users in Singapore, São Paulo, or Sydney, every database query is making a genuinely long round trip. For transactional, personal, or write-heavy data, that's an acceptable cost. For public, read-heavy content — blog posts, event listings, marketing copy — it's an unnecessary tax on every page load.
The naive fix is “just add a CDN cache in front of the API”, but that only gets you part of the way. You still need a strategy for freshness, invalidation, and what happens when the cache is empty or stale. So we built something more deliberate.
Postgres as truth, KV as the fast path.
The rule we apply is simple to state: Postgres is always the source of truth. Cloudflare KV is a read-optimized, globally distributed cache in front of it.
When a site admin makes a change on their admin dashboard — publishing a blog post, updating an event, editing a page of copy — that write goes to Postgres as normal. Postgres then fires a webhook to an API endpoint we control, which takes the updated record and writes it into KV. From that point on, edge requests for that content are served directly from KV, at the edge, typically in single-digit milliseconds, with no trip back to eu-west-2 at all.
Crucially, the application never trusts KV blindly. Every load function that reads from KV has a fallback: if the KV key is missing or the read fails, it falls back to querying Postgres directly, and — depending on the content type — will repopulate KV with the result. This means a KV outage or a cold key never results in a broken page; it just results in a slightly slower one, exactly once, until the cache is warm again.
Handling time-sensitive content differently.
Static content like a blog post is straightforward: it changes rarely, and when it does, the webhook keeps KV in sync almost immediately. Event listings needed a different approach, because “upcoming events” isn't really a property of any single record — it's a query result that changes simply with the passage of time, even if nothing in the database has been edited.
For this, individual event records are upserted into KV permanently and kept fresh via the webhook, exactly like blog posts — they have no TTL, because an event's own details don't go stale with time in the way a listing does. When an event is added or updated, that same webhook also invalidates the current cached listing, so the next request for the listings page is guaranteed to rebuild against the latest set of events rather than serve something we already know is out of date.
The listing itself — the computed set of “which events are upcoming right now” — is the one place we do apply a TTL, because it needs to expire on its own even when nothing has changed in the database. An event that started yesterday shouldn't still be showing up in a “what's on” list today, and no webhook fires just because time has passed. So the listing expires hourly; when a user hits the page and the cached list has expired, the load function fetches a fresh set from Postgres, serves it immediately, and repopulates KV in the background for the next visitor. Blog posts and their listings, by contrast, don't expire on a timer at all — they only change when someone publishes or edits something, so invalidation-on-write is sufficient and there's no need for a TTL. This is a variant of the stale-while-revalidate pattern: nobody waits on a slow recomputation, but the cache never drifts far from reality either.
Drawing the line between KV and Postgres.
The rule of thumb we apply is deliberately simple: KV is for public, static data — blog posts, case studies, event listings. Anything that needs complex querying, or is personalised to a specific user, stays in Postgres. KV is a key-value store, not a database; it has no concept of joins, filters, or relationships. The moment content needs to be queried in a non-trivial way, or is tied to an individual user's account, it doesn't belong in the cache layer at all — it belongs in Postgres, queried directly.
We did briefly consider Durable Objects, which are a better fit when you need strong consistency or coordination between requests — think collaborative editing or rate limiting — but they're a heavier tool than a read-heavy, mostly-static content problem calls for. In practice, KV's simplicity is exactly what we wanted: reads typically come back in around 20ms or less, it's trivial to keep in sync with a single webhook, and it doesn't ask us to reason about coordination at all.
The trade-off we accept: eventual consistency.
Nothing here is free. KV is eventually consistent — a write in one region can take up to around 60 seconds to propagate globally. In practice, that's a non-issue for the content we're putting through this pipeline. None of it is mission-critical or time-sensitive at the second-by-second level; nobody is going to notice or care if a newly published blog post takes up to a minute to appear for a visitor on the other side of the world. The pattern only works if you're honest with yourself about which data can tolerate that lag and which can't. Personal data, checkout flows, and anything transactional stays firmly on the direct-to-Postgres path, where consistency actually matters.
Keeping the webhook honest.
Because the webhook endpoint can write directly into our edge cache, it's a meaningful attack surface if left unguarded. We require a bearer token on every request — a long, securely generated secret that only Postgres knows — and reject anything that doesn't present it. Beyond that, the endpoint does nothing except validate, transform, and write; there's no arbitrary logic and minimal surface area for something to go wrong. If the webhook delivery fails for any reason, the fallback-to-Postgres behaviour in the read path means the site keeps serving correct data; it's just temporarily slower for that one piece of content until the next write or a manual re-sync.
I'll admit failure handling is the part of this system we'd most like to harden further. Right now there's no automatic retry or reconciliation job if a webhook delivery genuinely fails — the read-path fallback covers us, but it's a passive safety net rather than an active one. In over a year of running this in production, we haven't had a failure yet, but “hasn't happened” isn't the same as “can't happen”, and it's the next thing on my list to properly solve. I'll follow up on this piece once it's built.
What this has bought us.
In practice, this pattern has meant fast, consistent page loads across the projects where we've implemented it, while keeping a single, well-understood source of truth for every write. Reads from KV typically come back in around 20ms, regardless of where in the world the request originates — a meaningful improvement over a round trip to a single-region Postgres instance, even if I don't have hard numbers on that specific comparison across every global region we serve.
It's also had a real, measurable effect on cost. KV's free tier includes 100,000 reads a day; the paid Workers plan includes 10 million reads before usage-based pricing kicks in at 50¢ per million. Because only a fraction of requests — personal data, admin actions, cache misses — ever reach Postgres directly, we've meaningfully reduced connection load and query volume against Supabase. For projects with real global traffic, that's not a trivial saving.
More than anything, it's a reminder that “cache invalidation is hard” doesn't have to mean “avoid caching”. It means being deliberate about what you cache, how long you trust it, and what happens the moment it's wrong. Postgres tells the truth. KV tells it fast. And the fallback path makes sure our users never have to choose between the two.
