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.

The Azure comparison: The closest mental model is Virtual Actors in Orleans — same concept, globally distributed by default, deployed at the edge across 300+ locations, with V8 isolates meaning millisecond cold starts.

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.

flowchart LR
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

TypeScript — DO ID Patterns
// 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.

No Redis. No distributed lock. The consistency is architectural — every request for a given IP hits one instance, processed one at a time.
TypeScript — Rate Limiter DO
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.

TypeScript — Inventory Manager with Transactions
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.

sequenceDiagram
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
TypeScript — Session Manager with Alarm-Based TTL
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.

TypeScript — JSRPC Direct Method Calls
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.

TypeScript — WebSocket Chat Room with Hibernation
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.

flowchart LR
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
TypeScript — Durable Object + Dynamic Worker Orchestrator
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;
  }
}
The 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

flowchart LR
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.


Discover more from The [K]nightly Build

Subscribe to get the latest posts sent to your email.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.