Rules of Durable Objects·续2

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
    // This code is safe due to input gates
    async increment() {
        // While these storage operations execute, no other requests
        // can interleave - input gate blocks new events
        const value = (await this.ctx.storage.get("count")) ?? 0;
        await this.ctx.storage.put("count", value + 1);
        return value + 1;
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    COUNTER: DurableObjectNamespace<Counter>;
}

export class Counter extends DurableObject<Env> {
    // This code is safe due to input gates
    async increment(): Promise<number> {
        // While these storage operations execute, no other requests
        // can interleave - input gate blocks new events
        const value = (await this.ctx.storage.get<number>("count")) ?? 0;
        await this.ctx.storage.put("count", value + 1);
        return value + 1;
    }
}

Output gates hold outgoing network messages (responses, fetch requests) until pending storage writes complete. This ensures clients never see confirmation of data that has not been persisted:

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
    async sendMessage(userId, content) {
        // Write to storage - don't need to await for correctness
        this.ctx.storage.sql.exec(
            "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
            userId,
            content,
            Date.now(),
        );

// This response is held by the output gate until the write completes.
        // The client only receives "Message sent" after data is safely persisted.
        return "Message sent";
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
    async sendMessage(userId: string, content: string): Promise<string> {
        // Write to storage - don't need to await for correctness
        this.ctx.storage.sql.exec(
            "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
            userId,
            content,
            Date.now()
        );

// This response is held by the output gate until the write completes.
        // The client only receives "Message sent" after data is safely persisted.
        return "Message sent";
    }
}

Write coalescing: Multiple storage writes without intervening await calls are automatically batched into a single atomic implicit transaction:

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class Account extends DurableObject {
    async transfer(fromId, toId, amount) {
        // ✅ Good: These writes are coalesced into one atomic transaction
        this.ctx.storage.sql.exec(
            "UPDATE accounts SET balance = balance - ? WHERE id = ?",
            amount,
            fromId,
        );
        this.ctx.storage.sql.exec(
            "UPDATE accounts SET balance = balance + ? WHERE id = ?",
            amount,
            toId,
        );
        this.ctx.storage.sql.exec(
            "INSERT INTO transfers (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)",
            fromId,
            toId,
            amount,
            Date.now(),
        );
        // All three writes commit together atomically
    }

// 🔴 Bad: await on KV operations breaks coalescing
    async transferBrokenKV(fromId, toId, amount) {
        const fromBalance = (await this.ctx.storage.get(`balance:${fromId}`)) ?? 0;
        await this.ctx.storage.put(`balance:${fromId}`, fromBalance - amount);
        // If the next write fails, the debit already committed!
        const toBalance = (await this.ctx.storage.get(`balance:${toId}`)) ?? 0;
        await this.ctx.storage.put(`balance:${toId}`, toBalance + amount);
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    ACCOUNT: DurableObjectNamespace<Account>;
}

export class Account extends DurableObject<Env> {
    async transfer(fromId: string, toId: string, amount: number) {
        // ✅ Good: These writes are coalesced into one atomic transaction
        this.ctx.storage.sql.exec(
            "UPDATE accounts SET balance = balance - ? WHERE id = ?",
            amount,
            fromId
        );
        this.ctx.storage.sql.exec(
            "UPDATE accounts SET balance = balance + ? WHERE id = ?",
            amount,
            toId
        );
        this.ctx.storage.sql.exec(
            "INSERT INTO transfers (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)",
            fromId,
            toId,
            amount,
            Date.now()
        );
        // All three writes commit together atomically
    }

// 🔴 Bad: await on KV operations breaks coalescing
    async transferBrokenKV(fromId: string, toId: string, amount: number) {
        const fromBalance = (await this.ctx.storage.get<number>(`balance:${fromId}`)) ?? 0;
        await this.ctx.storage.put(`balance:${fromId}`, fromBalance - amount);
        // If the next write fails, the debit already committed!
        const toBalance = (await this.ctx.storage.get<number>(`balance:${toId}`)) ?? 0;
        await this.ctx.storage.put(`balance:${toId}`, toBalance + amount);
    }
}

For more details, see Durable Objects: Easy, Fast, Correct — Choose three ↗︎ and the glossary.

Avoid race conditions with non-storage I/O

Input gates only protect during storage operations. Non-storage I/O like fetch() or writing to R2 allows other requests to interleave, which can cause race conditions:

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class Processor extends DurableObject {
    // ⚠️ Potential race condition: fetch() allows interleaving
    async processItem(id) {
        const item = await this.ctx.storage.get(`item:${id}`);

if (item?.status === "pending") {
            // During this fetch, other requests CAN execute and modify storage
            const result = await fetch("https://api.example.com/process");

// Another request may have already processed this item!
            await this.ctx.storage.put(`item:${id}`, { status: "completed" });
        }
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    PROCESSOR: DurableObjectNamespace<Processor>;
}

export class Processor extends DurableObject<Env> {
    // ⚠️ Potential race condition: fetch() allows interleaving
    async processItem(id: string) {
        const item = await this.ctx.storage.get<{ status: string }>(`item:${id}`);

if (item?.status === "pending") {
            // During this fetch, other requests CAN execute and modify storage
            const result = await fetch("https://api.example.com/process");

// Another request may have already processed this item!
            await this.ctx.storage.put(`item:${id}`, { status: "completed" });
        }
    }
}

To handle this, use optimistic locking (check-and-set) patterns: read a version number before the external call, then verify it has not changed before writing.

Note

With the legacy KV storage backend, use the transaction() method for atomic read-modify-write operations across async boundaries.

Use blockConcurrencyWhile() sparingly

The blockConcurrencyWhile() method guarantees that no other events are processed until the provided callback completes, even if the callback performs asynchronous I/O. This is useful for operations that must be atomic, such as state initialization from storage in the constructor:

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
    constructor(ctx, env) {
        super(ctx, env);

// ✅ Good: Use blockConcurrencyWhile for one-time initialization
        ctx.blockConcurrencyWhile(async () => {
            this.ctx.storage.sql.exec(`
                CREATE TABLE IF NOT EXISTS messages (
                    id INTEGER PRIMARY KEY,
                    content TEXT
                )
            `);
        });
    }

// 🔴 Bad: Don't use blockConcurrencyWhile on every request
    async sendMessageSlow(content) {
        await this.ctx.blockConcurrencyWhile(async () => {
            this.ctx.storage.sql.exec(
                "INSERT INTO messages (content) VALUES (?)",
                content,
            );
        });
        // If this takes ~5ms, you're limited to ~200 requests/second
    }

// ✅ Good: Let output gates handle consistency
    async sendMessageFast(content) {
        this.ctx.storage.sql.exec(
            "INSERT INTO messages (content) VALUES (?)",
            content,
        );
        // Output gate ensures write completes before response is sent
        // Other requests can be processed concurrently
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
    constructor(ctx: DurableObjectState, env: Env) {
        super(ctx, env);

// ✅ Good: Use blockConcurrencyWhile for one-time initialization
        ctx.blockConcurrencyWhile(async () => {
            this.ctx.storage.sql.exec(`
                CREATE TABLE IF NOT EXISTS messages (
                    id INTEGER PRIMARY KEY,
                    content TEXT
                )
            `);
        });
    }

// 🔴 Bad: Don't use blockConcurrencyWhile on every request
    async sendMessageSlow(content: string) {
        await this.ctx.blockConcurrencyWhile(async () => {
            this.ctx.storage.sql.exec(
                "INSERT INTO messages (content) VALUES (?)",
                content
            );
        });
        // If this takes ~5ms, you're limited to ~200 requests/second
    }

// ✅ Good: Let output gates handle consistency
    async sendMessageFast(content: string) {
        this.ctx.storage.sql.exec(
            "INSERT INTO messages (content) VALUES (?)",
            content
        );
        // Output gate ensures write completes before response is sent
        // Other requests can be processed concurrently
    }
}

Because blockConcurrencyWhile() blocks all concurrency unconditionally, it significantly reduces throughput. If each call takes \~5ms, that individual Durable Object is limited to approximately 200 requests/second. Reserve it for initialization and migrations, not regular request handling. For normal operations, rely on input/output gates and write coalescing instead.

For atomic read-modify-write operations during request handling, prefer transaction() over blockConcurrencyWhile(). Transactions provide atomicity for storage operations without blocking unrelated concurrent requests.

Caution

Using blockConcurrencyWhile() across I/O operations (such as fetch(), KV, R2, or other external API calls) is an anti-pattern. This is equivalent to holding a lock across I/O in other languages or concurrency frameworks — it blocks all other requests while waiting for slow external operations, severely degrading throughput. Keep blockConcurrencyWhile() callbacks fast and limited to local storage operations.

Communication and API design

Use RPC methods instead of the fetch() handler

Projects with a compatibility date of 2024-04-03 or later should use RPC methods. RPC is more ergonomic, provides better type safety, and eliminates manual request/response parsing.

Define public methods on your Durable Object class, and call them directly from stubs with full TypeScript support:

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
    // Public methods are automatically exposed as RPC endpoints
    async sendMessage(userId, content) {
        const createdAt = Date.now();
        const result = this.ctx.storage.sql.exec(
            "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
            userId,
            content,
            createdAt,
        );
        const { id } = result.one();
        return { id, userId, content, createdAt };
    }

async getMessages(limit = 50) {
        const cursor = this.ctx.storage.sql.exec(
            "SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
            limit,
        );

return cursor.toArray().map((row) => ({
            id: row.id,
            userId: row.user_id,
            content: row.content,
            createdAt: row.created_at,
        }));
    }
}

export default {
    async fetch(request, env) {
        const url = new URL(request.url);
        const roomId = url.searchParams.get("room") ?? "lobby";

const id = env.CHAT_ROOM.idFromName(roomId);
        // stub is typed as DurableObjectStub<ChatRoom>
        const stub = env.CHAT_ROOM.get(id);

if (request.method === "POST") {
            const { userId, content } = await request.json();
            // Direct method call with full type checking
            const message = await stub.sendMessage(userId, content);
            return Response.json(message);
        }

// TypeScript knows getMessages() returns Promise<Message[]>
        const messages = await stub.getMessages(100);
        return Response.json(messages);
    },
};

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    // Type parameter provides typed method calls on the stub
    CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

type Message = {
id: number;
userId: string;
content: string;
createdAt: number;
};

export class ChatRoom extends DurableObject<Env> {
    // Public methods are automatically exposed as RPC endpoints
    async sendMessage(userId: string, content: string): Promise<Message> {
        const createdAt = Date.now();
        const result = this.ctx.storage.sql.exec<{ id: number }>(
            "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
            userId,
            content,
            createdAt
        );
        const { id } = result.one();
        return { id, userId, content, createdAt };
    }

async getMessages(limit: number = 50): Promise<Message[]> {
        const cursor = this.ctx.storage.sql.exec<{
            id: number;
            user_id: string;
            content: string;
            created_at: number;
        }>("SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", limit);

return cursor.toArray().map((row) => ({
            id: row.id,
            userId: row.user_id,
            content: row.content,
            createdAt: row.created_at,
        }));
    }
}

export default {
    async fetch(request: Request, env: Env): Promise<Response> {
        const url = new URL(request.url);
        const roomId = url.searchParams.get("room") ?? "lobby";

const id = env.CHAT_ROOM.idFromName(roomId);
        // stub is typed as DurableObjectStub<ChatRoom>
        const stub = env.CHAT_ROOM.get(id);

if (request.method === "POST") {
            const { userId, content } = await request.json<{
                userId: string;
                content: string;
            }>();
            // Direct method call with full type checking
            const message = await stub.sendMessage(userId, content);
            return Response.json(message);
        }

// TypeScript knows getMessages() returns Promise<Message[]>
        const messages = await stub.getMessages(100);
        return Response.json(messages);
    },
};

Refer to Invoke methods for more details on RPC and the legacy fetch() handler.

Read a Durable Object's identity from ctx.id

A Durable Object can read its own ID from this.ctx.id. If the caller accessed the Durable Object with getByName() or idFromName(), this.ctx.id.name holds that name. You do not need to pass the name as an argument or store it yourself. This also works in alarm handlers, where there is no caller to pass the name.

ctx.id.name is undefined if the Durable Object was created with newUniqueId(), was accessed with idFromString(), or was given a name longer than 1,024 bytes. Refer to Durable Object ID for details.

If your Durable Object needs application-defined data that is not part of its ID (for example, who created it), use an explicit init() method to store that data.

index.jsjs

```js import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject { // The name passed to getByName() or idFromName() getRoomId() { const roomId = this.ctx.id.name; if (!roomId) { throw new Error("ChatRoom must be accessed by name."); } return roomId; }

// Store application-defined data that is not part of the ID async init(createdBy) { const existing = await this.ctx.storage.get("createdBy"); if (existing) { return; // Already initialized }

await this.ctx.storage.put("createdBy", createdBy); await this.ctx.storage.put("createdAt", Date.now()); } }

export default { async fetch(request, env) { const url = new URL(request.url); const roomId = url.searchParams.get("room") ?? "lobby";