
























Two years ago I was just like you. I had heard of Cloudflare Durable Objects, I read the docs, but still had no idea what these were. Today I almost exclusively build with Durable Objects (DOs), and I can’t imagine building production applications without them. Let me explain what finally made it click for me.
You know how regular serverless functions (like AWS Lambda Functions) are stateless? Every request starts fresh, with no memory of what happened before. If you want to remember something between requests, you have to store it in a database.
This works fine for most CRUD operations.
But it quickly becomes a real challenge when you need to:
The traditional solution? Spin up a stateful server. But now you’re back to managing servers, dealing with scaling, load balancing, and paying for idle capacity.
graph TB
User[User Requests for Document 123]
User --> LB{API Gateway}
LB --> L1[Lambda A]
LB --> L2[Lambda B]
LB --> L3[Lambda C]
L1 --> Server[Central Server]
L2 --> Server
L3 --> Server
L1 -.->|❌ No coordination| L2
L2 -.->|❌ No coordination| L3
L3 -.->|❌ No coordination| L1
Server --> DB[(Central Database)]
style L1 fill:#f96,stroke:#333
style L2 fill:#f96,stroke:#333
style L3 fill:#f96,stroke:#333
style LB fill:#fc9,stroke:#333
style DB fill:#99f,stroke:#333
style Server fill:#9c9,stroke:#333
Durable Objects give you the benefits of stateful servers (long-lived connections, state, coordination) without the operational overhead. They’re serverless, but stateful. They scale automatically, but maintain strong consistency. They’re ephemeral (shutting down when idle), but durable (state persists).
graph TB
User1[User 1
Document 123] --> Worker1[Worker A]
User2[User 2
Document 123] --> Worker2[Worker B]
User3[User 3
Document 123] --> Worker3[Worker C]
Worker1 --> DO[Durable Object]
Worker2 --> DO
Worker3 --> DO
style Worker1 fill:#FAAD3F,stroke:#333
style Worker2 fill:#FAAD3F,stroke:#333
style Worker3 fill:#FAAD3F,stroke:#333
style DO fill:#9f9,stroke:#333,stroke-width:2px
Here’s the key insight that made it click for me:
A Durable Object is like having a tiny, long-lived server that is guaranteed to be unique for a specific ID.
When you create a Durable Object with idFromName("workspace-123"), Cloudflare ensures that only one instance of that object exists globally. Every request with that same ID goes to the exact same instance, no matter where in the world the request comes from.
This is fundamentally different from regular Cloudflare Workers. With Workers, if 100 requests come in for “workspace-123”, they might be handled by 100 different Worker instances across 100 different data centers, depending on the origating request. Each instance is isolated, stateless, and ephemeral.
With Durable Objects, all 100 requests for “workspace-123” are routed to the same single instance of your Durable Object. That instance can be running in Tokyo, and requests from New York will be routed there. This single instance can maintain WebSocket connections, coordinate concurrent access, and act as the single source of truth for that workspace’s state.
It’s like having a dedicated micro-server for each user, workspace, or chat room. Except you only pay when it’s actually being used, and you never have to think about deployment, scaling, or geographic distribution.
Imagine a library (Cloudflare’s edge network) with millions of private study rooms (Durable Objects). Each room has:
When you want to access “Room 123”, the librarian always sends you to the same room. The person inside remembers what they were doing, has access to their drawers (storage), and can handle your request immediately.
If nobody visits the room for a while, the person leaves (hibernation), but their drawers stay locked. When someone comes to visit the same room again, the person returns exactly where they left off.
graph TB
Client[Client Request] --> Worker[Cloudflare Worker]
Worker --> |"idFromName('user-123')"| DO_ID[Durable Object ID]
DO_ID --> |Get Stub| Stub[Durable Object Stub]
Stub --> |RPC Call| Instance[Durable Object Instance]
Instance --> Memory[In-Memory State]
Instance --> Storage[KV Storage]
Instance --> SQL[SQLite Database]
Instance --> Alarm[Scheduled Alarms]
style Instance fill:#f9a,stroke:#333,stroke-width:4px
style Memory fill:#9cf,stroke:#333,stroke-width:2px
style Storage fill:#9f9,stroke:#333,stroke-width:2px
style SQL fill:#ff9,stroke:#333,stroke-width:2px
style Alarm fill:#f99,stroke:#333,stroke-width:2px
Let’s build a simple “Workspace” Durable Object, based on patterns from real production code:
// workspace.ts - The Durable Object class
export class DurableWorkspace extends DurableObject {
private sql: SqlStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
// Run migrations on first access
ctx.blockConcurrencyWhile(async () => {
this.migrate();
});
}
migrate() {
// Create tables if they don't exist
this.sql.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created INTEGER NOT NULL
)
`);
}
// Store workspace identity
async init(workspaceId: string, ownerId: string) {
await this.ctx.storage.put("workspaceId", workspaceId);
await this.ctx.storage.put("ownerId", ownerId);
// Schedule a monthly cleanup
await this.ctx.storage.setAlarm(Date.now() + 30 * 24 * 60 * 60 * 1000);
}
async getWorkspaceId(): Promise<string> {
return await this.ctx.storage.get("workspaceId") || "";
}
async createProject(name: string) {
const id = crypto.randomUUID();
this.sql.exec(
"INSERT INTO projects (id, name, created) VALUES (?, ?, ?)",
id,
name,
Date.now()
);
return { id, name };
}
async listProjects() {
const result = this.sql.exec("SELECT * FROM projects ORDER BY created DESC");
return result.toArray();
}
// Called when alarm triggers
async alarm() {
console.log("Running monthly cleanup...");
// Clean up old data
this.sql.exec("DELETE FROM projects WHERE created < ?", Date.now() - 90 * 24 * 60 * 60 * 1000);
// Schedule next cleanup
await this.ctx.storage.setAlarm(Date.now() + 30 * 24 * 60 * 60 * 1000);
}
}
Now that we’ve seen a Durable Object class, how do we actually use it? You don’t instantiate Durable Objects directly. Instead, you get a “stub” - a proxy that lets you call methods on the Durable Object from your Worker.
The critical part is the idFromName() call. This is what ensures you always get the same instance for the same ID. Think of it like a phone book - you look up “workspace-123” in the directory, and Cloudflare gives you a direct line to that workspace’s unique Durable Object instance, wherever it happens to be running in the world.
Here’s how you access your Durable Object from a Worker:
// Helper function to get a workspace stub
export function getWorkspaceStub(env: Env, workspaceId: string): DurableObjectStub<DurableWorkspace> {
// idFromName ensures the same ID always goes to the same instance
const id = env.WORKSPACE.idFromName(workspaceId);
const stub = env.WORKSPACE.get(id);
return stub;
}
// In your Worker (API endpoint)
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const workspaceId = url.searchParams.get("workspace");
// Get the stub - always the same instance for this workspace
const workspace = getWorkspaceStub(env, workspaceId);
if (url.pathname === "/init") {
await workspace.init(workspaceId, "user-123");
return new Response("Initialized");
}
if (url.pathname === "/projects") {
if (request.method === "POST") {
const { name } = await request.json();
const project = await workspace.createProject(name);
return Response.json(project);
} else {
const projects = await workspace.listProjects();
return Response.json(projects);
}
}
return new Response("Not found", { status: 404 });
}
};
The Worker acts as a router - it extracts the workspace ID from the request, gets the appropriate Durable Object stub using idFromName(), and calls methods on it.
The workspace.createProject() and workspace.listProjects() calls are RPC (Remote Procedure Calls) - they’re executed inside the Durable Object instance, not in the Worker. The Worker just forwards the request and gets the response back.
One of the most powerful aspects of Durable Objects is having multiple storage options. When I first started, I didn’t understand why you’d need so many ways to store data. But each layer serves a distinct purpose and has different performance characteristics.
Think of it like this: in-memory state is your desk (fast, temporary), KV storage is your desk drawer (fast, persistent, small items), SQLite is your filing cabinet (structured, queryable, medium size), and external storage like R2 is your warehouse (massive, slower, for big things).
Understanding when to use each storage layer is crucial:
graph TD
DO[Durable Object Instance]
DO --> KV[Key-Value Storage
ctx.storage.put/get]
DO --> SQL[SQLite Database
ctx.storage.sql]
DO --> Memory[In-Memory State
class properties]
DO --> External[External Storage
R2, KV Namespace, D1]
KV --> |Use For| KV_Use[Simple metadata: User ID, config, etc.]
SQL --> |Use For| SQL_Use[Structured data: Relations & indexes]
Memory --> |Use For| Memory_Use[WebSocket connections, active sessions, etc.]
External --> |Use For| External_Use[Large files, cross-workspace data, analytics]
style KV fill:#9f9,stroke:#333
style SQL fill:#ff9,stroke:#333
style Memory fill:#9cf,stroke:#333
style External fill:#f9f,stroke:#333
export class ChatRoom extends DurableObject {
// In-memory: active WebSocket connections
private connections: Map<string, WebSocket> = new Map();
// In-memory: temporary message buffer
private messageBuffer: Message[] = [];
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
// SQLite: create messages table
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
`);
});
}
async initRoom(roomId: string, roomName: string) {
// Key-Value: store simple metadata
await this.ctx.storage.put("roomId", roomId);
await this.ctx.storage.put("roomName", roomName);
await this.ctx.storage.put("createdAt", Date.now());
}
async handleWebSocket(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// In-memory: track active connection
const connectionId = crypto.randomUUID();
this.connections.set(connectionId, server);
server.addEventListener("message", async (event) => {
const message = JSON.parse(event.data);
// In-memory: save message to the buffer
this.messageBuffer.push(message)
// SQLite: persist message
this.ctx.storage.sql.exec(
"INSERT INTO messages (id, userId, content, timestamp) VALUES (?, ?, ?, ?)",
crypto.randomUUID(),
message.userId,
message.content,
Date.now()
);
// In-memory: broadcast to all active connections
for (const [id, conn] of this.connections) {
if (id !== connectionId) {
conn.send(JSON.stringify(message));
}
}
// External: save to R2 for archival
if (this.messageBuffer.length >= 100) {
await this.archiveMessages();
}
});
server.addEventListener("close", () => {
this.connections.delete(connectionId);
});
server.accept();
return new Response(null, { status: 101, webSocket: client });
}
async archiveMessages() {
const roomId = await this.ctx.storage.get("roomId");
const messages = this.messageBuffer.splice(0);
// External storage: R2 bucket for large archives
await this.env.ARCHIVE_BUCKET.put(
`rooms/${roomId}/${Date.now()}.json`,
JSON.stringify(messages)
);
}
async getRecentMessages(limit: number = 50) {
// SQLite: efficient queries with indexes
const result = this.ctx.storage.sql.exec(
"SELECT * FROM messages ORDER BY timestamp DESC LIMIT ?",
limit
);
return result.toArray();
}
}
This is my favourite pattern when building multitenant applications. Let me explain it with a real-world analogy first, then show you the code.
Imagine you’re building a project management SaaS app. Think of it like an apartment building:
Why separate them? Because if you put all apartments’ furniture in the building manager’s office, it gets crowded fast. Instead, each apartment manages its own internal state, but the building manager knows about all apartments and can coordinate between them.
In code terms: don’t put all your data in one Durable Object. Create separate child Durable Objects for each logical entity (user, project, document, chat room), and have a parent Durable Object that coordinates them.
The thing is, each Durable Object is single-threaded. If you have one giant Durable Object handling everything for a workspace with 100 projects, and 50 users are all editing different projects simultaneously, they’re all waiting in line for that one Durable Object to process their requests.
But if each project is its own Durable Object, those 50 users can work in parallel across 50 different Durable Objects. The parent workspace just maintains a registry of which projects exist.
// Parent: DurableWorkspace
export class DurableWorkspace extends DurableObject {
async createThread(threadName: string) {
const threadId = crypto.randomUUID();
const workspaceId = await this.ctx.storage.get("workspaceId");
// Store reference in parent's database
this.ctx.storage.sql.exec(
"INSERT INTO threads (id, name, created) VALUES (?, ?, ?)",
threadId,
threadName,
Date.now()
);
// Get stub to child Durable Object
const threadStub = getThreadStub(this.env, threadId);
// Initialize the child
await threadStub.init(threadId, workspaceId, threadName);
return { id: threadId, name: threadName };
}
async listThreads() {
const result = this.ctx.storage.sql.exec(
"SELECT * FROM threads ORDER BY created DESC"
);
return result.toArray();
}
}
// Child: DurableThread
export class DurableThread extends DurableObject {
async init(threadId: string, workspaceId: string, threadName: string) {
// Store parent reference
await this.ctx.storage.put("threadId", threadId);
await this.ctx.storage.put("workspaceId", workspaceId);
await this.ctx.storage.put("threadName", threadName);
}
async addMessage(content: string) {
const id = crypto.randomUUID();
this.ctx.storage.sql.exec(
"INSERT INTO messages (id, content, created) VALUES (?, ?, ?)",
id,
content,
Date.now()
);
return { id, content };
}
async deleteThread() {
const workspaceId = await this.ctx.storage.get("workspaceId");
const threadId = await this.ctx.storage.get("threadId");
// Tell parent to remove reference
const workspaceStub = getWorkspaceStub(this.env, workspaceId);
await workspaceStub.deleteThreadReference(threadId);
// Clean up own data
this.ctx.storage.sql.exec("DELETE FROM messages");
await this.ctx.storage.deleteAll();
}
}
// Helper functions
function getThreadStub(env: Env, threadId: string) {
const id = env.THREAD.idFromName(threadId);
return env.THREAD.get(id);
}
function getWorkspaceStub(env: Env, workspaceId: string) {
const id = env.WORKSPACE.idFromName(workspaceId);
return env.WORKSPACE.get(id);
}
Let’s walk through the flow when a user creates a thread:
idFromName("workspace-123") to get the workspace Durable ObjectidFromName("thread-456")Later, when listing threads:
When adding a message:
This separation means the workspace can track hundreds of threads without getting bogged down by message operations, and threads can process messages in parallel without waiting for the workspace.
Here’s another outstanding feature that took me a while to appreciate: each Durable Object can schedule its own future work. Not through some external scheduler, but built right into the object itself.
Think about subscription renewals. With traditional serverless, you’d need:
With Durable Objects + Alarms:
No cron jobs scanning millions of records. No batch processing. Just-in-time execution for each user. This is incredibly powerful for per-user or per-entity scheduled tasks.
Here’s how it works in code:
export class SubscriptionManager extends DurableObject {
async createSubscription(userId: string, planType: string) {
await this.ctx.storage.put("userId", userId);
await this.ctx.storage.put("planType", planType);
await this.ctx.storage.put("status", "active");
// Schedule renewal check in 30 days
const renewalDate = Date.now() + 30 * 24 * 60 * 60 * 1000;
await this.ctx.storage.setAlarm(renewalDate);
}
async alarm() {
const userId = await this.ctx.storage.get("userId");
const planType = await this.ctx.storage.get("planType");
console.log(`Processing renewal for user ${userId}`);
// Charge the user
const success = await this.chargeUser(userId, planType);
if (success) {
// Schedule next renewal
await this.ctx.storage.setAlarm(Date.now() + 30 * 24 * 60 * 60 * 1000);
} else {
// Downgrade to free plan
await this.ctx.storage.put("status", "expired");
await this.ctx.storage.put("planType", "free");
}
}
async cancelSubscription() {
await this.ctx.storage.put("status", "cancelled");
// Cancel future alarms
await this.ctx.storage.deleteAlarm();
}
private async chargeUser(userId: string, planType: string): Promise<boolean> {
// Call payment API
return true;
}
}
When you schedule an alarm, Cloudflare guarantees it will fire at least once. If your alarm() method throws an exception, it automatically retries up to 6 times with exponential backoff (starting at 2 seconds).
You don’t need to write retry logic, just make sure your alarm() method is idempotent (safe to run multiple times) since it could potentially execute more than once.
Here’s my mini-decision framework:
Perfect for Durable Objects:
Not ideal for Durable Objects:
Be careful with:
Let me save you from the mistakes I made when I was learning Durable Objects. These gotchas aren’t obvious from the docs, but they’ll bite you in production if you’re not careful.
This is the biggest gotcha. A Durable Object instance is single-threaded. Every request to the same ID is processed sequentially, one at a time. This is actually a feature (strong consistency!), but it means you can’t block for long periods.
I once had a Durable Object that called a slow external API and took 5 seconds to respond. When 10 requests came in simultaneously for the same object, the last request waited 50 seconds. Not good.
// BAD: Blocking operation will freeze this DO for everyone
export class BadExample extends DurableObject {
async handleRequest() {
// This blocks for 10 seconds - ALL requests wait!
await new Promise(resolve => setTimeout(resolve, 10000));
return "Done";
}
}
// GOOD: Offload heavy work to queues
export class GoodExample extends DurableObject {
async handleRequest() {
// Queue the work, return immediately
await this.env.QUEUE.send({ task: "heavy-work" });
return "Queued";
}
}
When a Durable Object is first created, its storage is empty. It’s not possible to recall the name of the Durable Object from within the Durable Object. You need to explicitly initialize it. I use an init() method.
// BAD: Storage might be empty on first call
export class BadExample extends DurableObject {
async getWorkspaceId() {
return await this.ctx.storage.get("workspaceId"); // Could be undefined!
}
}
// GOOD: Explicit initialization
export class GoodExample extends DurableObject {
async init(workspaceId: string) {
await this.ctx.storage.put("workspaceId", workspaceId);
}
async getWorkspaceId() {
const id = await this.ctx.storage.get("workspaceId");
if (!id) throw new Error("Workspace not initialized - call init() first");
return id;
}
}
Before Hibernate API, if you had a chat room with 1000 connected users but nobody was sending messages, you were still paying for that Durable Object to stay alive and hold those connections.
With Hibernate API, the Durable Object can “hibernate” - essentially go to sleep while maintaining WebSocket connections. When a message arrives, it wakes up automatically, handles the message, and can go back to sleep.
The beauty is that this happens transparently. Your code doesn’t change. You just get automatic cost savings and better resource utilization.
export class ChatRoom extends DurableObject {
// Hibernate API automatically serializes/deserializes state
async webSocketMessage(ws: WebSocket, message: string) {
const data = JSON.parse(message);
// Broadcast to all connections
this.ctx.getWebSockets().forEach(socket => {
socket.send(JSON.stringify(data));
});
}
async webSocketClose(ws: WebSocket, code: number, reason: string) {
console.log("disconnected");
}
async handleConnect(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Attach metadata to the WebSocket
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
}
With Hibernate API, your Durable Object can go to sleep when idle (saving you money) and wake up automatically when a WebSocket message arrives. The state is automatically preserved!
×
The Cloudflare agents SDK (npm i agents) is a framework built on top of Durable Objects. It’s designed for building AI agents on the Cloudflare network, and has become my default framework when building with Durable Objects.
You don’t need to be building AI agents to use it. Despite the name, it’s actually a general-purpose framework for building stateful real-time applications with Durable Objects. It just happens to make AI agent development really easy too.
The Agent base class provides:
onConnect(), onMessage(), onClose() lifecycle methodsonRequest() method with clean routingHere’s a chat room using the Agents SDK - notice there’s no AI involved, it’s just using the framework’s convenience features:
import { Agent } from "agents";
export class ChatRoom extends Agent {
// WebSocket lifecycle - automatically handled
async onConnect(connection, ctx) {
const username = ctx.request.headers.get("username");
// Set connection metadata (persists through hibernation!)
await connection.setState({ username, joinedAt: Date.now() });
// Broadcast join message to everyone
this.broadcast(JSON.stringify({
type: "user_joined",
username,
timestamp: Date.now()
}));
}
override async onMessage(connection, message) {
const data = JSON.parse(message);
const state = await connection.getState();
// Save to SQLite for history
this.ctx.storage.sql.exec(
"INSERT INTO messages (id, username, content, timestamp) VALUES (?, ?, ?, ?)",
crypto.randomUUID(),
state.username,
data.content,
Date.now()
);
// Broadcast to all connected clients (except sender)
this.broadcast(JSON.stringify({
type: "message",
username: state.username,
content: data.content,
timestamp: Date.now()
}), [connection.id]);
}
async onClose(connection) {
const state = await connection.getState();
this.broadcast(JSON.stringify({
type: "user_left",
username: state.username,
timestamp: Date.now()
}));
}
// Regular HTTP request handling
override async onRequest(request) {
// Get recent message history via HTTP
if (request.method === "GET") {
const messages = this.ctx.storage.sql.exec(
"SELECT * FROM messages ORDER BY timestamp DESC LIMIT 50"
);
return Response.json(messages.toArray());
}
return super.onRequest(request);
}
}
Compare this to raw Durable Objects code:
Without Agents SDK:
// You manually create WebSocketPair
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Manually track connections in a Map
this.connections.set(id, server);
// Manually add event listeners
server.addEventListener("message", (event) => { /* ... */ });
server.addEventListener("close", () => { /* ... */ });
// Manually accept
server.accept();
return new Response(null, { status: 101, webSocket: client });
With Agents SDK:
// Just implement the lifecycle methods
async onConnect(connection, ctx) { /* ... */ }
async onMessage(connection, message) { /* ... */ }
async onClose(connection) { /* ... */ }
// Framework handles all the boilerplate
I use the Agents SDK for:
The framework handles connection lifecycle, hibernation, state persistence, and broadcasting - all the tedious parts of building with Durable Objects.
I use the Agents SDK for all my Durable Objects, both my production apps and my experiments. The base Agent class provides so much value that even for non-AI applications, it’s worth the dependency.
Two years ago, I read the Durable Objects docs three times and still didn’t get it. The breakthrough came when I stopped trying to understand the technology and started thinkering.
Durable Objects aren’t just “stateful Workers”. They’re a fundamentally different primitive. They give you:
The mental model that finally clicked for me: Durable Objects are like having millions of tiny, specialized servers that spawn on-demand, live exactly where they’re needed, and automatically shut down when idle.
You don’t need to understand how Cloudflare routes requests globally to the same instance. You don’t need to worry about replication or consistency. You just write code as if you have a single-threaded server for each user/room/workspace, and Cloudflare handles the rest.
Don’t try to use all the features at once. Start with:
idFromName.The key insights I wish I’d understood from day one:
Two years ago, I couldn’t wrap my head around Durable Objects. Today, they’re the foundation of everything I build. They’ve replaced Redis, PostgreSQL, WebSocket servers, cron jobs, and state machines in my architecture.
Once it clicks, you’ll wonder how you ever built without them.
I'm also building polylane, because nobody should be on-call in 2026.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。