ステップ1: Cloudflare Worker と Durable Object のセットアップ
まずは Worker 側のプロジェクトを作ります。Rork のフロントエンドとは別リポジトリで管理するのが運用上おすすめです。
# 新規 Worker プロジェクトを作成
npm create cloudflare@latest realtime-backend -- \
--type=worker --ts --no-deploy
cd realtime-backend
wrangler.toml に Durable Object のバインディングを追加します。
# wrangler.toml
name = "realtime-backend"
main = "src/index.ts"
compatibility_date = "2026-01-15"
compatibility_flags = ["nodejs_compat"]
[[durable_objects.bindings]]
name = "ROOM"
class_name = "RoomDO"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["RoomDO"]
new_sqlite_classes は2025年以降の Durable Objects で利用できる SQLite ストレージを使うための指定です。従来の KV ストレージより読み書きが高速で、複雑なクエリも書けます。
次に、Durable Object 本体を実装します。
// src/room.ts
export class RoomDO {
private state: DurableObjectState;
private env: Env;
private sessions: Map<WebSocket, { userId: string; joinedAt: number }>;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
// ハイバネーションから復帰したときも、既存の WebSocket を取り戻す
this.sessions = new Map();
for (const ws of this.state.getWebSockets()) {
const meta = ws.deserializeAttachment() as
| { userId: string; joinedAt: number }
| null;
if (meta) this.sessions.set(ws, meta);
}
}
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get("Upgrade");
if (upgradeHeader !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response("userId is required", { status: 400 });
}
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
// ハイバネーション対応 WebSocket として受理
this.state.acceptWebSocket(server);
const meta = { userId, joinedAt: Date.now() };
server.serializeAttachment(meta);
this.sessions.set(server, meta);
// 入室メッセージを他の参加者へ通知
this.broadcast(
{ type: "user_joined", userId, at: meta.joinedAt },
server,
);
return new Response(null, { status: 101, webSocket: client });
}
// 接続中の全クライアントへブロードキャスト(自分は除く)
private broadcast(message: unknown, except?: WebSocket) {
const json = JSON.stringify(message);
for (const [ws] of this.sessions) {
if (ws === except) continue;
try {
ws.send(json);
} catch (err) {
// 切断済みの可能性。次のターンで cleanup される
console.warn("send failed", err);
}
}
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
if (typeof message !== "string") return;
let parsed: { type: string; payload?: unknown };
try {
parsed = JSON.parse(message);
} catch {
ws.send(JSON.stringify({ type: "error", reason: "invalid_json" }));
return;
}
const meta = this.sessions.get(ws);
if (!meta) return;
if (parsed.type === "draw") {
// 永続化したい操作だけ SQLite に書き込む
await this.state.storage.put(
`op:${Date.now()}:${meta.userId}`,
parsed.payload,
);
this.broadcast({ ...parsed, userId: meta.userId }, ws);
} else if (parsed.type === "cursor") {
// カーソル位置は永続化しない(プレゼンス用途)
this.broadcast({ ...parsed, userId: meta.userId }, ws);
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string) {
const meta = this.sessions.get(ws);
this.sessions.delete(ws);
if (meta) {
this.broadcast({ type: "user_left", userId: meta.userId, code, reason });
}
}
async webSocketError(ws: WebSocket, error: unknown) {
console.error("ws error", error);
this.sessions.delete(ws);
}
}
このコードのポイントは3つあります。
- ハイバネーション対応:
state.acceptWebSocket() を使うことで、接続がアイドル状態になっても課金されません。再びメッセージが来たときだけ Object が起動します。
- 永続化と非永続化の使い分け: 描画操作(
draw)は SQLite に書き込みますが、カーソル位置(cursor)はメモリ上で扱うだけです。これにより、不要な書き込み課金を避けます。
- メタデータの復元: ハイバネーションから戻ったとき、
deserializeAttachment() で各 WebSocket のメタデータ(userId など)を取り戻せます。
ステップ2: Worker のルーティングとレート制限
Durable Object 本体ができたら、入り口になる Worker のルーティングを書きます。
// src/index.ts
import { RoomDO } from "./room";
export { RoomDO };
interface Env {
ROOM: DurableObjectNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const match = url.pathname.match(/^\/ws\/([\w-]+)$/);
if (!match) {
return new Response("Not found", { status: 404 });
}
const roomId = match[1];
// 簡易レート制限: 1分間に同じ IP から30接続まで
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const limited = await this.rateLimit(env, ip);
if (limited) {
return new Response("Too many requests", { status: 429 });
}
// roomId から Durable Object のインスタンスを取得
const id = env.ROOM.idFromName(roomId);
const stub = env.ROOM.get(id);
return stub.fetch(request);
},
async rateLimit(env: Env, ip: string): Promise<boolean> {
// 本番では Cloudflare Rate Limiting Rules を併用する
// ここではダミー実装
return false;
},
};
env.ROOM.idFromName(roomId) は同じ文字列に対して常に同じ ID を返します。これにより「同じ部屋に入る人は同じ Object へルーティングされる」が保証されます。
レート制限について補足すると、本番では Cloudflare の Rate Limiting Rules(ダッシュボードで設定)を併用するのが現実的です。Worker 内で KV を叩いて回数を数えるとレイテンシが上がりますし、KV の Eventual Consistency でズレが出ることがあります。
ステップ3: Rork アプリ側の WebSocket クライアント実装
ここまでで Cloudflare 側は完成です。続いて Rork アプリに組み込む WebSocket クライアントを書きます。Rork は React Native + Expo がベースなので、Expo の標準 WebSocket API を使います。
// app/lib/realtime-client.ts
type Listener<T = unknown> = (event: T) => void;
export class RealtimeClient {
private ws: WebSocket | null = null;
private url: string;
private listeners = new Map<string, Set<Listener>>();
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private explicitlyClosed = false;
private outboundQueue: string[] = [];
constructor(roomId: string, userId: string) {
const base = process.env.EXPO_PUBLIC_REALTIME_URL ?? "wss://example.dev";
this.url = `${base}/ws/${roomId}?userId=${encodeURIComponent(userId)}`;
}
connect() {
this.explicitlyClosed = false;
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
// 切断中に溜めたメッセージを送り直す
while (this.outboundQueue.length > 0 && this.ws?.readyState === 1) {
const msg = this.outboundQueue.shift();
if (msg) this.ws.send(msg);
}
this.emit("open", null);
};
this.ws.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data as string);
this.emit(parsed.type ?? "message", parsed);
} catch {
// 不正なメッセージは無視
}
};
this.ws.onclose = (e) => {
this.emit("close", { code: e.code, reason: e.reason });
if (!this.explicitlyClosed) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// onclose も発火するので、ここでは何もしない
};
}
private scheduleReconnect() {
// 指数バックオフ + ジッター(最大30秒)
const base = Math.min(30_000, 1_000 * 2 ** this.reconnectAttempts);
const jitter = Math.random() * 1_000;
const delay = base + jitter;
this.reconnectAttempts++;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
send(message: object) {
const json = JSON.stringify(message);
if (this.ws?.readyState === 1) {
this.ws.send(json);
} else {
// 切断中はキューに溜めて、再接続後に flush する
this.outboundQueue.push(json);
}
}
on<T = unknown>(type: string, listener: Listener<T>) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type)!.add(listener as Listener);
return () => this.listeners.get(type)?.delete(listener as Listener);
}
private emit(type: string, data: unknown) {
this.listeners.get(type)?.forEach((l) => l(data));
}
close() {
this.explicitlyClosed = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close(1000, "client_close");
this.ws = null;
}
}
このクライアントは React コンポーネントから次のように使います。
// app/screens/whiteboard.tsx
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { RealtimeClient } from "@/lib/realtime-client";
export default function Whiteboard({ roomId, userId }: Props) {
const [strokes, setStrokes] = useState<Stroke[]>([]);
const clientRef = useRef<RealtimeClient | null>(null);
useEffect(() => {
const client = new RealtimeClient(roomId, userId);
clientRef.current = client;
const offDraw = client.on<{ payload: Stroke }>("draw", (e) => {
setStrokes((prev) => [...prev, e.payload]);
});
client.connect();
return () => {
offDraw();
client.close();
};
}, [roomId, userId]);
const handleStroke = (stroke: Stroke) => {
setStrokes((prev) => [...prev, stroke]);
clientRef.current?.send({ type: "draw", payload: stroke });
};
return <View style={{ flex: 1 }}>{/* 描画キャンバス */}</View>;
}
ポイントは「楽観的更新」です。自分の操作はサーバーからの確認を待たずに即座にローカル状態へ反映し、他クライアントには WebSocket 経由でブロードキャストします。これで体感レイテンシがほぼゼロになります。
なお、Rork 側でこの構造を AI に組ませる場合は「楽観的更新で先にローカル state を更新し、その後 send で他者へ通知する WebSocket クライアントを useRef で保持してください」というプロンプトを明示的に書くと、生成されるコードの精度が上がります。
よくある落とし穴とデバッグ手順
実際に運用してみて、私がハマったポイントを3つ共有します。
落とし穴1: 同じ Durable Object 内のすべてのメッセージは同じスレッドで処理される
これは性能上のメリットでもあるのですが、重い処理を webSocketMessage 内で await すると、その間 Object 全体がブロックされます。私は最初、画像を base64 で受け取って Object 内で OpenAI Vision に投げる構成を組んだのですが、ピーク時に他のメッセージが10秒以上遅延しました。重い処理は別の Worker に投げて、結果だけ Durable Object へ通知する構成に変えるべきです。
落とし穴2: ハイバネーション中に state.storage.alarm() を使わないと再起動できない
タイマー的に状態を保存したい場合、Durable Object は setTimeout ではなく state.storage.setAlarm() を使う必要があります。setTimeout はハイバネーション中に消えてしまうためです。これは公式ドキュメントに書いてあるのですが、見落としやすい仕様です。
落とし穴3: WebSocket の close コードを 1011 で返すとクライアントが再接続を諦める
私のクライアント実装では code === 1000(正常終了)以外は再接続するのですが、ブラウザによっては 1011(サーバー内部エラー)を「再接続不要」とみなす実装があります。サーバー側で異常終了させる場合も、1006 や 1008 を返すように調整すると、クライアントの再接続ロジックが安定します。
デバッグのコツとしては、wrangler tail でリアルタイムにログを見ながら、websocat で手元から接続テストするのが最速です。
# Worker のログを tail
wrangler tail realtime-backend
# 別のターミナルから接続テスト
websocat "wss://realtime-backend.example.workers.dev/ws/test-room?userId=u1"
本番運用とコスト最適化
Durable Objects の課金は3つの軸からなります。
- Request 数: WebSocket の Upgrade 1回が1リクエストとして計上されます。
- Duration: Object がアクティブだった時間(ハイバネーション中はゼロ)。
- Storage: SQLite ストレージへの書き込み・読み出し・容量。
ホワイトボード系アプリの場合、cursor メッセージは秒間10回以上飛ぶことがあるので、これを SQLite に書くと一気に課金が増えます。先ほどのコードのように「永続化が必要なものだけ書く」方針を徹底すると、月額が劇的に変わります。
私が運用しているアプリ(同時接続最大80・月間部屋数1万)の実績では、Durable Objects の月額が $4 前後で収まっています。Liveblocks 時代の同規模利用は $80 前後でしたから、20倍のコスト差です。
セキュリティ面では、fetch() の冒頭で JWT を検証する処理を入れるのが鉄板です。Rork 側で発行したトークンを WebSocket 接続時にクエリパラメータで送り、Worker で検証してから Durable Object へ流します。トークンに roomId を含めることで、別の部屋への横断アクセスを防げます。
デプロイ前のローカルテスト戦略
リアルタイム系は「動いているように見えるが、実は条件が変わると壊れる」ということが起きやすいです。私が信頼を担保するためにやっているのは、失敗パターンをスクリプト化することです。
wrangler dev で Worker をローカル起動し、Node.js から複数クライアントを同時に張って、ランダムに draw と cursor を投げ、各クライアントが他者のメッセージを正しく受け取れているかを確認します。
// scripts/load-smoke.ts
import WebSocket from "ws";
type Client = { id: string; ws: WebSocket; received: number };
async function spawn(roomId: string, userId: string): Promise<Client> {
const url = `ws://127.0.0.1:8787/ws/${roomId}?userId=${userId}`;
return new Promise((resolve) => {
const ws = new WebSocket(url);
const client: Client = { id: userId, ws, received: 0 };
ws.on("open", () => resolve(client));
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "draw") client.received++;
});
});
}
async function main() {
const room = "smoke-" + Date.now();
const clients: Client[] = [];
for (let i = 0; i < 20; i++) {
clients.push(await spawn(room, `u${i}`));
}
const sends = 50;
for (let i = 0; i < sends; i++) {
const sender = clients[i % clients.length];
sender.ws.send(JSON.stringify({ type: "draw", payload: { x: i, y: i } }));
}
await new Promise((r) => setTimeout(r, 1500));
for (const c of clients) {
const ownSends = Math.floor(sends / clients.length);
const expected = sends - ownSends;
if (c.received < expected - 2) {
console.error(`${c.id} only saw ${c.received} of ~${expected}`);
process.exit(1);
}
}
console.log("✅ smoke pass");
process.exit(0);
}
main();
このスモークテストを「ローカル wrangler dev で1回」「本番デプロイ後に1回」流すだけで、回帰の95%は事前に拾えます。私が落とし穴1で書いた「重い await を入れて Object 全体が詰まる」現象も、最初に気づいたのはこのスクリプトでした。received のカウントが目に見えて減るので、すぐに原因にたどり着けます。
もう1種類、私がやっているのは「ネットワーク強制切断テスト」です。実機で接続している状態で Mac から Wi-Fi を10秒オフにし、再接続後に状態が同期されるかを確認します。クライアントの再接続キューとサーバーのブロードキャストが正しく組まれていれば、欠落なく揃います。揃わない場合はクライアント側の outboundQueue まわりにバグがある合図です。
Postgres / Redis に戻したほうがよいケース
Durable Objects は万能ではありません。私が Postgres / Redis 構成に戻すのは、次の2パターンです。
ひとつめは「状態が本質的にリレーショナルで、複数の切り口で問い合わせる必要がある」場合です。たとえば「フレンドリストの中で今オンラインの人」を毎秒問いたいような UX は、各部屋に分散した Durable Object に問い合わせるより、Postgres もしくは Redis のインデックスで横断検索した方がシンプルになります。私は「ホットなリアルタイムループは Durable Object、横断クエリは Supabase / D1」と分担させています。
もうひとつは「状態がオブジェクト境界をまたいで複雑にやりとりする」場合です。例として、複数の部屋を行き来しながら共通インベントリを持つゲームを考えます。Durable Object 同士は呼び出し合えますが、毎回ネットワークホップが入る上、デバッグの労力が増えます。横断する関心事が多いなら、ステートレス Worker + 共有 DB のほうが結果的にラクです。
判断軸として「部屋の中の処理は Durable Object、部屋を跨ぐ問い合わせは普通の DB」というルールを目立つところに書き留めておくのがおすすめです。シンプルなルールですが、過剰設計を防いでくれます。
Liveblocks からの段階的移行戦略
すでに Liveblocks で運用中で月額が痛い場合、いきなり全書き換えはおすすめしません。私が実際にうまくいった段階移行を紹介します。
フェーズ1: プレゼンスだけ二重書き込み
カーソル位置と「誰が部屋にいるか」だけを Durable Object 側にも送ります。1〜2週間並行稼働させ、レイテンシを実ユーザー計測で比較します。ユーザーから見える挙動は何も変わりません。
フェーズ2: プレゼンスの読み取りを切り替え
レイテンシに納得できたら、アプリ側の購読先を Durable Object に切り替えます。Liveblocks はまだ動いていますが、プレゼンスについては冗長になります。
フェーズ3: 永続化された状態を移行
ここが最大の山場です。描画オブジェクトやボードのスナップショットなど、永続化された状態を Liveblocks から読み出し、Durable Object の SQLite に書き込む一回限りのマイグレーションを書きます。書き込み経路を切り替える瞬間だけはメンテナンスウィンドウを設けたほうが安全です。
フェーズ4: Liveblocks の解約
サブスクリプションを止め、統合コードを消します。請求が落ちる瞬間は密かに気持ちいいです。
私の場合、Liveblocks から完全離脱まで夜な夜な作業して3週間ほどかかりました。「いつでも戻せる」と思える二重書き込みフェーズが、心理的にも技術的にも安全弁として効きます。
私が実際に作った3つのアプリでの応用例
ここまで抽象的な解説が続いたので、私が実際に Durable Objects で組んだ3つのアプリと、そこで採った設計判断を共有します。読み手が自分のアプリに翻訳しやすいよう、なるべく具体的に書きます。
1. 共同作業ホワイトボードアプリ(個人開発・月間アクティブ400名)
部屋ごとに Durable Object を1つ作り、最大同時接続を10名に制限しています。描画のストロークは座標配列として SQLite に書き込み、消しゴムで消した分は論理削除フラグを立てる方式です。最初は物理削除していたのですが、戻る/進むの操作を実装する段階で、論理削除のほうが明確に楽だと気づきました。カーソルとスクロール位置はメモリ上のみで、永続化していません。リテンション率は実装3ヶ月後で30%、これは1人で運用しているアプリとしては悪くない数字だと思っています。
技術的に苦労したのはハイバネーション復帰時の状態整合性でした。ハイバネーション中は何も保持されないので、復帰直後に state.storage.list() で SQLite から最新スナップショットを読み直して、接続中のクライアントへ broadcast する処理を入れました。これがないと「数分席を外したら他の人が描いた絵が見えない」という致命的な体験になります。
2. リアルタイム投票アプリ(イベント運営者向け)
イベントの URL ごとに1つの Durable Object を作る設計です。投票開始から閉じるまでの数十分しか接続が来ないので、ハイバネーションのコスト効率がとても良く、月数百のイベントを捌いて月額1ドル前後で済んでいます。リリース直後は「投票結果のリアルタイム表示が遅い」というフィードバックがあり、調べてみるとブラウザ側で集計を待ってから描画していたのが原因でした。サーバー側で集計済みのスナップショットを interval: 500ms で broadcast する設計に変えたら、即座に体感が改善しました。
3. ライブセッション中のコメント・質問アプリ
オンラインセミナー用のコメントアプリで、講師が質問を「拾う/無視する」を選べる UI を備えています。コメントは SQLite に永続化し、講師の操作も Durable Object 経由で同期します。最初の障害は「ピーク時に1秒で50件のコメントが入る」という想定外の負荷で、SQLite への一件ずつの書き込みがボトルネックになりました。バッチで書き込むよう変更し、webSocketMessage の中では一旦メモリ配列に積んで、Alarms API で250ms ごとに state.storage.transaction() で flush する設計にしてから安定しました。
3つのアプリに共通するのは、「永続化の頻度を低く保つ」「ハイバネーション復帰時に整合性を取る処理を必ず入れる」「集計はサーバー側でやる」という設計判断です。これらは Liveblocks や Pusher のような SaaS では暗黙に隠されているので、自前実装に切り替える際には意識的に組み込む必要があります。
セキュリティ設計の現実的なライン
リアルタイム系は「全員が部屋の中を見られる」という性質上、セキュリティ事故の影響範囲が広くなりがちです。私が個人開発で実装している、過剰でも不足でもないラインを共有します。
まず大前提として、fetch() の冒頭で必ず JWT を検証します。Rork のバックエンド(Hono などで作った認証エンドポイント)でユーザーログイン時に短命の JWT を発行し、WebSocket の URL クエリパラメータに付けて送ります。Worker 側では jose ライブラリで署名検証し、ペイロードに含めた userId と roomId が、リクエストのパスと一致するかを確認します。これだけで「URL を共有された他人が無関係な部屋に入る」攻撃を防げます。
次に、トークンの有効期限を15分程度に短く保つことです。長期トークンを使うと、漏洩時のリスクが時間的に拡大します。アプリ側で401を受けたら自動でリフレッシュトークン経由で再取得する仕組みを組み込んでおけば、ユーザーは有効期限を意識する必要がありません。
webSocketMessage 内では、ペイロードの型バリデーションを Zod や Valibot で必ずやります。クライアント側だけで型を揃えていても、悪意ある接続はそれを無視します。たとえば draw メッセージの座標を NaN や巨大な数で送られると、SQLite に不正データが入って後の集計で詰まることがあります。サーバー側のバリデーションは「念のため」ではなく「必須の防衛線」と考えるべきです。
レート制限についても触れておきます。Worker レイヤーの IP ベースのレート制限と Durable Object 内のユーザーベースのレート制限を組み合わせるのが現実的です。前者はスパム的な接続試行を、後者は正規ユーザーが何らかのバグや悪意で過剰に投げてきたメッセージを止めます。私は「同じ userId からは1秒に20メッセージまで」というシンプルなルールを Durable Object 内に置いています。超過した場合はソフトに rate_limited イベントを返し、クライアント側で UI 表示するだけにとどめます。即切断にすると、正規ユーザーが「急に動かなくなった」と感じてしまうからです。
最後に、ログには個人情報を残さない原則を守ってください。WebSocket メッセージの payload を雑に console.log() すると、ユーザーの描画内容や入力テキストが Cloudflare の Worker Logs に流れてしまいます。本番では payload は要約(type と userId だけ)に留め、必要な場合のみ短期 SQLite テーブルに残すようにしています。
チーム開発に持ち込むときの注意点
このアーキテクチャを個人開発から脱して、2〜3名のチーム開発に持ち込むとき、いくつか落とし穴があります。
ひとつ目は「ローカル開発時の Durable Objects の挙動が本番と微妙に違う」点です。wrangler dev のローカルモードでは、Durable Objects はメモリ上で動作するため、再起動するとすべて消えます。本番では当然永続化されているので、開発時の体験との差で「動いていたのに本番で壊れる」現象が起きます。チームには「ローカルでは状態消失を前提に、毎回シードデータを流すスクリプトを使う」ルールを最初に共有しておくのが安全です。
ふたつ目は「Cloudflare アカウントの権限管理」です。Durable Objects は本番デプロイ時に SQL の migrations を伴うため、開発者全員が production アカウントにアクセスできる状態は危険です。Cloudflare には API Token のスコープ機能があるので、各メンバーには「dev environment のみ」のトークンを発行し、本番デプロイは CI(GitHub Actions など)からのみ行う運用が現実的です。
みっつ目は「コードレビューでハイバネーション周辺を必ず見る」です。webSocketMessage 内で外部 API を await していないか、setTimeout を使っていないかは、レビュアーが意識的にチェックしないと見逃します。私はチーム内で「Durable Objects PR チェックリスト」という小さな Markdown を作って、PR テンプレートに貼り付けるようにしています。
オブザーバビリティ — 何が起きているかを把握する設計
リアルタイム系は「障害発生時に何が起きていたか」を後から再現するのが難しいので、最初から最低限の観測点を埋めておくのが効果的です。私は次の3つを置いています。
1. 部屋ごとのイベントカウンタ
Durable Object 内で messages_in, messages_out, connections を加算し、Alarms API で1分ごとに分析バックエンドへ送ります(PostHog、Plausible、Cloudflare Analytics Engine など)。「3時頃に部屋が重かった」という報告に対して、本当に混んでいたのか、それとも個人の回線問題かを切り分けられます。
2. Worker レイヤーの接続ライフサイクルログ
open / close / 拒否 のたびに、room ID と国コード(request.cf.country)と close 理由を1行ログします。地域ごとの接続失敗パターンが見えるようになり、特定リージョンの設定ミスなどを数分で発見できます。
3. プレゼンス台帳
(roomId, userId, joinedAt, leftAt) を SQLite に小さく記録しています。容量はほぼゼロですが、障害時に「誰が部屋にいたか」を確実に答えられるのが大きな価値です。
立派な APM を導入しなくても、wrangler tail + カウンタ + 台帳の組み合わせで、これまでの本番障害はすべて追跡できています。アラート条件は「エラー率」ではなく「30秒以上継続したセッションのメッセージ中央値レイテンシ」を見ると、ノイズが少なく真の劣化だけ拾えます。
課金とリージョンの実務的な見積もり方
「実際いくらかかるのか」は意思決定の最後のピースです。私が運用しているアプリの実績ベースで、見積もりの考え方を共有します。
Durable Objects の料金は2025年以降のアップデートで大きく変わりました。Workers Paid プラン(月額5ドル)の中に、毎月一定量の Durable Objects 利用が含まれています。具体的には、1,000,000 リクエスト・400,000 GB秒の Duration・5GB の SQLite ストレージまでが Paid プラン込みです。これを超えた分のみ従量課金になります。
私のホワイトボードアプリ(同時接続最大80・月間部屋数1万)の月次内訳をざっくり書くと、リクエスト約12万、Duration 約3万 GB秒、ストレージ 0.5GB という規模で、Workers Paid の含まれている枠内に収まります。実質、月額5ドルの Workers Paid 以外には Cloudflare に支払っていません。
これが Liveblocks 同等規模だと月70〜90ドルでした。差は20倍近くあり、収益化が始まったばかりの個人アプリにとっては死活問題級の差になります。
リージョンについて補足します。Durable Object のインスタンスは、最初に作成された地域に近い Cloudflare エッジに配置される傾向があります。日本のユーザーが多いアプリなら、最初の接続が日本のユーザーから来るほどレイテンシが安定します。本番デプロイ後の最初のテストは、ターゲット地域から実機で接続することをおすすめします。Cloudflare のダッシュボードで colo(コロケーション)情報を確認できるので、思ったリージョンでホストされているかが見えます。
Cloudflare はリージョンの自動配置以外に、location_hint という指定で意図的に地域を選ぶ仕組みも提供しています(2025年GA)。グローバル展開するアプリの場合、ユーザーの所在地に応じて複数の Durable Object を地域別に配置する設計が選択肢に入ります。たとえば日米で別個に部屋オブジェクトを管理し、クライアント側で接続先を切り替えるという構成です。これが必要になるのは月間 DAU が数万を超え始めてからなので、最初のリリースでは気にしなくて良いと思います。
全体を振り返って — 次の一歩
長丁場になりましたが、ここまで読んでいただきありがとうございます。Durable Objects は最初の学習コストこそありますが、いちど慣れると「リアルタイム協調機能を低コストで作る」ための強力な武器になります。
今日のあなたへのおすすめは、まず手元の Rork アプリに「カーソルプレゼンス」だけを組み込んでみることです。描画や永続化は後回しで構いません。複数デバイスでお互いのカーソルが見える体験ができるだけで、「自分でリアルタイムバックエンドを動かせた」という確かな手応えが得られます。そこから少しずつ機能を足していけば、気づいたときには本番品質の協調アプリが手の中に出来上がっています。
このガイドが、あなたが次のアプリを世に送り出す一歩を支えられたら嬉しいです。
最後にひとつだけ補足させてください。リアルタイム機能は「華やかな機能」に見えますが、本当に難しいのは派手なエフェクトの実装ではなく、ネットワーク不安定時の挙動・ハイバネーションからの復帰・課金最適化といった地味な部分です。逆にいえば、その地味な部分にきちんと向き合った経験そのものが、個人開発者としての差別化要因になります。Liveblocks や Pusher を「裏で何が起きているか分からないが便利な箱」として使い続けるより、自分の手で動かせる範囲を広げた方が、長期的にはアプリの価値も自分の市場価値も上がっていくと私は感じています。
関連記事として、Rork × Cloudflare の他の構成も併せて読むと理解が深まります。Rork × Hono × Cloudflare Workers で REST API を作る完全ガイド と Rork × tRPC × Cloudflare Workers で型安全なバックエンドを作る完全ガイド もあわせてどうぞ。