Durable Objects and SQLite storage
Durable Objects (DOs) are Cloudflare’s stateful serverless primitive: a JavaScript class where each instance has a globally-unique address, strongly-consistent storage, optional persistent WebSocket connections, alarms for scheduled execution, and an isolation guarantee that exactly one instance runs at a time. The 2024 evolution moved storage from a key-value API on top of SQLite (the “Key-Value backend”) to direct SQL access on top of SQLite (the “SQLite-in-DO backend”), which became the default for new DOs in late 2024 and went GA on the Free tier in early 2025. Combined with WebSocket Hibernation (cost-effective long-lived connections) and DO Containers (preview), this is the most interesting building block for stateful edge applications.
See also
- workers-bindings-and-storage — the binding catalog DOs live alongside
- workers-ai-and-agents — Agents SDK runs on DOs
- distributed-systems-fundamentals — DOs implement a “shard per ID” model
- sql-nosql-design — SQL vs KV tradeoffs for DO storage backend
1. The DO model
Per developers.cloudflare.com/durable-objects/. A Durable Object is a JavaScript class. Each instance is identified by a unique ID. For a given ID, exactly one instance runs in the world at a time — Cloudflare routes all requests for that ID to the single instance, queuing concurrent requests if needed.
This gives you:
- Per-entity strong consistency — no race conditions, no need for distributed locks.
- Long-lived state — keep things in memory between requests (until eviction).
- Persistent storage — SQL or KV backend, transactionally consistent with in-memory state.
- WebSocket termination — clients can hold connections to specific DO instances.
- Alarms — schedule wake-ups (cron-equivalent per-DO).
- Geographic affinity — each DO lives in one Cloudflare region (closest to first access).
The trade-off is the inverse: a single DO is single-threaded. For high write throughput, shard across many DOs.
2. Defining a Durable Object class
// src/index.ts
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}
async increment(): Promise<number> {
// Storage API — strongly consistent
let count = (await this.ctx.storage.get<number>("count")) ?? 0;
count += 1;
await this.ctx.storage.put("count", count);
return count;
}
async getCount(): Promise<number> {
return (await this.ctx.storage.get<number>("count")) ?? 0;
}
// Optional: fetch handler for HTTP-style invocation
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/inc") {
const value = await this.increment();
return Response.json({ count: value });
}
if (url.pathname === "/get") {
const value = await this.getCount();
return Response.json({ count: value });
}
return new Response("Not found", { status: 404 });
}
}
// Default Worker — entry point
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const counterId = env.COUNTER.idFromName(url.searchParams.get("name") ?? "global");
const counter = env.COUNTER.get(counterId);
// Option A: RPC-style (modern, since 2024)
const count = await counter.increment();
return Response.json({ count });
// Option B: fetch-style (always works)
// return counter.fetch(request);
},
};[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Counter"]3. Addressability — three ways to get an ID
Per developers.cloudflare.com/durable-objects/api/id/. Three ways to create a DurableObjectId:
// 1. From a name (deterministic — same name → same ID always)
const id = env.COUNTER.idFromName("user-7281");
// 2. From a hex string (round-trip an ID through storage)
const id = env.COUNTER.idFromString("abc123...");
// 3. Random new ID (each call produces a different ID)
const id = env.COUNTER.newUniqueId();idFromName is the most common pattern — derive the ID from a stable identifier (user ID, room ID, document ID).
newUniqueId + storing the resulting .toString() is useful when you want a new entity without naming it.
idFromString is for round-tripping — store the ID hex from id.toString() in your DB, then re-hydrate later.
4. Storage — KV backend (legacy)
Per developers.cloudflare.com/durable-objects/api/storage-api/. The original DO storage backend. Key-value, strongly consistent, transactionally consistent with in-memory state. Still used for older DOs (those declared with new_classes in migrations rather than new_sqlite_classes).
// Read
const value = await this.ctx.storage.get<string>("user:7281");
const many = await this.ctx.storage.get<string>(["user:1", "user:2"]);
const all = await this.ctx.storage.list({ prefix: "user:", limit: 1000 });
// Write
await this.ctx.storage.put("user:7281", { name: "Adam" });
await this.ctx.storage.put({ "user:7281": { ... }, "user:7282": { ... } });
// Delete
await this.ctx.storage.delete("user:7281");
await this.ctx.storage.deleteAll();
// Transactions
await this.ctx.storage.transaction(async (txn) => {
const balance = await txn.get<number>("balance") ?? 0;
await txn.put("balance", balance + 100);
});Limits:
- Max key size: 2048 bytes
- Max value size: 128 KB
- Max per-DO storage: 1 GB (KV backend) / 10 GB (SQLite backend)
5. Storage — SQLite backend (default 2024+)
Per developers.cloudflare.com/durable-objects/api/storage-api/#sql-api. DOs declared with new_sqlite_classes get the SQLite-in-DO backend. The KV API still works (it’s emulated on top of SQLite), plus you get direct SQL:
export class Room extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Schema setup — runs once per DO instance lifetime
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
`);
}
async postMessage(author: string, body: string): Promise<number> {
const result = this.ctx.storage.sql.exec(
"INSERT INTO messages (author, body) VALUES (?, ?) RETURNING id",
author, body
).one();
return result.id as number;
}
async listRecent(limit: number = 50): Promise<Array<unknown>> {
return [...this.ctx.storage.sql.exec(
"SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
limit
)];
}
}migrations config:
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Room"] # ← SQLite backend
# Use new_classes for legacy KV backend (avoid for new DOs)Why SQLite-in-DO is interesting: each DO is effectively its own private SQLite database. For multi-tenant SaaS, this is “one database per customer” without operational pain — the database creates itself on first use, scales-to-zero when idle, and is naturally isolated. Plus: free-tier supports SQLite-backed DOs (as of 2025), so you can build real apps on the free plan.
6. WebSocket Hibernation — the cost-saver for long-lived connections
Per developers.cloudflare.com/durable-objects/api/websocket-hibernation/. The 2023 launch that changed DO economics for real-time apps.
The problem (pre-Hibernation): a WebSocket connection holds the DO instance alive for the connection’s lifetime. Billed as DO duration at $12.50/M GB-seconds. A user idling in a chat room for an hour costs the same as a user actively chatting — DO duration meter ticks regardless.
Hibernation: the DO can mark a WebSocket connection as “hibernatable” — the DO is allowed to stop running between messages. When a message arrives on the socket, Cloudflare wakes the DO, delivers the message, then suspends it again. Duration is only metered while the DO is actively processing.
Result: 100× cost reduction for chat/collaboration apps with many idle users.
export class ChatRoom extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}
async fetch(request: Request): Promise<Response> {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Use accept WITHOUT hibernation:
// server.accept();
// Or use Hibernation API (recommended):
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
// Called by Cloudflare when a message arrives (DO wakes up if hibernating)
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// Broadcast to all connected clients
for (const peer of this.ctx.getWebSockets()) {
peer.send(message);
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string) {
console.log("Closed:", code, reason);
}
async webSocketError(ws: WebSocket, error: unknown) {
console.error("Socket error:", error);
}
}Quirks:
- The DO’s in-memory state is lost during hibernation. Persistent state must be in
ctx.storage. If youlet messages = []at module scope, it’s gone on wake-up. ctx.getWebSockets()returns all connections currently attached (including hibernated ones). Broadcasting works transparently.- You can attach metadata to a socket:
ws.serializeAttachment({ userId: "u_123" })— retrieved on wake-up viaws.deserializeAttachment().
7. Alarms — scheduled wake-ups
Per developers.cloudflare.com/durable-objects/api/alarms/. Set a timestamp at which the DO should run again. The DO can be otherwise idle (no requests, no connections) and still wake up.
export class Reminder extends DurableObject<Env> {
async setReminder(timestamp: number) {
await this.ctx.storage.put("reminderText", "Take a break!");
await this.ctx.storage.setAlarm(timestamp);
}
// Called when the alarm fires
async alarm() {
const text = await this.ctx.storage.get<string>("reminderText");
console.log("Alarm fired:", text);
// Send notification, etc.
await this.ctx.storage.deleteAlarm();
}
}Use cases:
- Scheduled cleanup of stale data.
- Game tick events (every 100ms during active session, hibernate otherwise).
- Subscription expiry checks.
- Retry queues with exponential backoff (compute next alarm time on failure).
Only one alarm per DO at a time — setAlarm overwrites any prior alarm.
8. RPC — modern Worker → DO interface
Per developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-from-a-worker/. Pre-2024, you invoked DOs via do.fetch(request). The modern pattern (since the Workers RPC release in 2024) is direct method calls:
// DO class
export class Counter extends DurableObject<Env> {
async increment(): Promise<number> { ... }
async getValue(): Promise<number> { ... }
}
// Worker
const counter = env.COUNTER.get(env.COUNTER.idFromName("global"));
// Direct method call — typed, no JSON serialization at your level
const value = await counter.increment();Works with Date, Map, Set, Error, ArrayBuffer, RegExp, Blob, and other structured-cloneable types. Functions and class instances are not transferrable.
9. DO Containers (preview, 2024-25)
Per blog.cloudflare.com/cloudflare-containers/. A DO can now have an attached container running long-lived processes (Node.js, Python, Go, etc.). The DO controls the container lifecycle, and the container has bidirectional communication with the DO.
Use cases:
- Running ML models that don’t fit V8 isolate memory (4 GB+ models).
- Background workers in non-JS languages.
- Wrapping legacy code that can’t run on
workerd.
Still in preview as of 2026-05-25. Pricing model still being defined.
10. Patterns
10.1 Per-user state
// Each user has their own DO instance, addressed by user ID
const userId = "u_7281";
const id = env.USER_STATE.idFromName(userId);
const userState = env.USER_STATE.get(id);
await userState.recordLogin();10.2 Multiplayer room / collaborative document
// Each room is a DO; clients connect via WebSocket
const roomId = "room-42";
const id = env.ROOM.idFromName(roomId);
const room = env.ROOM.get(id);
return room.fetch(request); // upgrades to WebSocket inside the DO10.3 Rate limiter (per-IP, per-account)
export class RateLimiter extends DurableObject<Env> {
async check(limit: number, windowMs: number): Promise<{ ok: boolean; remaining: number }> {
const now = Date.now();
const windowStart = now - windowMs;
let timestamps = (await this.ctx.storage.get<number[]>("ts")) ?? [];
timestamps = timestamps.filter(t => t > windowStart);
if (timestamps.length >= limit) {
return { ok: false, remaining: 0 };
}
timestamps.push(now);
await this.ctx.storage.put("ts", timestamps);
return { ok: true, remaining: limit - timestamps.length };
}
}
// Usage
const id = env.LIMITER.idFromName(`ip:${clientIP}`);
const limiter = env.LIMITER.get(id);
const { ok } = await limiter.check(100, 60000); // 100 req / minute
if (!ok) return new Response("Rate limited", { status: 429 });10.4 Serverless database (one DB per tenant)
With SQLite-in-DO, each tenant gets their own database that’s free when idle:
export class TenantDB extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, ...);
`);
}
async query(sql: string, ...args: any[]) {
return [...this.ctx.storage.sql.exec(sql, ...args)];
}
}
// Tenant requests
const tenantId = await getTenantFromAuth(request);
const id = env.TENANT_DB.idFromName(`tenant:${tenantId}`);
const db = env.TENANT_DB.get(id);
const users = await db.query("SELECT * FROM users LIMIT 100");11. Pricing
Per developers.cloudflare.com/durable-objects/platform/pricing/:
- Requests: 1M / mo included on Paid, then $0.15 / M
- Duration: 400k GB-sec / mo included, then $12.50 / M
- SQLite reads (rows): 1B / mo included, then $0.001 / M
- SQLite writes (rows): 50M / mo included, then $1 / M
- SQLite storage: 1 GB / mo included, then $0.75 / GB-month
Free tier (since 2025): SQLite-backed DOs are available on Free plan — 1M requests/day, 100k SQLite reads/day, 100k SQLite writes/day, 5 GB storage. This is enough for hobbyist apps and learning.
12. Limits
- Max DOs per account: 50000 (Paid)
- Max storage per DO: 1 GB (KV backend) / 10 GB (SQLite backend)
- Max CPU per request: same as Workers (30s default Paid)
- Max concurrent WebSocket connections per DO: ~32k (soft limit, contact for higher)
- Max DOs awakened by alarms simultaneously: high; rate-limited gracefully
- DO instance memory: 128 MB (same as Workers)
13. Choosing DO vs D1
| Use case | Choose |
|---|---|
| Per-entity strong consistency | DO |
| Long-lived WebSocket connections | DO (with Hibernation) |
| Per-tenant isolation | DO (one DO per tenant) |
| Wide table joins, complex queries | D1 |
| Shared global tables (read by many writers) | D1 |
| Aggregations across entities | D1 |
| < 10 GB per tenant, simple schema | DO with SQLite backend |
| Multi-region read replication | D1 (DOs are single-region per ID) |
A common pattern is to use both: D1 for global shared data, DOs for per-user / per-room state.
14. Gotchas
- DOs are single-threaded — one request at a time per DO instance. For high write throughput, shard across many DOs (e.g.
idFromName(\shard-${userId % 100}`)`). - Geographic affinity — a DO lives in one region (the region of first access). Cross-region access pays latency. Re-creating a DO in a different region requires using a new ID.
- No cross-DO transactions — each DO is its own transaction boundary.
- Storage and in-memory aren’t automatically synced —
await ctx.storage.put(key, value)saves to disk; in-memory variables don’t survive eviction. Store everything you need to survive in storage. - Hibernation drops module-scope state — variables outside the DO class are not preserved across hibernation cycles. Move state into
ctx.storageor instance properties (and re-hydrate on wake).