Cloudflare's Workers KV documentation gives you a number: a write may take up to 60 seconds to become visible in other locations. That number usually gets filed away as a worst-case replication delay and forgotten. It is more useful to read it as a cache TTL you are not permitted to lower — the cacheTtl option on get() defaults to 60 seconds and has a documented minimum of 60 seconds — and the cost it imposes on your application depends on something the docs do not put front and centre: how recently the key you just wrote was read in the location doing the reading.
Three published limits frame everything below.
- Propagation of a write to other locations: documented as up to 60 seconds.
-
get()withcacheTtl: default 60 seconds, minimum 60 seconds. There is no zero. - Writes to a single key: roughly one per second. KV is not a counter, and it is not a lock.
We did not run a global propagation benchmark for this article. A table of PoP-by-PoP timings measured from one machine on one afternoon would look like evidence and be worth very little — the number you actually care about is a distribution that moves with routing and cache occupancy. What follows is about the shape of the failure, which is stable, rather than the milliseconds, which are not.
The 60 seconds is a cache TTL, not a replication delay
There are two mechanisms sitting between your put() and someone else's get(), and they fail differently.
The first is propagation from KV's central store outward. The second is a read cache in front of it, local to the location serving the request. When a key is requested in a location that holds no cached copy, the read falls through toward the central store, and you often observe the new value well inside the 60-second window. When a key does have a cached copy in that location, you get the cached copy until its TTL expires, no matter how quickly the underlying propagation finished.
That inverts the intuition most caches train into you. Here, staleness scales with popularity. A key nobody reads is close to fresh on first access. A key read a thousand times a minute in Frankfurt is pinned to whatever Frankfurt last fetched. The keys carrying the highest staleness risk are exactly the ones you reached for KV to hold: feature flags, routing tables, config blobs, session lookups.
Two consequences follow directly.
Your staging environment lies to you. Low traffic means cold caches, which means reads-after-write that look fast and correct. The behaviour that bites you only appears under the read volume that keeps the cache warm, and that is production.
Per-key TTL also means per-key expiry. If a logical change spans two keys, they expire independently. A reader in one location can see the new value of key A and the old value of key B for tens of seconds. KV offers no cross-key atomicity and no transactions, and nothing in the API will warn you that you just wrote a change that cannot land atomically.
Treat the 60 as a design guideline from the docs rather than a guarantee. It is not an SLA figure, and under incident conditions the real window is unbounded — Cloudflare's published post-mortem for the 12 June 2025 outage describes a multi-hour KV disruption that propagated into other Cloudflare products built on KV. A design whose correctness rests on "it will be there in a minute" has no defined behaviour for the day it is not.
The failure that reads like data loss: a cached null
The pattern that generates support tickets is a uniqueness check written the obvious way:
const taken = await env.KV.get(`slug:${slug}`);
if (taken) return new Response('already taken', { status: 409 });
await env.KV.put(`slug:${slug}`, userId);
This has two defects, and the second is the expensive one.
The first is the familiar race: two concurrent requests both read null, both write, the later write wins, and the loser is never told. No error surfaces anywhere. You find it weeks later in a support thread.
The second is that a miss is cacheable. Cloudflare's KV documentation describes negative lookups as cached the same way hits are — the answer "this key does not exist" is itself an entry with a TTL. So the single-user, zero-concurrency path breaks too:
- The availability check reads
slug:acme, getsnull, and thatnullis now cached in the location serving that user. - The write succeeds.
- The confirmation page — same user, same location, seconds later — reads
slug:acme, hits the cachednull, and renders a not-found state.
The user watched the form succeed and then watched their thing fail to exist. That reads as data loss, and it self-heals in about a minute, which makes it close to impossible to reproduce on demand. Worth verifying negative-cache behaviour against the current docs before you build around it: the KV caching layer was rearchitected in 2024 and the details are not frozen.
Reading a key to check whether it is absent is the most costly thing you can do to a key you are about to write. If you must probe for absence, probe a store with read-after-write semantics — a Durable Object, or D1 with a
UNIQUEconstraint — and let KV serve only the reads that tolerate a stale answer.
Two fixes, in order of how much they buy you.
Do not re-read what you just wrote. After put(), return the value you already hold in memory. This sounds too obvious to write down until you notice how many frameworks POST, redirect, and then re-fetch — at which point the read is a fresh request that knows nothing about the write and goes straight to the local cache.
Make keys immutable and version the pointer. Write the payload under a content-addressed or versioned key such as config:v41, never overwritten, then update one small pointer key. A stale read then returns a coherent older version rather than a mixture. The window does not disappear; the failure mode changes from inconsistent to behind, and behind is something you can reason about, display, and alert on.
If that migration means touching every KV call site in a codebase, it is grep-able, mechanical work — the kind worth handing to an agent under one clear rule (no get() on a key this request writes) rather than doing by hand across forty files.
What we would reach for instead, and the condition that flips it
KV stays the right default when reads vastly outnumber writes, values are whole documents fetched by key, and a stale answer costs nothing worse than a slightly old page. Published paid-plan rates are $0.50 per million reads and $5.00 per million writes, deletes and lists, with a free tier of 100,000 reads and 1,000 writes per day. Read-heavy workloads are cheap here in a way strongly consistent stores are not, and that is the actual reason to accept the window.
The condition that flips it is narrow and absolute: correctness depends on a read reflecting your own write, or two keys must change together. Then use a Durable Object. One object per entity gives you single-threaded, strongly consistent access, and the price is a network hop to that object's home region — a read KV would serve locally in single-digit milliseconds can become a cross-continent round trip. For a global read path that is a real regression, which is why the usual answer is both: a Durable Object or D1 as the system of record, KV as the read-optimised projection in front of it, and a version pointer so you can measure how far behind the projection is.
If you cannot currently say which of your KV keys are read-after-write critical, that inventory is the work to do before the next incident rather than after it.
Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.
Top comments (1)
Checked the three limits in the intro against the docs. Two hold. The
cacheTtlfloor is 30, not 60:KV read docs
Staleness scaling with popularity holds either way, but "a cache TTL you are not permitted to lower" overstates it.