Cloudflare Workers のリアルタイム更新 — Durable Objects で実装して見えた 4 つのベストプラクティス
こんにちは!システムデザインワークスの山岸です。
住宅会社様向けに開発している顧客管理システムで、店舗受付のヒアリングシートを「お客様のスマホ ⇄ スタッフの PC」でリアルタイムに同時編集する機能を実装しました。基盤は Cloudflare Workers + Durable Objects の WebSocket です。
この実装を通じて、Workers でリアルタイム更新を組むときの型がはっきり見えてきました。先に結論をまとめます。
WebSocket では値を運ばない。「変わった」という事実だけを送り、値は通常の API 経路で取る
Hibernation API を使う。 接続しているだけの時間を課金から外す
push が全滅しても収束する縮退経路を作る。 リアルタイムは高速化であって生命線にしない
双方向にしない。 機能を最小に絞るほど、堅牢さは上がる
本記事では、この 4 つを実装の実例とあわせて解説します。
背景・課題:お客様の入力を、スタッフの画面に「その場で」映したい
要件はシンプルです。店舗に来られたお客様が QR コードを読み取ると、スマホにヒアリングシートが開きます。お客様が入力した内容は、スタッフの PC 画面にその場で反映され、スタッフ側も同じシートを同時に編集できます。入力が終わればスタッフが内容を確認し、そのまま顧客情報として登録する——受付の転記作業をなくす仕組みです。
最初のバージョンは 3 秒間隔のポーリング(定期的にサーバーへ問い合わせる方式)で作りました。動きはするのですが、反映までに最大 3 秒の待ちがあり、誰も操作していない間も通信が走り続けます。そこで反映を即時にするために WebSocket を導入することにしました。
ここで Workers 特有の事情が出てきます。Workers は以前の移行記事でも紹介したとおり、リクエストのたびに起動する実行環境で、常駐サーバーがありません。つまり「接続をずっと抱えておく場所」が普通の Worker にはないのです。その役割を担うのが Durable Objects——名前で一意に引ける、状態と接続を保持できる特別なオブジェクトです。今回は受付セッション 1 件につき Durable Object を 1 つ割り当てました。
ベストプラクティス 1:WebSocket では「変わった」だけを送り、値は通常経路で取る
Durable Objects は永続ストレージも持てるので、「編集中の値を Durable Object に持たせ、WebSocket で値を配る」設計も可能です。しかし今回は、あえて状態を一切持たせませんでした。
編集中の値の正本は D1(データベース)に置く
Durable Object がやるのは「変わったよ」という通知(poke)を接続中の全員に配ることだけ
通知を受けたクライアントは、連番(seq)カーソル付きの差分 API を叩いて値を取る
流れを図にするとこうなります。
なぜ WebSocket で値そのものを送らないのか。理由は堅牢性です。値を push で運ぶと、メッセージの順序・重複・取りこぼしがそのまま画面の正しさに直結し、その全パターンをクライアントで正しく処理し続ける必要があります。「変わった」だけを送る設計なら、poke は何通届いても、何通落ちても構いません。最新の状態は差分 API が保証してくれるからです。自分の書き込みが自分に返ってくるエコーも、seq の比較で捨てるだけで済みます。
副次的な効果として、セキュリティの心配もひとつ減ります。通知に業務データを載せなければ、万一配信経路の設定に穴があっても、そこから漏れるものがありません。
ベストプラクティス 2:Hibernation API で「つなぎっぱなし」を課金から外す
Durable Objects の WebSocket には新旧 2 つの書き方があり、現在の推奨は Hibernation API です。接続を維持したまま、待機中はオブジェクトをメモリから退避してくれるため、接続しているだけの時間は課金対象になりません。受付のように「開きっぱなしだが操作は散発的」という用途にぴったりで、今回の想定負荷では Workers 有料プランの込み枠に収まり、追加費用ゼロで運用できています。
1export class IntakeRoom {2 constructor(private state: DurableObjectState) {}34 async fetch(request: Request): Promise<Response> {5 const url = new URL(request.url);6 if (url.pathname === "/ws") {7 const pair = new WebSocketPair();8 // Hibernation API:待機中はメモリから退避され、課金も止まる9 this.state.acceptWebSocket(pair[1]);10 return new Response(null, {11 status: 101,12 ...({ webSocket: pair[0] } as ResponseInit),13 });14 }15 // POST /notify:Worker からの「変わった」を接続中の全員へ配る16 const message = await request.text();17 for (const ws of this.state.getWebSockets()) {18 try {19 ws.send(message);20 } catch {21 // 切断途中のソケットは無視(次の getWebSockets からは消える)22 }23 }24 return new Response(null, { status: 204 });25 }2627 // クライアントからのメッセージは受け取らない(一方向通知のため)。28 // ただしハンドラが無いと休眠中の接続が起こされないため、空実装を置く。29 webSocketMessage(): void {}30}
注意点がひとつ。最後の webSocketMessage(): void {} です。今回の設計ではクライアントからのメッセージを一切受け取らないのですが、このハンドラを消すと休眠中の接続がメッセージで起こされなくなり、正しく動きません。「何もしない関数」に存在理由がある、Hibernation API ならではの仕様です。
ベストプラクティス 3:push が全滅しても収束する縮退経路を作る
WebSocket は切れます。スマホがスリープすれば切れますし、電波の谷間でも切れます。だからこそ、push はあくまで「速くするための追加経路」であって、唯一の経路にはしない——これが今回の設計の背骨です。
クライアントは、WebSocket が生きている間も 15 秒間隔の保険ポーリングを残し、接続が確立できないときは 3 秒間隔のポーリングへ自動で切り替わります。切断時は指数バックオフで再接続を試み、どの状態でも差分 API さえ生きていれば画面は最新に収束します。
1const POLL_MS = 3000; // WebSocket が無いとき(縮退時)2const POLL_MS_WITH_WS = 15000; // WebSocket 接続中のセーフティネット34setInterval(() => {5 const interval = wsUp ? POLL_MS_WITH_WS : POLL_MS;6 if (Date.now() - lastFetchAt >= interval) void fetchDiff();7}, 1000);
この縮退設計を後押しした事情がもうひとつあります。このアプリは HonoX + D1 という軽量構成(技術選定の経緯はこちらの記事)なのですが、Vite ベースの開発サーバーでは同一 Worker 内の Durable Object を解決できません。そこでバインディングを「無いかもしれないもの」として扱い、WebSocket ルートは Durable Object が引けなければ 503 を返し、通知の失敗は握りつぶす構成にしました。
結果として、開発中は常にポーリング側の経路が走ることになりました。障害時にしか通らないフォールバックは腐りがちですが、この構成なら縮退経路が毎日テストされているのと同じです。「WebSocket が死んだら実は動かなかった」という事態は起きません。
なお、WebSocket ルートを組み込む際に踏みやすい注意点が 2 つあります。ひとつは、ハンドシェイク応答(ステータス 101)のヘッダが**変更不可(immutable)**であること。共通ミドルウェアがセキュリティヘッダを書き足そうとした瞬間に例外で落ちるため、upgrade リクエストだけ素通しにする分岐が必要です。
1// 101 + webSocket の応答は immutable。ヘッダを足そうとすると落ちるため、2// upgrade リクエストにはセキュリティヘッダを付けない(付けても意味がない)3const secureUnlessUpgrade = createMiddleware(async (c, next) => {4 if (c.req.header("upgrade")?.toLowerCase() === "websocket") return next();5 return secureHeaders()(c, next);6});
もうひとつは Origin の検証です。WebSocket は CORS の保護対象外なので、ブラウザからのクロスサイト接続はサーバー側で自前チェックする必要があります。接続ルートの先頭で Origin とホストの一致を確認し、合わなければ 403 を返すようにしました。
ベストプラクティス 4:双方向にしない — 機能は引き算する
実は初版には、相手がいまどのフィールドを編集しているかをハイライトする presence 機能がありました。Hibernation API のタグ機能で接続に役割(お客様/スタッフ)を載せ、編集位置を双方向に中継する実装です。技術的には問題なく動いていました。
しかし運用を想像し直すと、受付の 1 枚のシートで「相手のカーソル位置」まで見えることの価値は大きくありません。一方で、クライアントからメッセージを受けるとなると、入力の検証・サイズ制限・役割の管理とコードは確実に増え、攻撃面も広がります。最終的に presence は全廃し、「Durable Object は一方向の通知だけ、クライアントからは何も受けない」まで割り切りました。
動いている機能を消す判断には勇気が要りましたが、残ったコードは 80 行足らずの見通しのよい通知ハブになりました。リアルタイム機能は華やかな装飾を足したくなりがちですが、要件を満たす最小の形まで削るほど、堅牢さと保守性は上がります。
検証:スタックが違っても、同じ形になった — Supabase Realtime の場合
実は後日、製造業のお客様の生産管理システムでも同種の要件がありました。工場に据え置いたタブレットで工程表を表示しっぱなしにするため、他の担当者の更新に画面が追随しないと、古い表を見て作業してしまうリスクがあります。
こちらのスタックは Supabase。Realtime には行データの変更をそのまま配信するモード(postgres_changes)もあるのですが、採用したのは Broadcast、つまりここでも「変わった」だけを送る方式でした(ベストプラクティス 1)。データベースのトリガーから軽い通知を発行し、受け取ったクライアントが自分で再取得します。行データを配信しなかった直接の理由は行レベルセキュリティ(RLS)との相性問題——Realtime の配信経路はアプリ本体とは別の接続で権限を評価するため、アプリ側の前提がそのまま通用しない——でしたが、「通知に業務データを載せない」安心感も決め手になりました。
1const channel = supabase.channel(`tenant:${tenantId}:schedule`, {2 config: { private: true },3});4channel5 .on("broadcast", { event: "schedule_changed" }, () => refresh())6 .subscribe();
そして Broadcast には再送がなく、切断中の通知は失われます。ここでも答えは同じで、保険のポーリングを残しました(ベストプラクティス 3。接続中は間隔を 30 秒から 5 分へ延長し、再接続時に 1 回だけ照合)。プッシュ導入で待機時の通信量はおよそ 10 分の 1 になり、それでいて Realtime を意図的に遮断するテストをしても、画面は十数秒でポーリングが追随します。
Durable Objects と Supabase Realtime。仕組みはまったく違いますが、出来上がった形は同じ**「薄い push + 確実な pull + 保険のポーリング」**でした。リアルタイム更新の設計で迷ったら、まずこの型から始めることをおすすめします。
まとめ
Workers に限らず、リアルタイム更新を堅牢に作るための型は次の 4 つに集約されると考えています。
push で値を運ばない。 「変わった」だけを送れば、順序・重複・取りこぼしという WebSocket の難所が設計からまるごと消える
Hibernation API を使う。 「つなぎっぱなし」を課金から外せる Workers ならではの仕組みを活かす
push が全滅しても収束する経路を最初から作る。 開発環境が縮退経路のテストを兼ねるなら、フォールバックは腐らない
双方向にしない。 要件を満たす最小の形まで削るほど、コードも攻撃面も小さくなる
よくある質問
- リアルタイム更新には WebSocket とポーリングのどちらを使うべきですか?
- 二者択一ではなく併用をおすすめします。数秒の遅れが許容できる業務ならポーリングだけでも十分成立しますし、即時性が欲しい場合も WebSocket は「速くするための追加経路」として足し、切断時はポーリングに自動で切り替わる設計にしておくと堅牢です。
- Durable Objects の利用に追加費用はかかりますか?
- Workers の有料プラン(月5ドル〜、2026年9月時点)が前提ですが、プランに利用の込み枠が含まれています。Hibernation API を使えば接続を維持しているだけの時間は課金対象にならないため、本記事のような受付用途では込み枠内に収まり、追加費用なしで運用できています。
- Supabase Realtime と Durable Objects はどう使い分けますか?
- 既存のスタックに合わせるのが基本です。データベースに Supabase を使っているなら Realtime、Cloudflare 上にシステムを構築しているなら Durable Objects が自然な選択です。どちらを選んでも「値は運ばず『変わった』だけを送り、保険のポーリングを残す」という設計の型は共通して使えます。
- 稼働中の業務システムにも後付けできますか?
- 可能です。本記事の設計は通知に値を載せないため、既存の API やデータベース構造をほぼ変えずに、通知経路とクライアント側の再取得処理を追加するだけで済みます。まずポーリングで成立させ、あとから push を足す段階的な導入もできます。
ご相談お待ちしております。
栃木県でアプリ開発・システム開発の会社をお探しの方、Web サイトや業務システムを長く安心して任せられるパートナー企業を探している方へ。 受付業務のデジタル化や、複数人・複数端末で使う業務システムのリアルタイム化など、要件に合わせた堅実な設計からシステムデザインワークスが一貫して対応します。
「うちの業務もタブレットやスマホで効率化できないか」といったご相談からで構いません。お気軽に お問い合わせフォーム よりご連絡ください。