Edge functions run everywhere and remember nothing. Durable Objects give you one addressable, single-threaded instance with transactional storage — the missing source of truth.
We had a rate limiter that let through roughly triple the traffic it was supposed to. The logic was fine. The problem was that our limiter counted requests in a Worker, and Workers run in whatever point of presence is closest to the user. A burst from a single client hitting three different PoPs got counted three separate times, each below the threshold, none the wiser. Nothing was broken. There was just no single place that knew the real number.
That is the shape of every edge state problem. Edge functions are stateless by design and run in hundreds of locations at once, which is exactly why they're fast. But anything that needs one true answer — a counter, a rate limit, a chat room's member list, a lock, who owns a WebSocket connection — races the moment you spread it across PoPs. You can push the truth back to a central database, but then you've paid a round trip to a single region and thrown away the reason you went to the edge.
A Durable Object is one instance, identified by an ID, that lives in exactly one place at a time. Every request addressed to that ID routes to that same instance, no matter which PoP it came from. The instance is single-threaded, so inside it you get to reason about state the way you would in a normal program: no two requests run its code at the same moment. It has in-memory state that survives between requests and a transactional storage API that survives restarts and moves.
That combination is the whole trick. The counter that was racing across PoPs becomes a single object addressed by client IP. All three requests from that burst route to the same instance, get serialized, and see the same number. The race is gone because there is now exactly one thing doing the counting.
Objects get created lazily the first time you address an ID, hibernate when idle, and wake up with their storage intact. Cloudflare places each one near where it's used and can migrate it if traffic shifts.
Real-time collaboration is the clean fit: one object per document or room holds the live state and fans updates out to connected clients. Per-user or per-tenant state — a session, a shopping cart, a game lobby — maps to one object per user. Distributed locks and leader election work because single-threaded access is a mutex you didn't have to build. Coordination in general: anything where you'd otherwise reach for Redis and a lot of hope.
Here's the rate limiter, rebuilt as an object. The class holds a fetch handler and reads and writes through its storage.
// src/rate-limiter.ts
export class RateLimiter {
state: DurableObjectState;
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const now = Date.now();
const windowMs = 60_000;
const limit = 100;
// Reads are fast: storage is local to this instance.
let hits = (await this.state.storage.get<number[]>("hits")) ?? [];
hits = hits.filter((t) => now - t < windowMs);
if (hits.length >= limit) {
return new Response("rate limited", { status: 429 });
}
hits.push(now);
await this.state.storage.put("hits", hits);
return new Response("ok", { status: 200 });
}
}
The Worker in front of it gets a stub for the object by deriving an ID from the client, then forwards the request:
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const ip = request.headers.get("cf-connecting-ip") ?? "anon";
const id = env.RATE_LIMITER.idFromName(ip);
const stub = env.RATE_LIMITER.get(id);
return stub.fetch(request);
},
};
interface Env {
RATE_LIMITER: DurableObjectNamespace;
}
idFromName(ip) is the important line: the same IP always maps to the same object, so every request from that client lands on one instance. The binding and the migration that registers the class live in wrangler.toml:
name = "edge-limiter"
main = "src/index.ts"
compatibility_date = "2026-06-01"
[[durable_objects.bindings]]
name = "RATE_LIMITER"
class_name = "RateLimiter"
[[migrations]]
tag = "v1"
new_classes = ["RateLimiter"]
Migrations are how Cloudflare tracks class changes over deploys — you add a new tag whenever you rename or delete a class, not for ordinary code edits.
Single-threaded is the feature and the ceiling. One object handles its requests one at a time, so a hot object is a throughput bottleneck. If a single key sees tens of thousands of writes a second, shard it across several objects and aggregate, or you'll queue behind yourself.
Locality is real but not free. An object sits in one region. A user on the far side of the planet pays the round trip to reach it. Pick your ID scheme so objects sit near the traffic that uses them — per-room objects tend to cluster naturally, global singletons do not.
Cost shows up as requests, duration, and storage operations, and it's easy to underestimate the storage ops when every request does a read and a write. Batch where you can.
And don't reach for a Durable Object as a read cache. High-fanout reads of rarely-changing data belong in KV or the cache, which replicate everywhere. Funneling a million reads through one single-threaded instance throws away the edge entirely.
If you have genuine coordination — a counter, a lock, a room, a rate limit that has to be correct — Durable Objects are the cleanest answer we've used at the edge, and they replaced a Redis cluster we were tired of babysitting. If you're only caching or serving read-heavy data, stay in KV. The test we apply: does this need one thing to be the source of truth? If yes, give it an object. If no, keep it stateless and fast.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Buffered SSR makes users wait for the slowest query before they see anything. Streaming from the edge flips that — send the shell now, fill the gaps as data lands.
A shared API key between two internal services proves nothing about who is calling. mTLS makes every service present a cryptographic identity instead.
Explore more articles in this category
The edge is fast because it's constrained. This is the decision map for what belongs at the edge, what belongs at origin, and how compute, data, caching, and auth fit together.
Static keys leak. The question isn't if but how fast you notice and how clean your response runbook is when the pager goes off.
Edge code runs in hundreds of PoPs, lives for milliseconds, and gives you no shell. Here's how we get logs, traces, and metrics out of it anyway.
Evergreen posts worth revisiting.