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

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 you let 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 via ws.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 DO

10.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 caseChoose
Per-entity strong consistencyDO
Long-lived WebSocket connectionsDO (with Hibernation)
Per-tenant isolationDO (one DO per tenant)
Wide table joins, complex queriesD1
Shared global tables (read by many writers)D1
Aggregations across entitiesD1
< 10 GB per tenant, simple schemaDO with SQLite backend
Multi-region read replicationD1 (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 syncedawait 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.storage or instance properties (and re-hydrate on wake).

Further reading