Durable Objects
Durable Objects (DOs) give you stateful, single-instance compute on Cloudflare’s network. Each DO is globally unique, identified by an ID, and guaranteed to run as a single instance at a time. This makes them the building block for real-time features, coordination, and anything that needs strong consistency without a traditional database.
Prerequisites: First Worker, Storage Landscape
What Durable Objects Are
A Durable Object is a JavaScript class that:
- Has a globally unique identity (by name or random ID)
- Runs as a single instance - no concurrent instances of the same ID
- Has private, persistent storage (key-value API or SQLite)
- Can hold WebSocket connections and survive hibernation
- Lives on the edge, colocated with its first caller
Think of it as a tiny server dedicated to one entity: a chat room, a rate limiter, a user session, a game lobby.
DO Lifecycle
stateDiagram-v2
[*] --> Idle: stub.get(id)
Idle --> Instantiated: First request
Instantiated --> Handling: Incoming fetch/alarm
Handling --> Handling: More requests
Handling --> Hibernating: No active work
Hibernating --> Handling: New request or alarm
Hibernating --> Evicted: Idle timeout
Evicted --> Instantiated: Next request
Evicted --> [*]
Key transitions:
- Instantiated: the runtime creates your class instance and calls
constructor(state, env) - Handling: your
fetch()oralarm()method processes a request - Hibernating: the DO has WebSocket connections but no active CPU work; you only pay for connections, not CPU
- Evicted: after prolonged inactivity, the runtime destroys the instance; storage persists, the object re-instantiates on the next request
Accessing a Durable Object
From a Worker, you get a “stub” (proxy) and call fetch() on it:
// In your Worker
app.get("/room/:name", async (c) => {
const id = c.env.CHAT_ROOM.idFromName(c.req.param("name"));
const stub = c.env.CHAT_ROOM.get(id);
return stub.fetch(c.req.raw);
});
Two ways to get an ID:
idFromName(name)- deterministic; same name always gives the same ID (good for named entities like chat rooms)newUniqueId()- random; guaranteed unique (good for sessions, temporary state)
Storage API
Each DO gets private storage that survives eviction. Two options:
Key-Value Storage
Simple get/put/delete with automatic serialization:
export class Counter implements DurableObject {
constructor(private state: DurableObjectState, private env: Env) {}
async fetch(request: Request): Promise<Response> {
const current = (await this.state.storage.get<number>("count")) ?? 0;
const next = current + 1;
await this.state.storage.put("count", next);
return new Response(JSON.stringify({ count: next }));
}
}
Key-value operations:
| Method | Description |
|---|---|
get(key) | Read a single value |
get(keys[]) | Read multiple values (returns Map) |
put(key, value) | Write a single value |
put(entries) | Write multiple key-value pairs atomically |
delete(key) | Delete a single key |
deleteAll() | Wipe all storage |
list() | List all keys (with optional prefix/range) |
SQLite Storage
For complex queries, use the built-in SQLite database. Enable it in wrangler.jsonc:
{
"durable_objects": {
"bindings": [
{
"name": "CHAT_ROOM",
"class_name": "ChatRoom",
"sqlite_database": true
}
]
}
}
Then use this.state.storage.sql:
export class ChatRoom implements DurableObject {
private sql: SqlStorage;
constructor(private state: DurableObjectState, private env: Env) {
this.sql = state.storage.sql;
this.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
content TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/messages") {
const rows = this.sql
.exec("SELECT * FROM messages ORDER BY sent_at DESC LIMIT 50")
.toArray();
return Response.json(rows);
}
if (url.pathname === "/send" && request.method === "POST") {
const { sender, content } = await request.json<{
sender: string;
content: string;
}>();
this.sql.exec(
"INSERT INTO messages (sender, content) VALUES (?, ?)",
sender,
content
);
return Response.json({ ok: true });
}
return new Response("Not found", { status: 404 });
}
}
Gotcha: SQLite in DOs is synchronous (
execnotprepare(...).run()like D1). This is because DO storage is local to the instance, not over the network. The API is different from D1.
WebSocket Hibernation
The killer feature. A DO can hold thousands of WebSocket connections while hibernating, meaning you only pay for CPU when messages actually arrive.
How It Works
flowchart LR
subgraph client["Clients"]
C1["Dashboard 1"]
C2["Dashboard 2"]
C3["Dashboard 3"]
end
subgraph worker["Worker"]
W["Route Handler"]
end
subgraph durableObject["Durable Object"]
DO["WebSocket Handler"]
S[("SQLite")]
end
C1 -->|WebSocket| W
C2 -->|WebSocket| W
C3 -->|WebSocket| W
W -->|"stub.fetch(upgrade)"| DO
DO --- S
style durableObject fill:#e8f4e8
Instead of the standard addEventListener("message", ...) pattern, you use the Hibernation API:
- Accept the WebSocket with
this.state.acceptWebSocket(ws) - The runtime calls
webSocketMessage(),webSocketClose(), etc. on your class - Between messages, the DO hibernates - no CPU charges
WebSocket Lifecycle
export class LiveDashboard implements DurableObject {
private sql: SqlStorage;
constructor(private state: DurableObjectState, private env: Env) {
this.sql = state.storage.sql;
this.sql.exec(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// WebSocket upgrade
if (url.pathname === "/ws") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Tag the socket for later filtering
const tag = url.searchParams.get("source") ?? "all";
this.state.acceptWebSocket(server, [tag]);
return new Response(null, { status: 101, webSocket: client });
}
// HTTP endpoint to push events (called by the queue consumer)
if (url.pathname === "/event" && request.method === "POST") {
const event = await request.json<{ type: string; data: string }>();
this.sql.exec(
"INSERT INTO events (type, data) VALUES (?, ?)",
event.type,
event.data
);
this.broadcast(JSON.stringify(event));
return Response.json({ ok: true });
}
// Recent events via HTTP
if (url.pathname === "/events") {
const rows = this.sql
.exec("SELECT * FROM events ORDER BY created_at DESC LIMIT 100")
.toArray();
return Response.json(rows);
}
return new Response("Not found", { status: 404 });
}
// Hibernation API callbacks - called when the DO wakes up
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
// Handle incoming messages from clients
try {
const data = JSON.parse(message as string);
if (data.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
}
} catch {
ws.send(JSON.stringify({ error: "invalid message" }));
}
}
webSocketClose(ws: WebSocket, code: number, reason: string): void {
ws.close(code, reason);
}
webSocketError(ws: WebSocket, error: unknown): void {
console.error("WebSocket error:", error);
ws.close(1011, "Internal error");
}
private broadcast(message: string, filterTag?: string): void {
const sockets = filterTag
? this.state.getWebSockets(filterTag)
: this.state.getWebSockets();
for (const ws of sockets) {
try {
ws.send(message);
} catch {
// Socket already closed, will be cleaned up by webSocketClose
}
}
}
}
Key Hibernation API methods:
| Method | Description |
|---|---|
state.acceptWebSocket(ws, tags?) | Register a WebSocket for hibernation |
state.getWebSockets(tag?) | Get all connected WebSockets (optionally filtered by tag) |
webSocketMessage(ws, msg) | Called when a message arrives |
webSocketClose(ws, code, reason) | Called when a socket closes |
webSocketError(ws, error) | Called on socket errors |
Gotcha: DOs are single-threaded. One request at a time per instance. Concurrent requests queue up via “input gates” - the runtime serializes them automatically. This is a feature, not a bug: it means you never have race conditions on your storage.
Alarm API
Schedule a DO to wake up at a specific time. Useful for deferred work, cleanup, or periodic tasks within a single DO.
export class ExpiringSession implements DurableObject {
constructor(private state: DurableObjectState, private env: Env) {}
async fetch(request: Request): Promise<Response> {
// Set an alarm to expire this session in 30 minutes
const thirtyMinutes = 30 * 60 * 1000;
await this.state.storage.setAlarm(Date.now() + thirtyMinutes);
await this.state.storage.put("lastAccess", Date.now());
return new Response("Session refreshed");
}
async alarm(): Promise<void> {
const lastAccess = await this.state.storage.get<number>("lastAccess");
const thirtyMinutes = 30 * 60 * 1000;
if (lastAccess && Date.now() - lastAccess < thirtyMinutes) {
// Session was accessed recently, reschedule
await this.state.storage.setAlarm(Date.now() + thirtyMinutes);
return;
}
// Session expired, clean up
await this.state.storage.deleteAll();
}
}
Rules:
- One alarm per DO at a time (setting a new one replaces the old one)
- Alarms survive hibernation and eviction
- Minimum granularity is about 1 second
- Use
state.storage.getAlarm()to check if one is set - Use
state.storage.deleteAlarm()to cancel
Input Gates and Output Gates
DOs have a consistency model built on two concepts:
Input gates serialize concurrent requests. If three requests arrive simultaneously, they execute one at a time. This prevents race conditions on storage without locks.
Output gates prevent the DO from accepting new input while a fetch() to an external service is in flight and the response has not yet been processed. This ensures that if your code does await fetch(external) followed by a storage write, no other request can interleave between the fetch response and the write.
Together, these give you effectively serializable isolation:
// This is safe without locks because of input gates
async fetch(request: Request): Promise<Response> {
const balance = await this.state.storage.get<number>("balance") ?? 0;
// No other request can read/write between these two lines
await this.state.storage.put("balance", balance - 10);
return new Response(`New balance: ${balance - 10}`);
}
Patterns
Chat Room
One DO per room. WebSocket hibernation handles connections, SQLite stores messages:
const roomId = env.CHAT_ROOM.idFromName("general");
const room = env.CHAT_ROOM.get(roomId);
// Clients connect via WebSocket, DO broadcasts to all
Rate Limiter
One DO per API key. Strongly consistent counter without race conditions:
const limiterId = env.RATE_LIMITER.idFromName(apiKey);
const limiter = env.RATE_LIMITER.get(limiterId);
const res = await limiter.fetch(new Request("http://internal/check"));
const { allowed } = await res.json();
Distributed Lock
One DO per resource. The input gate serialization gives you lock semantics for free:
const lockId = env.LOCK.idFromName(resourceId);
const lock = env.LOCK.get(lockId);
// All requests serialize, so the first one "holds" the lock
Session State
One DO per user session. Alarm API handles expiration:
const sessionId = env.SESSION.idFromName(userId);
const session = env.SESSION.get(sessionId);
// Session data persists; alarm cleans up after inactivity
Webhook Hub: Real-Time Delivery Dashboard
Here is the complete DO that upgrades the full-stack app polling dashboard to real-time WebSocket updates. When the queue consumer delivers a webhook, it posts the result to this DO, which broadcasts to all connected dashboard clients.
wrangler.jsonc additions
{
"durable_objects": {
"bindings": [
{
"name": "DELIVERY_TRACKER",
"class_name": "DeliveryTracker",
"sqlite_database": true
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DeliveryTracker"]
}
]
}
Complete DO class
// src/delivery-tracker.ts
export class DeliveryTracker implements DurableObject {
private sql: SqlStorage;
constructor(private state: DurableObjectState, private env: Env) {
this.sql = state.storage.sql;
this.sql.exec(`
CREATE TABLE IF NOT EXISTS delivery_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL,
target_url TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Set up a cleanup alarm every hour
state.storage.setAlarm(Date.now() + 60 * 60 * 1000);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// WebSocket upgrade for dashboard clients
if (url.pathname === "/ws") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.state.acceptWebSocket(server);
// Send recent events on connect
const recent = this.sql
.exec(
"SELECT * FROM delivery_events ORDER BY timestamp DESC LIMIT 20"
)
.toArray();
server.send(JSON.stringify({ type: "history", events: recent }));
return new Response(null, { status: 101, webSocket: client });
}
// Delivery result posted by the queue consumer
if (url.pathname === "/delivery" && request.method === "POST") {
const event = await request.json<{
webhookId: number;
targetUrl: string;
status: "delivered" | "failed" | "dead_letter";
error?: string;
}>();
this.sql.exec(
"INSERT INTO delivery_events (webhook_id, target_url, status, error) VALUES (?, ?, ?, ?)",
event.webhookId,
event.targetUrl,
event.status,
event.error ?? null
);
// Broadcast to all connected dashboards
const message = JSON.stringify({ type: "delivery", event });
for (const ws of this.state.getWebSockets()) {
try {
ws.send(message);
} catch {
// closed socket, webSocketClose will clean up
}
}
return Response.json({ ok: true });
}
// HTTP fallback: recent events
if (url.pathname === "/events") {
const rows = this.sql
.exec(
"SELECT * FROM delivery_events ORDER BY timestamp DESC LIMIT 100"
)
.toArray();
return Response.json(rows);
}
return new Response("Not found", { status: 404 });
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
try {
const data = JSON.parse(message as string);
if (data.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
}
} catch {
ws.send(JSON.stringify({ error: "invalid message" }));
}
}
webSocketClose(ws: WebSocket, code: number, reason: string): void {
ws.close(code, reason);
}
webSocketError(ws: WebSocket, error: unknown): void {
ws.close(1011, "WebSocket error");
}
async alarm(): Promise<void> {
// Clean up events older than 24 hours
this.sql.exec(
"DELETE FROM delivery_events WHERE timestamp < datetime('now', '-1 day')"
);
// Reschedule
await this.state.storage.setAlarm(Date.now() + 60 * 60 * 1000);
}
}
Connecting the queue consumer
Update the queue consumer from Queues & Async Processing to post delivery results to the DO:
async queue(batch: MessageBatch<DeliveryJob>, env: Env): Promise<void> {
// Use a single DO instance for the delivery tracker
const trackerId = env.DELIVERY_TRACKER.idFromName("global");
const tracker = env.DELIVERY_TRACKER.get(trackerId);
for (const msg of batch.messages) {
const { webhookId, targetUrl, payload } = msg.body;
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// Notify the real-time tracker
await tracker.fetch(new Request("http://internal/delivery", {
method: "POST",
body: JSON.stringify({
webhookId,
targetUrl,
status: "delivered",
}),
}));
msg.ack();
} catch (err) {
await tracker.fetch(new Request("http://internal/delivery", {
method: "POST",
body: JSON.stringify({
webhookId,
targetUrl,
status: "failed",
error: String(err),
}),
}));
msg.retry();
}
}
}
Dashboard WebSocket client
Add a WebSocket connection to the React dashboard from Full-Stack App:
useEffect(() => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "delivery") {
// Update delivery status in the UI
setDeliveries((prev) => [data.event, ...prev].slice(0, 50));
}
if (data.type === "history") {
setDeliveries(data.events);
}
};
ws.onclose = () => {
// Reconnect after 3 seconds
setTimeout(() => window.location.reload(), 3000);
};
return () => ws.close();
}, []);
Route the WebSocket through the Worker to the DO:
// In the Hono API
app.get("/api/ws", async (c) => {
const id = c.env.DELIVERY_TRACKER.idFromName("global");
const stub = c.env.DELIVERY_TRACKER.get(id);
// Forward the upgrade request to the DO
return stub.fetch(new Request(new URL("/ws", c.req.url), c.req.raw));
});
Cost Considerations
| Component | Included (Paid Plan) | Overage |
|---|---|---|
| Requests | 1M/month | $0.15/M |
| Duration | 400K GB-s/month | $12.50/M GB-s |
| Storage (KV) | 1GB | $0.20/GB/month |
| Storage (SQLite) | 5GB | $0.75/GB/month |
During WebSocket hibernation, you pay for storage and connections but not CPU duration. A DO with 1,000 idle WebSocket connections costs almost nothing until messages flow.
Gotcha: DOs are not replicated or load-balanced. Each DO ID runs on exactly one server. If that data center has an issue, the DO is unavailable until failover (usually seconds). Design for this by keeping DOs stateless where possible and using D1 as the source of truth.