Stateful Edge Logic Without a Database Roundtrip: Durable Objects in Practice
Durable Objects give serialized, strongly consistent state per ID at the edge. Use them as the owner of one entity, keep state under 10 MB, and verify alarms and ID routing locally with wrangler dev.
11 Apr 2026, 15:57 UTC

The problem is simple: you need per-entity state that stays consistent across concurrent requests at the edge, without adding a database roundtrip and without building your own locking. Durable Objects give you a strongly consistent object with an attached key-value store that is serialized per ID, running in its own isolate and co-located with the request that addresses it.
The useful takeaway is to treat a Durable Object as the authoritative owner of one entity, not as a cache. Let the object serialize mutations, use alarms for time-based work, and keep the object’s state small enough to stay under the platform limits.
Serialization as a design primitive
A Durable Object is a JavaScript class with a lifecycle: constructor, fetch, alarm, and access to this.state. The platform guarantees that operations for a given object ID are serialized. That means two requests that hit the same ID are processed one at a time in the same isolate, so you do not need external locks for that entity.
This is different from ordinary Workers, which are stateless and share nothing between invocations. With a Durable Object you get per-object persistence via this.state.storage, which is a strongly consistent store attached to the ID. The object stays warm for the duration of the request and can be addressed later by any Worker in the same namespace.
Addressing is explicit. From a Worker you obtain a stub with await env.MY_OBJECT.get(id) and then call methods or fetch on it. The ID is an opaque value you choose; it must be globally unique within the namespace because all requests with the same ID are routed to the same object.
Lifecycle and scheduling
The constructor runs once per object lifetime. It is a good place to initialize defaults in this.state.storage if they do not exist. The fetch handler receives the incoming request from the stub call. The alarm handler is invoked for scheduled work you set with this.state.storage.setAlarm(timestamp).
The platform aims for at-most-once execution of alarms and scheduled callbacks. That is useful for retries and heartbeats, but you should make alarm handlers idempotent because a late delivery can still occur.
Worked example: per-room rate limiter
Consider a chat room where you want to allow at most N messages per minute per room, enforced at the edge. The room ID becomes the Durable Object ID.
export class RoomLimiter {
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 === 'check') {
const now = Date.now();
const windowStart = now - 60_000;
let timestamps = await this.storage.get('timestamps') ?? [];
timestamps = timestamps.filter(t => t > windowStart);
if (timestamps.length >= 5) {
return new Response('rate limited', { status: 429 });
}
timestamps.push(now);
await this.storage.put('timestamps', timestamps);
return new Response('ok');
}
if (action === 'reset') {
await this.storage.delete('timestamps');
return new Response('reset');
}
return new Response('unknown', { status: 400 });
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const roomId = url.searchParams.get('room');
if (!roomId) return new Response('missing room', { status: 400 });
const obj = env.ROOM_LIMITER.get(env.ROOM_LIMITER.idFromName(roomId));
return obj.fetch(request);
}
}
Where to run: place the code in src/index.js and define the Durable Object binding ROOM_LIMITER in wrangler.toml. Run npx wrangler dev locally after wrangler login. Required permission is access to the Cloudflare account and the namespace defined in the binding.
Expected checks: requests with the same roomId always hit the same object instance, so the timestamps array is shared. Different roomIds create distinct objects. Inspect console output for alarm callbacks if you add a setAlarm to clean old timestamps.
Risks: the timestamps array lives in the object’s 10 MB state quota. Unbounded growth will eventually throw StorageQuotaExceededError. Also, using idFromName with user-supplied input without normalization can cause collisions because IDs must be globally unique within the namespace; duplicate IDs route to the existing object and break isolation.
Trade-offs and limits
Durable Objects provide strong consistency per object, but that consistency is scoped to the ID. Cross-object transactions are not provided. The 10 MB state storage limit per object is a hard cap; exceeding it results in an error and the write is rejected. There is no automatic spill to external storage.
Object IDs are stable. Deleting an object is not automatic; you must manage lifecycle explicitly. Because execution is serialized, long-running handlers increase latency for all other requests to that ID.
Practical verification: deploy a minimal Worker that uses env.OBJECTS.get(id) and await obj.fetch(), then issue two requests with the same ID and observe that state mutations are preserved. Run npx wrangler dev and watch console output for alarm callbacks to confirm scheduled execution semantics. Check the Durable Objects quota in the Cloudflare dashboard and monitor state size per object to stay below 10 MB.
Actionable closing
Use Durable Objects when you need a single writer per entity at the edge: rate limiting, presence, counters, or collaborative cursors. Keep each object focused on one ID, keep state small and append-only where possible, and use alarms for time-based cleanup rather than polling. Validate IDs at the edge and monitor storage size per object to avoid quota errors.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.