Handling KV Eventual Consistency in Edge Reads

This guide is part of Edge Caching & CDN Integration. It addresses one specific and very common surprise: you write a value to Workers KV, read it back a moment later from another PoP, and get the old value — not because anything failed, but because that is exactly what KV promises.

KV is not a database that happens to be slow to update. It is a globally replicated read cache whose consistency model is stated up front: a write is durable immediately at the central store, and it becomes visible everywhere else eventually, with an upper bound measured in tens of seconds. Every bug in this article comes from code written as though that sentence said something else.

The problem

A user updates their display name. The PUT handler writes to KV, returns 200, the client refetches the profile, and the old name comes back. You reload and it is still wrong. Thirty seconds later it is right. Nothing is in your logs, because nothing failed.

Or: a feature flag is toggled off during an incident. Half your edge nodes honour it within a second; the rest keep serving the feature for another minute, so your incident dashboard shows a partial, flapping rollback that looks far more alarming than the truth.

Or worse: two writes to the same key land within the same second from two regions, and the value that survives is the one that reached the central store last — which is not necessarily the one that was issued last, and definitely not necessarily the one you wanted.

Root cause: KV is an eventually consistent read replica, not a shared variable

Workers KV is architected for one access pattern: extremely high-volume reads of values that change rarely. To make reads fast in every region it caches values in the PoP that served them, and it fills those caches from a central store asynchronously. Cloudflare documents the global propagation window as up to 60 seconds, and in practice most of the world sees a new value far faster than that — but “usually fast” is not a guarantee you can build correctness on.

Three consequences follow, and each one produces a distinct class of bug.

Read-your-own-writes does not hold. The isolate that performed the write has no privileged view. A read issued microseconds later, even in the same request, may be served from that PoP’s cached copy of the old value. This is the single most common KV surprise, and it is aggravated by cacheTtl: a high cacheTtl means the PoP will not even ask the central store again until the TTL elapses.

Last-write-wins is by arrival, not by intent. KV has no compare-and-swap, no conditional put, no transactions. Concurrent writers silently clobber each other, and the winner is decided by ordering at the central store. Any write that reads a value, modifies it and writes it back — a counter, a list append, a “toggle” — is a lost-update waiting to happen.

Negative results propagate too. A delete is a write. A key you removed can still be readable elsewhere for the same window, and a key you just created can read as null elsewhere for the same window. Code that treats null as “definitely absent” will make wrong decisions during the propagation window.

If you need strong consistency, KV is the wrong tool and no amount of tuning fixes it — that is what Durable Objects exist for, as compared in KV vs Durable Objects for edge state. What follows is how to keep KV’s read performance while making the inconsistency window harmless.

The KV propagation window A write is acknowledged immediately at the central store. The writing PoP, a nearby PoP and a distant PoP each begin serving the new value at different times within a window that can extend to sixty seconds. t = 0 +10 s +30 s +60 s put() acknowledged central store new value durable from t = 0 writing PoP new value nearby PoP stale value served distant PoP stale value served documented worst case: reads may be stale anywhere in this window
Durability and visibility are different guarantees. KV gives you the first immediately and the second eventually.

Step 1 — Tune cacheTtl deliberately, per read

cacheTtl controls how long the PoP that served a read may reuse its cached copy before consulting the central store again. It defaults to 60 seconds and has a floor of 60 seconds on Workers KV, so it is a lever on the tail of staleness, not a way to eliminate it.

// lib/kv-read.ts

export interface ReadEnv {
  DATA: KVNamespace;
}

/** Values that change rarely and tolerate a long tail of staleness. */
export async function readStatic<T>(env: ReadEnv, key: string): Promise<T | null> {
  return env.DATA.get<T>(key, { type: 'json', cacheTtl: 3600 });
}

/** Values where a minute of staleness is the most you will accept. */
export async function readVolatile<T>(env: ReadEnv, key: string): Promise<T | null> {
  return env.DATA.get<T>(key, { type: 'json', cacheTtl: 60 });
}

The important discipline is that cacheTtl is a per-read option, not a namespace setting. Long-lived reference data — locale tables, routing maps, published content — should use a long cacheTtl because a re-read costs a central round trip of tens of milliseconds and buys nothing. Anything the user can change should use the floor. Mixing them under one default is how a config table ends up pinned stale for an hour.

Raising cacheTtl also raises your effective inconsistency window past 60 seconds, because the PoP will not look for the new value until the TTL expires. If you have set cacheTtl to an hour and are wondering why a change has not propagated, that is the answer.

Step 2 — Make new content unreachable under an old key

The standard workaround for eventual consistency is to stop mutating keys at all. Instead of overwriting profile:u42, write a new immutable key profile:u42:v7 and change which version readers ask for. A key that is never overwritten can never be read stale — the only two outcomes are the correct value or null.

// lib/versioned-key.ts

export interface VersionedRef {
  base: string;
  version: number;
}

export function dataKey(ref: VersionedRef): string {
  return `${ref.base}:v${ref.version}`;
}

export function pointerKey(base: string): string {
  return `${base}:current`;
}

export function parseKey(key: string): VersionedRef | null {
  const match = /^(.*):v(\d+)$/.exec(key);
  if (!match) return null;
  const version = Number(match[2]);
  return Number.isSafeInteger(version) && version >= 0
    ? { base: match[1], version }
    : null;
}

/**
 * Writes an immutable version, then advances the pointer.
 * Order matters: the pointer must never name a version that is not written.
 */
export async function writeVersion<T>(
  env: ReadEnv,
  base: string,
  version: number,
  value: T,
): Promise<VersionedRef> {
  const ref: VersionedRef = { base, version };
  await env.DATA.put(dataKey(ref), JSON.stringify(value), {
    // Keep old versions readable long enough for in-flight readers.
    expirationTtl: 86_400,
  });
  await env.DATA.put(pointerKey(base), String(version));
  return ref;
}

/**
 * Reads a specific version if the caller knows one, otherwise follows the
 * pointer. `known` is how you get read-your-own-writes: the writer hands the
 * version back to the client, which sends it on the next request.
 */
export async function readVersioned<T>(
  env: ReadEnv,
  base: string,
  known?: number,
): Promise<{ value: T | null; version: number | null }> {
  if (known !== undefined) {
    const value = await env.DATA.get<T>(dataKey({ base, version: known }), {
      type: 'json',
      cacheTtl: 3600, // immutable — cache it hard
    });
    if (value !== null) return { value, version: known };
    // Fall through: the version may not have propagated to this PoP yet.
  }

  const pointer = await env.DATA.get(pointerKey(base), { cacheTtl: 60 });
  if (pointer === null) return { value: null, version: null };

  const version = Number(pointer);
  const value = await env.DATA.get<T>(dataKey({ base, version }), {
    type: 'json',
    cacheTtl: 3600,
  });
  return { value, version };
}

Two details carry the whole design. First, write the data before the pointer. If the pointer advances first, a reader in a PoP that has already seen the new pointer but not the new data reads null — a hard failure instead of a soft staleness. Written in the other order the worst case is that a reader follows an old pointer and gets an older but complete value.

Second, the pointer is itself a KV key and is itself eventually consistent. Versioning does not remove the window; it converts “wrong value” into “older but coherent value,” which is a far better failure mode. To actually get read-your-own-writes, the writer must return the version it just created and the client must send it back — a header, a cookie, or a field in the response body. Then readVersioned asks for that exact immutable key and, if it has propagated, gets it; if not, it falls back to the pointer rather than erroring.

Anatomy of a versioned key scheme A pointer key names the current version number while each version is stored under its own immutable key. Immutable keys can be cached for an hour; only the small pointer needs a short cache TTL. profile:u42:current value = "7" • mutable cacheTtl 60 s, ~8 bytes profile:u42:v5 — superseded still readable, expires in 24 h profile:u42:v6 — superseded still readable, expires in 24 h profile:u42:v7 — current immutable, cacheTtl 3600 s names Client-held version x-profile-version: 7 skips the pointer entirely gives read-your-own-writes
Only the pointer is mutable, so only the pointer needs a short cache TTL — the bytes that matter are cached for an hour.

Step 3 — Put a Durable Object in front when you need an authority

Versioned keys make staleness coherent, but they still cannot answer “what is the true current value, right now?” A Durable Object can: each object is a single-threaded, single-instance actor with strongly consistent storage, so reads and writes against it are serialized. The productive pattern is Durable Object as authority, KV as read replica: writes go to the DO, which owns the version counter and then mirrors the new version into KV; the overwhelming majority of reads never touch the DO at all.

// do/profile-authority.ts

interface ProfileState {
  version: number;
  value: unknown;
}

export class ProfileAuthority {
  constructor(
    private state: DurableObjectState,
    private env: { DATA: KVNamespace },
  ) {}

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === 'POST' && url.pathname === '/write') {
      const { base, value, requestId } = (await request.json()) as {
        base: string;
        value: unknown;
        requestId: string;
      };

      // Idempotency: the same requestId must never advance the version twice.
      const seen = await this.state.storage.get<number>(`req:${requestId}`);
      if (seen !== undefined) {
        return Response.json({ version: seen, replayed: true });
      }

      const current = (await this.state.storage.get<ProfileState>('state')) ?? {
        version: 0,
        value: null,
      };
      const next: ProfileState = { version: current.version + 1, value };

      // Storage writes inside a DO are serialized and strongly consistent.
      await this.state.storage.put('state', next);
      await this.state.storage.put(`req:${requestId}`, next.version, {
        expirationTtl: 86_400,
      });

      // Mirror into KV for the read path. Data first, pointer second.
      await this.env.DATA.put(`${base}:v${next.version}`, JSON.stringify(value), {
        expirationTtl: 86_400,
      });
      await this.env.DATA.put(`${base}:current`, String(next.version));

      return Response.json({ version: next.version, replayed: false });
    }

    if (url.pathname === '/authoritative') {
      const current = await this.state.storage.get<ProfileState>('state');
      return Response.json(current ?? { version: 0, value: null });
    }

    return new Response('not found', { status: 404 });
  }
}

The requestId check is what makes the write idempotent, and idempotency is not optional here. Edge writes get retried — by clients on a timeout, by your own retry wrapper on a 5xx, by a user double-tapping a button. Without a dedupe key a retry allocates a second version of identical content, which is wasteful but harmless; with a read-modify-write payload it can double-apply a change, which is not. Give every mutation a client-generated id and store it beside the state.

Route reads to the DO only when a caller explicitly needs the authoritative answer — a checkout total, a permission check, a quota decision. Everything else reads KV. A DO read is a network hop to a single location on the planet, so a page that routes all its reads through one costs you the entire latency advantage of running at the edge.

Durable Object authority with KV as read replica Writes travel from edge middleware to a single Durable Object which increments the version, stores state and mirrors the new version into KV. Reads are served from the local KV replica, with a rare authoritative path back to the Durable Object. Write request POST, requestId Durable Object single instance, serialized owns version counter KV central store v(n) then pointer PoP replica — ams sub-millisecond read PoP replica — gru sub-millisecond read Read request 99% local, 1% authoritative async fan-out rare: needs truth
The Durable Object is on the write path only; the read path keeps KV's latency and gains a version number it can trust.

Step 4 — Wire the version through the response

The last piece is returning the version to the client so the next read can be exact.

// middleware.ts
import { readVersioned } from './lib/versioned-key';

interface Env {
  DATA: KVNamespace;
  PROFILE: DurableObjectNamespace;
}

export default async function middleware(request: Request, env: Env): Promise<Response> {
  const url = new URL(request.url);
  const userId = url.pathname.split('/')[2];
  const base = `profile:${userId}`;

  if (request.method === 'PUT') {
    const stub = env.PROFILE.get(env.PROFILE.idFromName(base));
    const res = await stub.fetch('https://do/write', {
      method: 'POST',
      body: JSON.stringify({
        base,
        value: await request.json(),
        requestId: request.headers.get('idempotency-key') ?? crypto.randomUUID(),
      }),
    });
    const { version } = (await res.json()) as { version: number };

    // Hand the version back so the client's next GET is exact.
    return Response.json({ ok: true, version }, {
      headers: { 'x-profile-version': String(version), 'cache-control': 'no-store' },
    });
  }

  const hinted = request.headers.get('x-profile-version');
  const { value, version } = await readVersioned(env, base, hinted ? Number(hinted) : undefined);

  return Response.json(value, {
    headers: {
      'x-profile-version': version === null ? 'none' : String(version),
      'cache-control': 'private, max-age=0, must-revalidate',
    },
  });
}

crypto.randomUUID is part of Web Crypto and available in every edge runtime; it is not the Node crypto module and needs no polyfill. Bindings are declared in wrangler.toml:

name = "profile-edge"
main = "src/middleware.ts"
compatibility_date = "2026-07-01"

[[kv_namespaces]]
binding = "DATA"
id = "your-namespace-id"

[[durable_objects.bindings]]
name = "PROFILE"
class_name = "ProfileAuthority"

[[migrations]]
tag = "v1"
new_classes = ["ProfileAuthority"]

Local versus production divergence

Behaviour Local dev (wrangler dev) Edge production
KV read after write Immediately visible — local SQLite backing Up to 60 s before all PoPs see it
cacheTtl Effectively ignored Governs when the PoP re-consults the central store; 60 s floor
Number of replicas One Hundreds, each with independent cache state
Concurrent writers Serialized by a single process Genuinely concurrent; last arrival at the central store wins
Durable Object location In-process One instance, in one region, for the whole planet
Delete visibility Immediate Same propagation window as a write
List operations Consistent May omit very recent writes

The first row is the reason nearly every eventual-consistency bug ships. Locally, KV behaves like a strongly consistent store, so read-your-own-writes appears to work perfectly and the code that depends on it looks correct. Assume the local behaviour is a lie and write tests that model the window explicitly, which is what the next section does. The same caution applies to emulation generally — see local emulation with wrangler dev vs production.

Validating with Vitest

You cannot test a real propagation delay in a unit test, but you can test against a fake KV that models one. Give the fake a controllable visibility lag and assert that your read path still returns something coherent.

// kv-consistency.test.ts
import { describe, expect, it, vi } from 'vitest';
import { dataKey, pointerKey, parseKey, readVersioned, writeVersion } from './lib/versioned-key';

/** A KV stand-in where writes become visible only after `lagMs`. */
function laggyKv(lagMs: number, clock: () => number) {
  const store = new Map<string, { value: string; visibleAt: number }>();
  return {
    store,
    async put(key: string, value: string) {
      store.set(key, { value, visibleAt: clock() + lagMs });
    },
    async get(key: string, opts?: { type?: 'json' }) {
      const entry = store.get(key);
      if (!entry || clock() < entry.visibleAt) return null;
      return opts?.type === 'json' ? JSON.parse(entry.value) : entry.value;
    },
  };
}

describe('versioned keys', () => {
  it('round-trips a key through parse', () => {
    expect(parseKey(dataKey({ base: 'profile:u42', version: 7 }))).toEqual({
      base: 'profile:u42',
      version: 7,
    });
  });

  it('rejects a malformed or unversioned key', () => {
    expect(parseKey('profile:u42')).toBeNull();
    expect(parseKey('profile:u42:vX')).toBeNull();
  });

  it('writes the data key before the pointer', async () => {
    const order: string[] = [];
    const env = {
      DATA: {
        put: vi.fn(async (key: string) => { order.push(key); }),
        get: vi.fn(),
      },
    } as never;

    await writeVersion(env, 'profile:u42', 7, { name: 'Ada' });
    expect(order).toEqual([dataKey({ base: 'profile:u42', version: 7 }), pointerKey('profile:u42')]);
  });
});

describe('reads during the propagation window', () => {
  it('returns the previous coherent value, never a torn one', async () => {
    let now = 0;
    const kv = laggyKv(30_000, () => now);
    const env = { DATA: kv } as never;

    await writeVersion(env, 'profile:u42', 1, { name: 'Ada' });
    now = 40_000; // v1 fully propagated
    await writeVersion(env, 'profile:u42', 2, { name: 'Grace' });

    // Still inside v2's window: the pointer has not moved yet here.
    const during = await readVersioned<{ name: string }>(env, 'profile:u42');
    expect(during.value).toEqual({ name: 'Ada' });
    expect(during.version).toBe(1);

    now = 80_000;
    const after = await readVersioned<{ name: string }>(env, 'profile:u42');
    expect(after.value).toEqual({ name: 'Grace' });
    expect(after.version).toBe(2);
  });

  it('falls back to the pointer when a hinted version has not propagated', async () => {
    let now = 0;
    const kv = laggyKv(30_000, () => now);
    const env = { DATA: kv } as never;

    await writeVersion(env, 'profile:u42', 1, { name: 'Ada' });
    now = 40_000;
    await writeVersion(env, 'profile:u42', 2, { name: 'Grace' });

    // Client hints v2, which this PoP cannot see yet — must not return null.
    const result = await readVersioned<{ name: string }>(env, 'profile:u42', 2);
    expect(result.value).toEqual({ name: 'Ada' });
  });

  it('never resurrects a version that was never written', async () => {
    const kv = laggyKv(0, () => 0);
    const env = { DATA: kv } as never;
    expect(await readVersioned(env, 'profile:u99')).toEqual({ value: null, version: null });
  });
});

The second read test is the one that earns its keep. A naive implementation returns null when the hinted version is not yet visible, and null renders as an empty profile page — a much worse user experience than showing data that is thirty seconds old.

Common pitfalls and resolutions

Read-your-own-writes fails right after a PUT — you are reading a mutable key and hoping. Return the new version from the write and have the client send it back, or read from the Durable Object for that one request.

A change propagates in seconds for some users and an hour for others — a long cacheTtl on the read. The PoP will not re-consult the central store until it expires. Lower cacheTtl on mutable keys and keep the long values for immutable ones.

A counter loses increments — read-modify-write against KV has no compare-and-swap, so concurrent writers clobber each other. Move the counter into a Durable Object; that is precisely the workload it exists for.

A reader gets null immediately after a successful write — the pointer was written before the data. Always write the immutable version first and advance the pointer second.

A retried write creates two versions with the same content — the mutation has no idempotency key. Generate one per logical mutation client-side and dedupe on it inside the Durable Object.

Deleted content is still served for a minute — a delete is a write and propagates on the same schedule. If removal must be immediate, advance the pointer to a tombstone version rather than deleting the data key.

Every read got slow after adding the Durable Object — read traffic is being routed to a single instance in a single region. Route only authoritative reads there and serve the rest from KV.

Production deployment checklist

Frequently Asked Questions

How long does a Workers KV write take to become globally visible?

Cloudflare documents the propagation window as up to sixty seconds. In practice most regions see a new value much sooner, but the documented upper bound is the only number you can safely design against. A high cacheTtl on the read extends that window further, because the PoP will not re-consult the central store until its cached copy expires.

Why does read-your-own-writes fail even in the same request?

Because the isolate that performed the write has no privileged view of KV. The read is served by that PoP’s cached copy, which may still hold the previous value. The fix is to return the new version identifier from the write and read that exact immutable key afterwards, or to read from a Durable Object when you need the authoritative answer.

Do versioned keys eliminate the inconsistency window?

No, they change its failure mode. Because a versioned key is never overwritten, a read of it can only return the correct value or nothing at all. The pointer that names the current version is still eventually consistent, so a reader may follow an older pointer and see an older but internally coherent value rather than a wrong one.

When should I use a Durable Object instead of KV?

Whenever correctness depends on ordering or on reading the true current value: counters, quotas, locks, anything that reads a value, modifies it and writes it back. Durable Object storage is strongly consistent and serialized per object. Keep the high volume read path on KV, because a Durable Object read is a network hop to one region.

What is the right cacheTtl to set?

Set it per read rather than picking one default. Immutable versioned data can use an hour or more, since re-reading it can never yield anything different. Mutable keys such as a version pointer should sit at the sixty second floor. Mixing the two under a single long default is how a configuration change ends up pinned stale.

Why do writes need an idempotency key?

Because edge writes get retried by clients on timeouts, by retry wrappers on server errors, and by users pressing a button twice. Without a dedupe key each retry allocates a new version, and for any read modify write payload it can apply the same change twice. Generate one identifier per logical mutation and record it alongside the state.