Cloudflare WorkersDurable ObjectsEdge ArchitectureDynamic Workers
…and why that distinction could save your architecture
If I had a dollar for every time someone described Durable Objects as “basically just serverless storage with a Worker bolted on,” I’d have enough to fund a reasonably-sized Azure reserved instance. For about 40 minutes.
The misunderstanding is understandable. The name doesn’t help. “Durable” sounds like storage. “Objects” sounds like code. Put them together and your brain autocompletes to “persistent key-value store with methods.” That’s not wrong, exactly — it’s just missing about 90% of what makes them genuinely interesting, and nearly all of what makes them powerful. Let’s fix that tonight.
The Lineup at the Teller’s Window
Imagine a bank — not a modern one with self-service kiosks, but a classic bank with one very specific teller assigned to your account. You can only access your account through that teller. They serve you one at a time, they have a personal ledger with your exact account state, and everything is consistent because all access flows through one person.
That’s a Durable Object. The teller is a coordinator — a single-threaded, globally unique execution context that also happens to have a ledger. The consistency guarantee comes from the serialization, not the storage. This is the actor model, and Cloudflare just made it deployable to 300+ PoPs with a single wrangler deploy.
KV vs. Durable Objects: Let’s Put This One to Rest
Workers KV is a bulletin board — anyone can pin a note, last write wins, eventually consistent, designed for read-heavy workloads. Durable Objects are a smart coordinator — all writes go through one brain, in order, fully consistent. Use them when the sequence of operations matters: rate limiting, session management, inventory, real-time collaboration.
subgraph KV[“Workers KV – Bulletin Board”]
direction TB
W1[“Writer A”] –> B[“Shared Data: eventual consistency”]
W2[“Writer B”] –> B
end
subgraph DO[“Durable Object – Smart Teller”]
direction TB
REQ[“All Requests: serialized”] –> INST[“Single Instance: one at a time”]
INST –> STORE[“Local Storage: ACID”]
end
style KV fill:#1e3a5f,color:#fff
style DO fill:#7c2d12,color:#fff
Three Ways to Get a DO ID
// Named IDs - deterministic, same name always = same DO instance
const userId = env.RATE_LIMITER.idFromName(`user:${userId}`);
const roomId = env.CHAT_ROOM.idFromName(`room:${roomSlug}`);
// Generated IDs - globally unique, opaque, unguessable
const sessionId = env.SESSION.newUniqueId();
// Parsed IDs - reconstruct a DO from a stored string
const storedId = await env.SESSIONS_KV.get(`session:${token}`);
if (storedId) {
const doId = env.SESSION.idFromString(storedId);
return env.SESSION.get(doId).fetch(request);
}
// Location hints - prefer placement near specific region
const europeFirst = env.USER_STATE.idFromName(`user:${id}`, { locationHint: 'eeur' });
A Production Rate Limiter
In the Azure world you’d reach for Redis + Lua scripts. Here, the actor model does the work — no locks, no atomic helpers, just serial execution.
export interface Env { RATE_LIMITER: DurableObjectNamespace; }
export class RateLimiter implements DurableObject {
constructor(private readonly state: DurableObjectState, private readonly env: Env) {}
async fetch(request: Request): Promise<Response> {
const now = Date.now();
const windowMs = 60_000;
const maxRequests = 100;
// All requests serialized - the actor model IS the lock
const stored = await this.state.storage.get<{count: number; windowStart: number}>('window');
let window = stored ?? { count: 0, windowStart: now };
if (now - window.windowStart > windowMs) window = { count: 0, windowStart: now };
window.count++;
await this.state.storage.put('window', window);
const remaining = Math.max(0, maxRequests - window.count);
if (window.count > maxRequests) {
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
status: 429,
headers: { 'Retry-After': String(Math.ceil((window.windowStart + windowMs - now) / 1000)) },
});
}
return Response.json({ allowed: true, remaining });
}
}
export default {
async fetch(request: Request, env: Env) {
const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
return env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName(ip)).fetch(request);
},
};
Transactional Storage — ACID at the Edge
The DO’s local storage supports full transactions — a proper BEGIN TRANSACTION equivalent. Because all requests are serialized, you don’t even need to worry about phantom reads.
export class InventoryManager implements DurableObject {
constructor(private state: DurableObjectState, private env: Env) {}
async fetch(request: Request): Promise<Response> {
const { action, itemId, quantity } = await request.json<{
action: 'reserve' | 'release' | 'query'; itemId: string; quantity?: number;
}>();
if (action === 'query') {
const stock = await this.state.storage.get<number>(`stock:${itemId}`) ?? 0;
return Response.json({ itemId, available: stock });
}
// storage.transaction() - atomic, either everything commits or nothing does
// Requests are serialized before the transaction starts - no phantom reads
const result = await this.state.storage.transaction(async (txn) => {
const current = await txn.get<number>(`stock:${itemId}`) ?? 0;
if (action === 'reserve') {
if ((quantity ?? 0) > current) return { success: false, reason: 'insufficient_stock' };
await txn.put(`stock:${itemId}`, current - (quantity ?? 0));
return { success: true, remaining: current - (quantity ?? 0) };
}
await txn.put(`stock:${itemId}`, current + (quantity ?? 0));
return { success: true, remaining: current + (quantity ?? 0) };
});
return Response.json(result);
}
}
The Alarm API — Built-In Scheduled Work
DOs have a built-in alarm system. No external cron, no separate scheduler service. The alarm fires inside the DO, with full storage access, after surviving hibernation and Worker restarts. Think of it as a built-in cron job that only your actor can hear.
participant C as Client
participant DO as SessionManager DO
participant RT as CF Runtime
C->>DO: POST /session/create
DO->>DO: storage.put session data
DO->>RT: storage.setAlarm(now + ttl)
DO–>>C: 200 created
Note over DO,RT: DO may hibernate here
RT->>DO: alarm() fires at TTL
DO->>DO: storage.deleteAll()
Note over DO: Session purged atomically
export class SessionManager implements DurableObject {
constructor(private state: DurableObjectState, private env: Env) {}
async fetch(request: Request): Promise<Response> {
const { userId, ttlMs = 3_600_000 } = await request.json<{userId: string; ttlMs?: number}>();
await this.state.storage.put('session', { userId, createdAt: Date.now(), expiresAt: Date.now() + ttlMs });
// No external scheduler - the alarm fires inside this DO's context
await this.state.storage.setAlarm(Date.now() + ttlMs);
return Response.json({ created: true });
}
// Fires inside the DO's serialized context - no race conditions possible
async alarm(): Promise<void> {
await this.state.storage.deleteAll();
}
}
JSRPC — Call DO Methods Directly
The newest addition to the DO API. Instead of routing through fetch() with JSON payloads, you define methods on your DO and call them directly. TypeScript types flow through. No HTTP ceremony.
import { WorkerEntrypoint, DurableObject } from 'cloudflare:workers';
export class CounterDO extends DurableObject {
async increment(by = 1): Promise<number> {
const next = (await this.ctx.storage.get<number>('value') ?? 0) + by;
await this.ctx.storage.put('value', next);
return next;
}
async reset(): Promise<void> { await this.ctx.storage.delete('value'); }
async getValue(): Promise<number> { return await this.ctx.storage.get<number>('value') ?? 0; }
}
export default class ApiWorker extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const counter = this.env.COUNTER.get(this.env.COUNTER.idFromName('global'));
// Direct method call - no fetch(), no JSON.stringify(), fully typed
const value = await counter.increment();
return Response.json({ value });
}
}
WebSocket Hibernation — Real-Time Without the Bill
Think of it like a doctor on call — not in the hospital 24/7, but responding within seconds when a message arrives. The DO hibernates between WebSocket messages. You pay for execution time, not uptime. Thousands of concurrent chat rooms for essentially nothing.
export class ChatRoom implements DurableObject {
constructor(private readonly state: DurableObjectState, private readonly env: Env) {}
async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade') !== 'websocket')
return new Response('Expected WebSocket upgrade', { status: 426 });
const [client, server] = Object.values(new WebSocketPair());
// Magic line: hand WebSocket to runtime. DO hibernates. Pay per message, not uptime.
this.state.acceptWebSocket(server, ['room-participant']);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
const parsed = JSON.parse(text);
const sessions = this.state.getWebSockets('room-participant');
for (const session of sessions)
if (session !== ws) session.send(JSON.stringify(parsed));
}
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
ws.close(code, reason);
}
}
Dynamic Workers: Your DO Just Got a Superpower
On March 24th, 2026 — literally last week — Cloudflare shipped Dynamic Workers into open beta. If you’re building anything involving runtime code execution — AI agents, user automations, sandboxed plugins — this changes your architecture. Before: spin up a container (seconds of cold start, 100MB+ RAM). After: V8 isolate, millisecond startup, same thread as caller, unlimited concurrency.
subgraph Before[“Before: Container”]
U1[“User Code”] –> C[“Linux Container: 1-5s cold start”]
C –> R1[“Result”]
end
subgraph After[“After: Dynamic Worker”]
U2[“User Code”] –> DW[“V8 Isolate: milliseconds”]
DW –> R2[“Result”]
end
style Before fill:#7c2d12,color:#fff
style After fill:#14532d,color:#fff
export class DynamicOrchestrator implements DurableObject {
constructor(private readonly state: DurableObjectState,
private readonly env: Env & { DYNAMIC_WORKER: any }) {}
async fetch(request: Request): Promise<Response> {
const { userCode, inputData } = await request.json<{userCode: string; inputData: Record<string, unknown>}>();
const dynamicWorker = this.env.DYNAMIC_WORKER.newWorker({
code: `export default { async fetch(req, env) { const input = env.INPUT_DATA; ${userCode} } }`,
// Principle of least privilege - the sandbox sees ONLY what we grant it
bindings: { INPUT_DATA: { type: 'json', value: inputData } },
// globalOutbound: inject credentials on outbound requests - sandbox never sees raw API keys
// globalOutbound: this.env.CREDENTIAL_INJECTOR,
});
const result = await dynamicWorker.fetch(new Request('https://worker/execute'));
// DO maintains tamper-evident audit log
const logEntry = { timestamp: new Date().toISOString(), status: result.status };
const logs = await this.state.storage.get<typeof logEntry[]>('logs') ?? [];
await this.state.storage.put('logs', [...logs, logEntry].slice(-1000));
return result;
}
}
globalOutbound pattern: Intercept every outbound HTTP request from the Dynamic Worker sandbox. Inject credentials on the way out — the sandbox never sees raw API keys. The LLM writes code, it runs in the sandbox, credentials are injected transparently. This is the AI agent security pattern.The Full Architecture
subgraph World[“Global Requests”]
R1[“New York”]
R2[“London”]
R3[“Tokyo”]
end
subgraph DO[“Durable Object – The Bank Teller”]
Q[“Request Queue: serialized”]
EX[“Serial Executor”]
ST[“Transactional Storage”]
WS[“WebSocket Hibernation”]
DW[“Dynamic Worker Spawner”]
end
subgraph DWS[“Dynamic Workers”]
DW1[“Isolate A”]
DW2[“Isolate B”]
end
R1 –>|idFromName| Q
R2 –>|idFromName| Q
R3 –>|idFromName| Q
Q –> EX
EX <–> ST
EX –> WS
EX –> DW
DW –> DW1
DW –> DW2
style DO fill:#f97316,color:#fff
style DWS fill:#3b82f6,color:#fff
style World fill:#6b7280,color:#fff
This is not “serverless storage with a Worker bolted on.” It’s a globally distributed, strongly consistent, single-threaded actor with optional sandboxed execution. The storage is almost incidental. The model is the value.
Coming Up Next
Everything here has been TypeScript. But Dynamic Workers support Python and WebAssembly. And if you’re going to ship sandboxed binary execution at the edge in a memory-safe, zero-cost-abstraction, I-will-not-apologize-for-my-type-system kind of way…
Next week: Rust on the Edge. We’ll compile Rust to WebAssembly, deploy it as a Worker, run the same binary server-side with WASI, and make the case that the future of compute isn’t JavaScript frameworks fighting each other — it’s a universal binary format that runs identically in your browser, at the edge, and on the server. See you then.
The [K]nightly Build ships when the code compiles. Usually on schedule. Mostly.

Leave a Reply