KV vs Durable Objects for Edge State

This guide is part of KV and Durable Object Caching at the Edge. It is a decision framework for the recurring question: should this piece of edge state live in Cloudflare Workers KV or in a Durable Object? The answer hinges on consistency, latency, write throughput, and cost — and choosing wrong produces stale reads, lost updates, or a bill you did not expect.

The problem

You are storing something from a Worker — a counter, a session flag, a cached fragment, a feature-flag map, a rate-limit window — and Cloudflare gives you two primitives that both look like “edge storage.” The KV store is the obvious default because it is simple and globally fast. But a class of workloads silently breaks on KV: anything that needs to read its own writes, increment atomically, or serialize access. Those belong in a Durable Object. Picking the right one upfront is far cheaper than migrating after a data-integrity incident.

Root cause: two different consistency models

KV and Durable Objects are not two flavors of the same database. They are built on opposite trade-offs.

KV is a globally replicated, read-optimized, eventually consistent store. A read is served from a replica in the local Point of Presence, so it is fast everywhere — single-digit milliseconds when warm. The cost is consistency: a write propagates to all edge locations within roughly 60 seconds, and read-after-write is not guaranteed even in the same isolate. Writes to a single key are rate-limited to about one per second. KV shines for read-heavy data that tolerates short staleness.

A Durable Object is a single, globally unique instance addressed by id. Every request for a given id is routed to the same instance, so all access is serialized and strongly consistent. You can read your own writes immediately, increment a counter without races, and hold a lock. The cost is locality: that single instance lives in one place, so a request from a distant region pays an extra network hop, and you pay for the duration the object stays active plus its storage operations.

KV versus Durable Object access models KV serves reads from local replicas in every PoP with eventual consistency. A Durable Object routes every request for an id to one strongly consistent instance. KV (eventual, global) PoP A PoP B replicas Durable Object (strong) PoP A PoP B one instance serialized writes
KV gives every PoP a fast local replica at the cost of consistency; a Durable Object funnels every request for an id to one consistent instance at the cost of a hop.

The decision table

Dimension Cloudflare KV Durable Object
Consistency Eventual (~60s global propagation) Strong (single instance)
Read-your-writes Not guaranteed Guaranteed
Warm read latency ~1–10 ms at local PoP local PoP fast; cross-region adds a hop
Write throughput per key/id ~1 write/s per key high; serialized, no per-key cap
Atomic increment / compare-and-set No (races) Yes
Value / state size 25 MB per value SQLite-backed storage, large
Best for read-heavy cached data counters, locks, coordination, sessions
Cost shape per read/write/list operation per active duration + storage ops
Global reach replicated everywhere automatically one location per id

Step 1: Classify the access pattern

Ask three questions in order.

  1. Does correctness depend on reading your own writes immediately? If yes → Durable Object. A rate-limit counter, a seat-reservation tally, or a one-time-token check cannot tolerate the ~60s KV window.
  2. Do many writers touch the same key concurrently? If yes → Durable Object. KV’s ~1 write/s per-key limit and lack of atomic increment make concurrent writers lose updates.
  3. Is it read-heavy, shared, and tolerant of short staleness? If yes → KV. Cached API responses, rendered fragments, and feature-flag maps that change rarely are textbook KV.

Step 2: Sketch the KV implementation

KV is the right call for a feature-flag map read on every request and updated rarely:

interface Env {
  FLAGS: KVNamespace;
}

export default {
  async fetch(_req: Request, env: Env): Promise<Response> {
    // Eventual consistency is fine: a flag change appearing within ~60s is acceptable.
    const flags = await env.FLAGS.get<Record<string, boolean>>("global", "json");
    const enabled = flags?.newCheckout ?? false;
    return new Response(JSON.stringify({ enabled }), {
      headers: { "content-type": "application/json" },
    });
  },
};

There is no coordination here — just a fast global read. KV is the cheaper, simpler choice.

Step 3: Sketch the Durable Object implementation

A per-user request counter must be exact, so it lives in a Durable Object addressed by user id:

export class RequestCounter implements DurableObject {
  constructor(private state: DurableObjectState) {}

  async fetch(_request: Request): Promise<Response> {
    // Serialized access: no two requests for this id run concurrently here.
    const current = (await this.state.storage.get<number>("count")) ?? 0;
    const next = current + 1;
    await this.state.storage.put("count", next);
    return new Response(JSON.stringify({ count: next }), {
      headers: { "content-type": "application/json" },
    });
  }
}

interface Env {
  COUNTER: DurableObjectNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const userId = new URL(request.url).searchParams.get("user") ?? "anon";
    const id = env.COUNTER.idFromName(userId); // same user → same instance
    return env.COUNTER.get(id).fetch(request);
  },
};

Because idFromName(userId) deterministically maps a user to one instance, the read-modify-write is race-free. The same pattern underpins token-bucket rate limiting at the edge.

Local vs production divergence

Behavior wrangler dev --local Production
KV consistency Immediate (in-memory) Eventual, up to ~60s
KV write rate limit Not enforced ~1 write/s per key
Durable Object routing Single local instance Single global instance per id, possible cross-region hop
Durable Object storage In-memory or local SQLite Persistent, transactional

The trap is testing a concurrent-write workload on KV locally, seeing it pass because local KV is immediately consistent, and shipping a race that only appears under production’s eventual model.

Validation with Vitest

Assert the Durable Object increments atomically under sequential calls:

import { env, runInDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("RequestCounter", () => {
  it("increments without losing updates", async () => {
    const id = env.COUNTER.idFromName("user-1");
    const stub = env.COUNTER.get(id);
    const r1 = await stub.fetch("https://do/").then((r) => r.json());
    const r2 = await stub.fetch("https://do/").then((r) => r.json());
    expect(r1).toEqual({ count: 1 });
    expect(r2).toEqual({ count: 2 });
  });
});

Pitfalls

  • Using KV as a counter. Concurrent get then put on KV loses increments and trips the ~1 write/s limit. Counters belong in a Durable Object.
  • Putting read-heavy global config in a Durable Object. A single instance becomes a global bottleneck and a cross-region hop for every reader. Use KV for read-mostly shared data.
  • Ignoring Durable Object cost shape. You pay for active duration; a chatty object kept alive by constant traffic costs more than the equivalent KV reads. Model it.
  • Assuming KV read-after-write. Even same-isolate reads after a KV write may return the old value. Never gate logic on it.
  • One Durable Object for everything. Sharding all state into a single id serializes unrelated traffic. Address objects by a natural key (user, room, tenant) to spread load.

Production deployment checklist

Frequently Asked Questions

Can I use KV for a rate limiter?

Only for coarse, approximate limits that tolerate the ~60s consistency window and the ~1 write/s per-key cap. For accurate per-user or per-IP limiting, use a Durable Object, which serializes the read-modify-write so counts cannot race. KV will undercount under concurrency.

Are Durable Objects slower than KV?

For a reader near the object’s location, a Durable Object is comparable to KV. The difference appears across regions: KV serves from a local replica everywhere, while a Durable Object lives in one place, so a distant request pays a network hop. Trade that latency for the strong consistency the object provides.

Which is cheaper?

It depends on shape. KV bills per read, write, and list operation and is economical for read-heavy workloads. Durable Objects bill for active duration plus storage operations, which can be cheaper for write-heavy coordination but more expensive for an always-on object kept alive by constant traffic. Model your actual access pattern.

Can I combine both?

Yes, and it is common. Use a Durable Object as the consistent write coordinator and KV as the globally replicated read cache it populates. Writes serialize through the object; reads fan out cheaply from KV. This pattern gives correctness on the write path and locality on the read path.