header background image

ポーリング卒業のすすめ:Hono製SSEサンプルで学ぶリアルタイムUX

2025年9月23日☕️☕️☕️ 11 min read

「リアルタイムで数字が動くダッシュボードを作りたいんだけど、WebSocketを立てるほどでもないんだよね」──2025年の現場でもよく聞く声です。そこで久しぶりに触ってみたのがSSE(Server-Sent Events)。あの「EventSource」でお馴染みのストリーミングAPIですが、最近のフロントエンドと合わせると、意外なくらい手軽に「リアルタイムっぽさ」を演出できます。今回の記事では、SSEの基本をざっくりおさらいしつつ、Honoベースの最小構成サンプル、そして導入したときのUXとサーバー負荷の肌感を共有します。

tl;dr

  • SSEはHTTPのままサーバーから一方向にイベントを送り続けられる仕組み。WebSocketよりセットアップが簡単で、ポーリングよりもレスポンスが早い
  • https://github.com/thundermiracle/simple-sseのサンプルはHono製。クラウドフレアWorkerやNode.js、Bunなど好みのランタイムで動かしつつ、ブラウザのEventSourceだけでリアルタイムUIが作れる
  • ポーリングよりUXが滑らかでサーバー負荷も抑えやすいが、コネクション数が多い場合はkeep-aliveの管理とスケール戦略が必要

なぜ今SSEを見直したのか

WebSocketやGraphQL Subscriptionsが当たり前になった一方で、ちょっとした管理画面や通知UIでは「設定が大げさ」「ライブラリを入れるほどでもない」という場面が増えています。SSEは以下の理由で今こそ使い道があると感じています。

  • HTTPに乗るのでインフラ構成がシンプル: NginxやCloudflareなど、既存のリバースプロキシ設定をほぼそのまま使い回せる
  • ブラウザ実装が枯れている: IEをターゲットにしなければEventSourceがそのまま使える(Service Worker経由でも動作)
  • バックエンド言語の選択肢が豊富: Node.js、Go、Python、Rustなど大半の言語でサポートされている。特にHonoのような軽量フレームワークはランタイムを選ばない

5分で分かるSSEの仕組み

  1. クライアントがAccept: text/event-streamを付けてHTTP GET
  2. サーバーはContent-Type: text/event-streamでレスポンスを返し、接続を開いたままにする
  3. 送信したいタイミングでdata: ...\n\n形式のメッセージを流す
  4. クライアントはEventSourcemessageイベントで受信する

長いHTTPレスポンスをストリーミングし続けるだけ、という仕組みなので、TLS終端や認証もふつうのHTTPと同じ考え方で扱えます。

サンプルコードで見る最小構成

thundermiracle/simple-sseリポジトリには、SSEとシンプルなpub/subを組み合わせた構成がそのまま載っています。主要なポイントだけ抜粋してみましょう。

サーバー側(Hono)

// apps/backend-hono/src/stocks.ts — https://github.com/thundermiracle/simple-sse より
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'

const subscribers = new Set<(payload: StockUpdatePayload) => void>()

const stocksApp = new Hono()

stocksApp.get('/stream', (c) => {
  return streamSSE(c, async (stream) => {
    const sendUpdate = async (payload: StockUpdatePayload) => {
      await stream.writeSSE({
        data: JSON.stringify(payload),
        event: 'stock-update',
        id: Date.now().toString(),
      })
    }

    const listener = (payload: StockUpdatePayload) => {
      void sendUpdate(payload).catch((error) => {
        console.error('Failed to push SSE update:', error)
      })
    }

    subscribers.add(listener)

    await sendUpdate({
      timestamp: new Date().toISOString(),
      stocks: STOCKS,
    })

    await new Promise<void>((resolve) => {
      stream.onAbort(() => {
        subscribers.delete(listener)
        resolve()
      })
    })
  })
})

実際のファイルでは、schedulePriceUpdates()が1〜3秒おきに新しい株価を作り、notifySubscribers()subscribersセットに入っているすべてのクライアントへ配信するpub/subスタイルになっています。streamSSEがヘッダーと接続維持を面倒見てくれるので、クリーンアップもonAbortにぶら下げるだけで完了します。

フロントエンド側(React)

// apps/frontend/src/App.tsx — https://github.com/thundermiracle/simple-sse より
const eventSource = new EventSource('http://localhost:4000/api/stocks/stream')

eventSource.addEventListener('stock-update', (event) => {
  const data: StockUpdate = JSON.parse(event.data)
  setStocks(data.stocks)
})

eventSource.onerror = (error) => {
  console.error('SSE Error:', error)
  setConnectionStatus('error')
}

フロントエンドはまずRESTで銘柄リストを取得し、その後はstock-updateイベントを購読するだけで最新状態を受信します。UI側はTailwindベースのダッシュボードで、接続状態を示すインジケーターや接続/切断ボタンを備えています。EventSourceが自動再接続やバックオフを担保してくれるため、pub/subの受け側はシンプルにsetStocksへ流し込むだけで完成です。

Simple SSE

SSEを使う場合・使わない場合の比較

現場で議論になりがちな「それ、SSEでいいの?」問題をざっくり整理すると以下のイメージです。

  • ポーリング(SSEなし)

    • メリット: 実装が簡単、HTTPクライアントがそのまま使える
    • デメリット: 例えば5秒おきに全ユーザーが/statusを叩くと、ピーク時のリクエスト数が膨れ上がる。変更が無い瞬間でもレスポンスが発生
    • UX: 画面更新がカクカク。レスポンスが遅れるとユーザーは「壊れた?」と感じる
  • SSE(今回の構成)

    • メリット: 更新があった瞬間に通知でき、レスポンスが軽くなる(差分だけ送ればOK)
    • デメリット: コネクションが張りっぱなし。HTTPサーバーやProxyのタイムアウト設定を意識する必要がある
    • UX: ほぼリアルタイム。サンプルのようにログがスルスル流れるだけで「最新感」が出る
  • WebSocket

    • メリット: 双方向通信が必要なチャットや編集コラボで強い
    • デメリット: プロトコルが違うのでインフラの設定と監視が一段難しい。ステート管理が複雑
    • UX: インタラクティブ性が高いが、クライアント側のクリーンアップを忘れると逆に不安定

結局「クライアントからサーバーに頻繁な書き込みが必要か?」で判断できます。読み取りのみならSSEで十分なケースが多いです。

サンプルアプリのUX体験レポ

simple-sseリポジトリをローカルで走らせると、コンソールとブラウザの両方で「イベントが1〜3秒ごとに届く様子」がそのまま観測できます。

  1. pnpm install && pnpm dev(またはpnpm dev --filter backend-hono--filter frontendで個別起動)
  2. ブラウザを開くと、株価カードがじわじわ書き換わり続ける
  3. ネットワークタブで/api/stocks/streamを見ると、1つのHTTPレスポンスが延々と伸びているのが確認できる

ポーリング版と見比べると、イベントが届くたびにリストが即座に反映されるため、ユーザーは「常に最新を見ている」という安心感を得られます。小さな違いですが、モニタリング画面や通知センターでは体験の満足度が大きく変わります。

サーバー負荷の現実的な話

SSEは「リクエスト数が減る」代わりに「長時間接続を保持する」方向に負荷の形が変わります。Honoの場合、Node.jsのhttpサーバー、Bun、Workers Runtimeなど、どこで走らせるかによって監視するメトリクスが微妙に変わりますが、共通して意識したいポイントは以下のとおりです。

  • 同時接続上限を把握する: Node.jsならserver.maxConnections、Cloudflare Workersならコンカレンシーの制限など、設定値と監視をセットにする
  • キープアライブのタイムアウト調整: Proxyが早めにコネクションを切るとSSEが頻繁に再接続して逆効果。proxy_read_timeoutを長めに設定
  • バックエンドの処理を軽量化: 送るのはJSON文字列だけにして、重い計算やDBアクセスは別スレッド/キューに逃がす
  • スケールアウト戦略: コンテナやワーカーを増やす場合は、接続をシャーディングするか、Redis Pub/Subなどでイベントを分配する

実運用では、定期ポーリングに比べてレスポンスサイズを80%削減できた事例もあります。差分だけを書き込むことで、トラフィックを制御しつつリアルタイム感を維持できるのがSSEの強みです。

まとめ

SSEは最新のキラキラ技術ではありませんが、「リアルタイムに見せたいけど双方向は不要」というユースケースには今でもベストフィットです。thundermiracle/simple-sseのようなミニマル実装から始めれば、1日もかからずにプロトタイプを作れます。

  • 小規模な通知・ダッシュボードならSSEを第一候補に
  • 双方向が必要になったらWebSocketやWebRTCにステップアップ
  • 本番投入時はプロキシのタイムアウトと同時接続数をまず確認(Honoならデプロイ先の制限もあわせてチェック)

今回のサンプルのように、スナップショットを丸ごと送るバッチ配信と組み合わせれば、リアルタイム性とバッチ処理の両立も簡単です。リアルタイムUIを軽やかに楽しむための選択肢として、SSEを再評価してみてください。

ThunderMiracle

Blog part of ThunderMiracle.com

コメントは表示領域に入ると読み込みます