Serializing Per-Key Edge State with Cloudflare Workers Durable Objects
Durable Objects serialize mutations for a single object ID at the edge, preventing lost updates without an external lock.
09 Feb 2026, 03:43 UTC

A lost increment under burst traffic
You deploy a stateless Cloudflare Worker to count requests per customer ID. Under light traffic it works. Under burst traffic, two requests for the same customer arrive at different edge locations, read the same counter value from KV, both write n + 1, and one increment is lost.
The useful takeaway: Durable Objects serialize mutations for a single object ID by running that object on one CPU thread, so per-key state stays consistent without an external lock or database.
How a Durable Object serializes one key
A Durable Object is a durable, single-threaded compute unit tied to an object ID. The Worker front end remains stateless, but it looks up the object by ID and sends messages through a stub. Because all requests for the same ID are handled on the same thread, reads and writes cannot interleave.
Terms used here
- Stateless Worker: a Worker with no persistent memory between requests; any request may run on a different isolate.
- Durable Object: a stateful, single-threaded object bound to an ID and persisted by the platform.
- Object ID: the sharding key that determines which object instance handles a request.
- Stub: the client-side handle a Worker uses to send requests to a Durable Object.
Worked example: a per-key counter
This example keeps a simple integer counter for each customer ID. All mutations go through one Durable Object per customer.
wrangler.toml
[[durable_objects.bindings]]
name = "COUNTER_DO"
class_name = "Counter"
Durable Object class
export class Counter {
constructor(state, env) {
this.state = state;
this.storage = state.storage;
}
async fetch(request) {
const url = new URL(request.url);
const action = url.searchParams.get("action");
if (action === "increment") {
const current = (await this.storage.get("count")) ?? 0;
await this.storage.put("count", current + 1);
return new Response(String(current + 1));
}
if (action === "get") {
const current = (await this.storage.get("count")) ?? 0;
return new Response(String(current));
}
return new Response("unknown action", { status: 400 });
}
}
Worker front end
export default {
async fetch(request, env) {
const url = new URL(request.url);
const customerId = url.searchParams.get("customer") || "default";
const id = env.COUNTER_DO.idFromName(customerId);
const stub = env.COUNTER_DO.get(id);
const response = await stub.fetch("http://counter.internal/?action=increment");
return response;
},
};
Run and verify locally
- Open a terminal in the project directory.
- Run
wrangler dev. You need a Cloudflare API token with Workers script edit permission for your account. - Send concurrent requests to the same key:
for i in $(seq 1 50); do curl "http://127.0.0.1:8787/?customer=acme&action=increment" & done; wait
Then read the count:
curl "http://127.0.0.1:8787/?customer=acme&action=get"
Expected check: if the counter started at zero, the final value should be 50. The Durable Object for acme applies increments one at a time, so no update is overwritten. This describes intended behavior, not an executed test.
Risk: this command mutates state. Run it against a local or test binding, not a production counter you intend to keep unchanged.
Trade-offs and limitations
- Durable Objects cost more and start slower than stateless Workers, so use them only when ordering or strong consistency matters.
- The single-threaded guarantee applies per object ID, not globally. Coordinating across many objects still requires explicit design.
- Per-object memory and storage are bounded. Large state should be externalized to R2 or D1, which adds complexity.
Rollback
Removing the [[durable_objects.bindings]] block from wrangler.toml and redeploying removes the front-end binding. Existing Durable Object data remains in the namespace until you delete it through the Cloudflare dashboard or API.
When to use it
Choose Durable Objects for per-key counters, rate limiters, queue tails, or session state where lost updates are unacceptable. Choose stateless Workers with KV for eventually consistent reads or cheap, high-volume caching. Verify your choice by sending concurrent writes to the same key and confirming the final value matches the request count.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.