この記事の概要
Hono、 Deno KVを使ってリアルタイムで状態更新されるアプリを作成しました。
この記事はその手法と体験をまとめた記事になります。
※ このアプリ自体まだ完成していないので、1つのやり方として進めている手法になります。
作成したアプリ背景
あるコミュニティのアイスブレイクとして、とあるwebサービスを利用させていただいていました。
ラウンジというwebサービスにある一致するまで終われまテンです。
使い始めの時は、会員登録も不要で20人行かないくらいであれば無料で全員楽しめていました。
昨今の物価高の影響でしょうか、ゲームの定員5名までなら無料使用可能の制限がついてしまいました。
こればかりはしょうがなく、このまま上記サービスを使うのは運営上難しいので似たようなものを自分で作ってみることにしました。
課題
- リアルタイムで参加
→ 数人 ~ 10人くらいが参加、サクサク動くこと、状態変化が瞬時に反映されることが大事 - インフラ代は0円
→ 個人開発になるためPaaSサービスの無料枠で何とか収めたいです - マルチデバイス対応
→ 参加者はスマホ、PCどこからでもプレイ可能である
技術スタック
フロントエンド
- Next.js
- Vercel
バックエンド
- Hono
- Deno Deploy
- Deno KV
技術選定の根拠ととかは深くありません。
仕事でPHPを使っていることが多いので、他の物を触ってみたいなとか
JSのバックエンドフレームワークでHonoというものがあるらしいからちょっと触ってみたいくらいです。
上記で大枠がきまり、リアルタイム通信を実現するにはどうしようか
SupabaseのRealtimeを使えばできそうな気がするがどうしよう
DenoをつかったHonoであればDeno Deployにそのままデプロイできていいかも。
さらに、さらにDeno Deployで使えるDeno KVにwatch機能があって状態変化監視できるかも。
みたいな感じで技術選定していきました。

※ 各技術スタックの説明などは特にしませんので、知らない記法などは適宜調べて頂けたらと思います。
課題1 リアルタイムで参加
手法
リアルタイム通信を実現するにあたりいくつか手法を調べました。

ポーリング
クライアントから定期的に確認をしにいくのが1番の特徴だと思います。
インフラコストを0円に抑えことを考えると、apiのリクエスト数をおさえたいです
今回のアプリには相性が悪いかと考え不採用にしました。
もちろん短時間+少人数でゲームを楽しむ分には大丈夫だと思いますが。
WebSocket
クライアント、サーバーからも、通信を好きなタイミングで送ることができる方式です。
選択肢の1つでしたが、いくつかの理由で見送りました。
- 切断時の再接続処理を自前で作らないといけなさそうでした、短期間で実践まで持っていきたかったので、開発コストを抑えるべく今回はみおくり
- 調べていくとvercelとWebSocketが相性悪い(サーバーレスなので接続を維持できないなど)
SSE
恥ずかしながらSSE(Server Sent Event)という手法を今回初めて知りました。
イベントが発生したらサーバーから一方向のストリーミング処理を行うものです。
WebSocketでもリアルタイム通信はできますが、SSEはブラウザ標準で自動再接続をサポートしてくれます。
今回開発期間が短く、開発、テストを含め作業コストを抑えたいこともあり、ここをサポートする特徴は大きかったです。
またHonoには、streamSSEヘルパーがありSSEによる開発がしやすいと感じたため採用しました。
実現手法
Next側
ユーザーは、ルーム参加もしくは作成のapiにアクセスし、ルームIDをサーバーからの返却値として受け取ります。
useEffectの中でEventSourceを使い、roomIdに対応するSSEエンドポイントから状態変化を受け取るようにパイプを作ります。
あとは、サーバーから送られたものをsetStateで更新しフロントの更新をするだけです。
この時送られてくるデータは、簡単にいうと全部のデータが入っています。
例えば、参加者の情報、自分の回答内容、他の参加者の回答内容、問題文。
サーバー側で細かく分けて送るよりも、全部一気に送ってNextが差分だけ更新した方が、送信内容の型が固定で処理が簡潔に書けると思ったからです。
export function useWatchRoom(roomId: string) {
const [roomData, setRoomData] = useState<FullRoomState>(null);
useEffect(() => {
if (!roomId) return;
const eventSource = new EventSource(`${process.env.NEXT_PUBLIC_API_BASE_URL}/rooms/${roomId}/watch`);
// サーバーから「update」イベントが飛んできた時の処理
eventSource.addEventListener("update", (event) => {
try {
// 文字列(JSON)で送られてくるので、JavaScriptのオブジェクトに変換
const parsedData = JSON.parse(event.data) as { fullState: FullRoomState };
// サーバー側は { fullState: {...} } の形で送っているので、中身だけ取り出して保存する
setRoomData(parsedData.fullState);
} catch (error) {
console.error("データの解析に失敗しました", error);
}
// 通信エラーが起きた時の処理(EventSourceは自動で再接続を試みてくれます)
eventSource.onerror = (error) => {
console.error("SSEの通信エラーが発生しました", error);
};
// この画面からいなくなった時(アンマウント時)にパイプを引っこ抜く!
return () => {
eventSource.close();
};
});
}, [roomId]);
return roomData;
};
※ useEffectのなかでsetStateはアンチパターンかもしれませんが、すみません普段フロントは触らないのでそこまで考えられてないです。
サーバー側
特定ルームの状態をリアルタイムに配信するためのSSEエンドポイントです。
フロントからEventSource経由でパイプを作る依頼がきますので、
受け取った roomId に対して、まず kv.get(["rooms", roomId]) で存在確認。
存在すれば streamSSE を開始し、kv.watch([["room_updates", roomId]]) を監視します。
回答の追加や、次の問題にいき問題文が変更されるなどの変更イベントが来るたびに、そのルーム配下のデータを再収集して event: "update" でフロントに配信する仕組みです。
watchが1つのデータしか監視できない都合上、roomごとに1つ更新監視用のデータを作成しました(room_updates)。
誰かが回答したなど、roomの状態が変わる時は一緒に更新をかけるようにしています。
これにより、roomに関するなにかしらの状態変化が起こったら、クライアントにroom情報全て送りなおしてページの更新は任せようということです。
const WatchRoomParams = z.object({
roomId: z.string().max(8).openapi({ example: "123e4567"})
})
const WatchRoomRoute = createRoute({
method: "get",
path: "/{roomId}/watch",
description: "Watch a room",
request: {
params: WatchRoomParams,
},
responses: {
200: {
description: "Watch a room success",
},
400: {
description: "Watch a room failed",
}
}
});
roomsApp.openapi(WatchRoomRoute, async (c) => {
const roomId = c.req.param("roomId");
const roomRes = await kv.get(["rooms", roomId]);
const roomData = roomRes.value as RoomState | null;
if (!roomData) {
return c.json({
message: "ルームが存在しません",
}, 404);
}
return streamSSE(c, async (stream) => {
const watcher = kv.watch([["room_updates", roomId ]]);
for await (const _event of watcher) {
// ルーム情報は個別に取る
const roomRes = await kv.get(["rooms", roomId]);
const fullState = {
room: roomRes.value as RoomState,
members: [] as Member[],
rounds: [] as RoundState[],
answers: {} as Record<string, AnswerState>,
}
// ルーム内の全てのエントリーを取得
const roomAllEntries = kv.list({ prefix: ["rooms", roomId] });
for await (const entry of roomAllEntries) {
const key = entry.key;
if(key[2] === "members") {
fullState.members.push(entry.value as Member);
}
if(key[2] === "rounds") {
fullState.rounds.push(entry.value as RoundState);
}
if(key[2] === "answers") {
fullState.answers[key[3]] = entry.value as AnswerState;
}
}
// roomIdに対応するユーザー全てに配信がされる
await stream.writeSSE({
event: "update",
data: JSON.stringify({ fullState })
})
}
})
})
リアルタイムで参加してもらった
実際に作ったものはこちらです。
別ブラウザでゲームに参加しているだけですが、回答状況がリアルタイムで反映されているのがわかると思います。
コミュニティの方々にも試してもらいましたが、特に参加できないとか、途切れるとか、反応が遅いとかはなかったので良かったです。
課題3に関してもおそらく大丈夫かと。

課題2 インフラ代は0円
正確に確認できたのはサーバー側だけですが、特に無料プランを使い潰したなどはありませんでした。
ゲームを30分ほどプレイした時のインフラコスト0円に抑えて課題2も達成です。
vercel
CPU使用時間 ✅
リクエスト数 ✅
responseデータ ✅
Deno Deploy
※下段の数字はフリープランの使用量
CPU使用時間 0.37% ✅
リクエスト数 0.15% ✅
responseデータ 0.0012% ✅
まとめ
① 目的達成&要件クリア!
リアルタイム、承認数で遊べるアプリが完成!
Vercel × Deno Deployの組み合わせで、「インフラ代0円」を余裕で達成
② リアルタイム処理の新たな学び、実装がとにかく楽
WebSocketのような面倒なコネクション管理や再接続処理が一切不要
SSE × Deno KVの watch 数行のコードの開発体験が良かったです。