# Design Judgment for a Real-Time UI: Choosing WebSocket / SSE / Optimistic Update + Invalidation Correctly from the Requirements

> A decision guide for choosing the implementation method of a real-time UI (WebSocket/SSE/polling/optimistic update + cache invalidation) from the requirements. The reason 'real-time ≠ WebSocket mandatory,' idempotent concurrent editing, SSE's wire format and implementation, and near-real-time design — explained with real code interweaving the MDN official spec and real-project judgments.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: リアルタイム, アーキテクチャ設計, Next.js, TypeScript, パフォーマンス
- URL: https://tomodahinata.com/en/blog/websocket-sse-realtime-architecture-decision-guide
- Category: Reliability, async & real-time
- Pillar guide: https://tomodahinata.com/en/blog/transactional-outbox-pattern-reliable-event-publishing-guide

## Key points

- 'Real-time = WebSocket mandatory' is an assumption; the body of design is splitting requirements into 6 axes and choosing from 4 options
- WebSocket is the king of bidirectional, low-latency, but reconnection, heartbeats, ordering, duplicates, and broadcast scale are all yours to build
- For one-way server→client push, SSE is optimal, with automatic reconnection and Last-Event-ID resume built in as standard
- If high-frequency writes × offline × ordering matter, near-real-time with an idempotency key + optimistic update + invalidation is the least breakable
- Absorbing ordering not in the connection layer but in the shape of the data (idempotency key + serial drain, or monotonic increase) is production quality

---

"I want to make it real-time" — as a requirement it's one phrase. But the moment you try to land it in an implementation, the options blow open at once. **Do you connect with WebSocket? Stream from the server with SSE? Fetch with polling? Or is "near-real-time" — make writes idempotent, invalidate the cache, and re-fetch at short intervals — enough?**

And here, many designs misstep on the first step. The assumption that **"real-time = WebSocket mandatory."** Certainly WebSocket is the king of bidirectional, low-latency, but in exchange it takes on **the delivery cost of broadcast, fragility on failure, and the difficulty of ordering and retries.** Many actual requirements can be realized more cheaply and less breakably with SSE (one-way server→client push), or idempotent writes + invalidation + short-interval re-fetch.

This article is **a decision-making framework that derives "which method to choose" from the requirements.** I line up the 4 options in a comparison table, show code faithful to each official spec, and close with a "when in doubt" quick reference. As a subject, I interweave the design judgment of **deliberately not adopting WebSocket** in the [multi-user concurrent-scoring real-time match-recording app](/case-studies/realtime-sports-scoring-app) I built for amateur baseball.

> **The rule of this article**: the APIs / wire format of WebSocket / SSE / EventSource are based on **MDN (the web-standards documentation, as of June 2026).** Specs can be updated, so always confirm the latest values in the official docs before shipping to production. The code is shaped to be usable in real operation, but auth tokens and secrets presuppose environment variables (hardcoding strictly forbidden). For real-project design judgments, I state so explicitly.

---

## 0. First: The Decision Table (This Is the Spine of This Article)

Before entering the detailed implementation, **first split the requirements into 6 axes and apply the 4 options.** This is the most important work.

| Decision axis | WebSocket | SSE (EventSource) | Polling / long polling | Optimistic update + invalidation (near-real-time) |
| --- | --- | --- | --- | --- |
| Communication direction | **Bidirectional** (full-duplex) | One-way (server→client) | One-way (client-driven fetch) | One-way (fetch) + local lead-reflect |
| Assumed update frequency | High (ms–seconds) | Medium–high push | Low–medium | High-frequency writes × near-real-time reflect |
| Ordering guarantee | Ordered per connection. Across multiple connections/reconnections, **yours** | Events emitted in order, resumable by `id` | Per request. **Ordering is yours** | **Designed order-independent with idempotency keys** (convergence guaranteed) |
| Failure resistance | Disconnect detection, reconnection, dup/loss **all yours** | **Auto-reconnect + resume by Last-Event-ID** (standard) | Recovers on the next fetch even if missed (robust) | Queue + backoff + idempotency = **least breakable** |
| Scale/cost | Maintain persistent connections for all instances. Broadcast is high-cost | Connections maintained but **delivery logic is simple**. Beware proxy compatibility | Server is plain HTTP. **Cost rises with frequency × count** | Rides on the existing CRUD API. **Almost no added infra** |
| Implementation complexity | High (per-protocol, connection management, state) | Medium (straightforward if you ride the standard spec) | Low | Medium (idempotency design needed, but operation is easy) |

To summarize this table in one line:

> **If true bidirectional, low-latency is needed, WebSocket. For one-way push from the server, SSE. For low frequency, plain polling. If high-frequency writes × offline × ordering matter, idempotency key + near-real-time.**

The substance the phrase "I want to make it real-time" points at is usually one of the right 3 columns. WebSocket is the option to consider last, not the one to jump at first.

---

## 1. WebSocket: The King of Bidirectional, Low-Latency. But Operation Is All Yours

### 1.1 WebSocket Is "a Separate Protocol, Not HTTP"

First pin down the nature precisely. MDN's definition is this.

> The WebSocket API makes it possible to open a two-way interactive communication session between the user's browser and a server. With this API, you can send messages to a server and receive responses without having to poll the server for a reply.

There are 2 points. **(1) Bidirectional (two-way / full-duplex)**, and **(2) no polling needed.** Client and server can send messages to each other as equals. The scheme is `ws://` (plaintext) / `wss://` (TLS). After starting the handshake over HTTP, it exchanges `Sec-WebSocket-Key` / `Sec-WebSocket-Accept` and **upgrades to a dedicated protocol.** That is, it runs on a layer separate from the request/response world.

### 1.2 The Minimal Example and Lifecycle

The `WebSocket` lifecycle MDN defines is expressed by **4 events.**

- `open` — when the connection opens
- `message` — when data is received (`MessageEvent`)
- `error` — when the connection closes on an error
- `close` — when the connection closes (`CloseEvent`)

The connection state is `readyState`, taking the 4 states `CONNECTING(0)` / `OPEN(1)` / `CLOSING(2)` / `CLOSED(3)`. Sending is `send()`, disconnecting is `close(code, reason)`.

```ts
// WebSocket の最小例（ブラウザ）。これ自体は素直だが、本番はここからが本番。
const socket = new WebSocket("wss://example.com/ws");

socket.addEventListener("open", () => {
  // OPEN(1) になってからしか送れない。CONNECTING(0) 中の send は失敗する。
  socket.send(JSON.stringify({ type: "subscribe", room: "game:42" }));
});

socket.addEventListener("message", (event: MessageEvent) => {
  const msg = JSON.parse(event.data);
  applyServerMessage(msg);
});

socket.addEventListener("error", () => {
  // error の詳細は意図的に乏しい（セキュリティ上）。close とセットで扱う。
});

socket.addEventListener("close", (event: CloseEvent) => {
  // 正常終了(event.wasClean)か異常切断かで再接続方針を変える。
  scheduleReconnect(event.code);
});
```

This far is textbook. **The problem is that "the part not in the textbook" is 90% of operation.**

### 1.3 What Needs Custom Implementation in Production (= WebSocket's True Cost)

Choosing WebSocket means deciding to **write all of the following yourself.**

```ts
// 本番WebSocketに最低限必要な「自前装備」のスケッチ。
// 標準APIは接続を開くだけで、下記は何も面倒を見てくれない。
class ResilientSocket {
  private socket: WebSocket | null = null;
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  private reconnectAttempt = 0;
  private lastSeq = 0; // 受信済みの最終シーケンス（順序・重複対策）

  connect(url: string) {
    const socket = new WebSocket(url);
    this.socket = socket;

    socket.addEventListener("open", () => {
      this.reconnectAttempt = 0;
      this.startHeartbeat();
      // 再接続時は「どこまで受け取ったか」をサーバーに伝えて差分を要求する。
      socket.send(JSON.stringify({ type: "resume", afterSeq: this.lastSeq }));
    });

    socket.addEventListener("message", (e) => {
      const msg = JSON.parse(e.data);
      if (msg.type === "pong") return; // ハートビート応答
      if (msg.seq <= this.lastSeq) return; // 重複は破棄（at-least-once対策）
      this.lastSeq = msg.seq; // 連番でギャップ検出も可能
      applyServerMessage(msg);
    });

    socket.addEventListener("close", () => {
      this.stopHeartbeat();
      this.scheduleReconnect(url); // 指数バックオフで再接続
    });
  }

  private startHeartbeat() {
    // 中間プロキシは無通信のWS接続を黙って切る。明示的なping/pongで生存を保つ。
    this.heartbeatTimer = setInterval(() => {
      if (this.socket?.readyState === WebSocket.OPEN) {
        this.socket.send(JSON.stringify({ type: "ping" }));
      }
    }, 25_000);
  }

  private stopHeartbeat() {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
  }

  private scheduleReconnect(url: string) {
    const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 30_000);
    this.reconnectAttempt += 1;
    setTimeout(() => this.connect(url), delay + Math.random() * 1000); // ジッタ
  }
}
```

Organized, here is all the debt you take on yourself to run WebSocket in production.

1. **Reconnection**: the standard API has no auto-reconnect. If it's cut, re-establish it yourself (exponential backoff + jitter).
2. **Heartbeat**: the intermediate load balancer / proxy cuts idle WS connections. Keep it alive with `ping`/`pong`.
3. **Ordering and duplicates**: across reconnections, "how far did it arrive" becomes ambiguous. Sequence numbers and idempotent processing are needed.
4. **Broadcast scale**: hold "deliver to everyone in the same room" on the server. As instance count grows, you need a mechanism to share, across instances, who holds which connection (Redis Pub/Sub, etc.), and here is **the main driver of delivery cost.**
5. **Authentication**: the browser's `WebSocket` constructor can't attach custom headers. Design to pass the token in the first message after connection, or put a short-lived ticket in the query (→ Chapter 5).

> **The correct scene to choose WebSocket**: chat, online games, collaborative cursors (dragging the same shape simultaneously) — cases where **the client also sends at high frequency, and latency governs UX.** Conversely, if you "just want to notify the UI when the server's state changes," bidirectional is overkill.

---

## 2. SSE (Server-Sent Events): One-Way Server→Client Push, as Standard

### 2.1 SSE Has "Reconnection and Resume Built In"

MDN defines SSE's essence thus. `EventSource` is —

> **Unidirectional**: Data flows one direction only—from server to client. Unlike WebSockets, `EventSource` cannot send data from client to server in message form.

One-way. But in exchange, the **auto-reconnection** and **resume by Last-Event-ID** you'd custom-implement with WebSocket are **in the spec from the start.** This is big. For uses where "the server notifies" — progress notifications, notification streams, live updates — SSE is overwhelmingly easier.

### 2.2 SSE's Wire Format (MDN's Exact Definition)

SSE is a plain-text stream of `Content-Type: text/event-stream`. **Line up field lines, and delimit messages with a blank line (two newlines).** MDN defines 4 fields.

| Field | Meaning |
| --- | --- |
| `data:` | The message body. Consecutive `data:` lines are **joined with a newline in between**, and the trailing newline is removed |
| `event:` | The event name. Omit it and it's received as a `message` event |
| `id:` | Sets the `EventSource`'s last event ID. Used for resume on reconnection |
| `retry:` | Specifies the wait until reconnection, in **milliseconds** (integers only; non-integers ignored) |

A line starting with `:` is a comment and is ignored (usable as a connection-keeping keepalive). MDN's sample stream is this.

```text
: this is a comment

event: userconnect
data: {"username": "bobby", "time": "02:33:48"}

data: Here's a system message
data: with two lines

event: usermessage
data: {"username": "bobby", "text": "Hi everyone."}

retry: 10000
```

Multiple `data:` lines are newline-joined like `"another message\nwith two lines"`. **As long as you uphold this format, the browser's `EventSource` does all the parsing, reconnection, and resume** — here is SSE's sweetness.

### 2.3 The SSE Server: `ReadableStream` in a Next.js Route Handler

With Next.js's App Router, just returning a `ReadableStream` from a Route Handler makes it SSE. **Correctly assembling the wire format** is the only job.

```ts
// app/api/jobs/[id]/stream/route.ts
// 長時間ジョブの進捗を SSE で準リアルタイムに流す。
import type { NextRequest } from "next/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic"; // ストリーミングはキャッシュさせない

// SSE 1メッセージを組み立てる小さな純関数（SRP：整形だけを担当）。
function sseEvent(opts: {
  data: unknown;
  event?: string;
  id?: string;
  retry?: number;
}): string {
  const lines: string[] = [];
  if (opts.retry !== undefined) lines.push(`retry: ${opts.retry}`);
  if (opts.id !== undefined) lines.push(`id: ${opts.id}`);
  if (opts.event !== undefined) lines.push(`event: ${opts.event}`);
  // 改行を含む本文は data: 行を分割する（MDNの連結規則に合わせる）。
  const payload = JSON.stringify(opts.data);
  for (const line of payload.split("\n")) lines.push(`data: ${line}`);
  return lines.join("\n") + "\n\n"; // 空行（\n\n）でメッセージを区切る
}

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  // 切断後の再開：ブラウザは再接続時に Last-Event-ID ヘッダを送ってくる。
  const lastId = req.headers.get("Last-Event-ID");
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const send = (s: string) => controller.enqueue(encoder.encode(s));

      // クライアントに再接続間隔を指示（瞬断時の張り直しを穏やかに）。
      send(sseEvent({ event: "ready", data: { id }, retry: 5_000 }));

      // 既知の進捗を resume：lastId 以降だけを送る設計にする。
      for await (const p of watchJobProgress(id, lastId)) {
        // id を必ず振る → 切断しても EventSource が続きから再開できる。
        send(sseEvent({ event: "progress", id: String(p.seq), data: p }));
        if (p.percent >= 100) break;
      }

      send(sseEvent({ event: "done", data: { id } }));
      controller.close();
    },
    cancel() {
      // クライアント切断時のクリーンアップ（購読解除など）はここで。
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform", // プロキシのバッファ抑止
      Connection: "keep-alive",
      "X-Accel-Buffering": "no", // nginx等の中間バッファを無効化
    },
  });
}
```

What matters here is `X-Accel-Buffering: no` and `Cache-Control: no-transform`. **If a proxy buffers the SSE response, it "stops being real-time,"** so suppressing the intermediate-layer buffer is an operational pitfall. There's infrastructure that won't pass the WebSocket upgrade, but SSE is plain HTTP so it passes through most environments (this can also be a reason to adopt it).

### 2.4 The SSE Client: `EventSource` (Auto-Reconnect, Last-Event-ID)

The client just news up `EventSource`. Per MDN's definition, `readyState` is `CONNECTING(0)` / `OPEN(1)` / `CLOSED(2)`, the events are `open` / `message` / `error`, and named events are received with `addEventListener`.

```ts
// 進捗SSEを購読する。自動再接続と Last-Event-ID 再開は EventSource が面倒を見る。
export function subscribeJobProgress(
  jobId: string,
  onProgress: (p: { percent: number; seq: number }) => void,
): () => void {
  const es = new EventSource(`/api/jobs/${jobId}/stream`, {
    withCredentials: true, // Cookie認証を流す（CORSクレデンシャル）
  });

  // 名前付きイベント（サーバーの event: progress に対応）。
  es.addEventListener("progress", (e) => {
    onProgress(JSON.parse((e as MessageEvent).data));
  });

  es.addEventListener("done", () => es.close()); // 完了で明示クローズ＝再接続を止める

  // error は「切断」も含む。EventSource は自動で再接続を試みるので、
  // ここで毎回 close() しないこと（再接続を殺してしまう）。
  es.addEventListener("error", () => {
    if (es.readyState === EventSource.CLOSED) {
      // 本当に終了した場合だけ後始末。CONNECTING(0) なら自動再接続中。
    }
  });

  return () => es.close(); // 呼び出し側のクリーンアップ
}
```

> **SSE's anti-pattern**: calling `es.close()` every time in the `error` handler. Per the MDN spec, `error` fires even on a temporary disconnect, and **`EventSource`, left alone, auto-reconnects.** `close()` here means killing the standard recovery mechanism yourself. The correct way is to clean up only when `readyState === CLOSED`.

### 2.5 A Real Project: Progress Delivery on a Broadcaster AI Platform

In an in-house AI platform for a major domestic broadcaster, I delivered **the progress of long-running AI jobs** near-real-time to the UI by this SSE method. The backend picks up changes in job state via Firestore snapshot subscription and streams them to the browser as SSE events.

There was one quiet but effective device here. **The guarantee of monotonic increase that prevents progress regression.** In distributed processing, a late-arriving event can roll the progress bar back "80% → 60%." As UX this is the worst, so I placed the rule "ignore progress smaller than the currently-displayed value (take the max)" in `resolve_monotonic_progress_percent`, guaranteeing that **progress moves only by monotonic increase.** Even on a channel where order isn't fully guaranteed, the idea is to **absorb the ordering problem with the meaning of the value (monotonic increase).** This is another expression of the same philosophy as the next chapter's "make it order-independent with idempotency keys."

---

## 3. Polling / Long Polling: For Low Frequency, This Is the Most Robust

Surprisingly, **if the update frequency is low, plain polling is often the most robust and most cost-efficient.** Because you don't maintain a persistent connection, the server can be plain stateless HTTP, and even if it misses something, it naturally recovers on the next fetch.

```ts
// 低頻度更新の準リアルタイム取得。指数的でない素朴な間隔で十分なことが多い。
export function pollResource<T>(
  url: string,
  onData: (data: T) => void,
  intervalMs = 5_000,
): () => void {
  let stopped = false;
  let timer: ReturnType<typeof setTimeout>;

  const tick = async () => {
    if (stopped) return;
    try {
      const res = await fetch(url, { cache: "no-store" });
      if (res.ok) onData(await res.json());
    } finally {
      // タブが非表示なら間隔を伸ばす（無駄な取得とコストを削る）。
      const delay = document.hidden ? intervalMs * 4 : intervalMs;
      timer = setTimeout(tick, delay);
    }
  };

  tick();
  return () => {
    stopped = true;
    clearTimeout(timer);
  };
}
```

The criteria are these.

- **An update once every several seconds to several minutes suffices** (dashboards, status displays, inventory counts) → **plain polling.** Thinning out with `document.hidden` also lowers cost.
- **"Immediately on change" yet low frequency**, and you want to conserve connections → **long polling** (the server holds the response until a change). But now that SSE exists, for a greenfield SSE is often the better move.
- **If you use TanStack Query, `refetchInterval`** does it. Before adding a dedicated real-time mechanism, first doubt whether this suffices (YAGNI).

If the substance of "the real-time feel" is "periodic updates that tolerate a few seconds of delay," neither WebSocket nor SSE is needed. **Draw the cheapest line that satisfies the requirement first** — this is the designer's job.

---

## 4. Optimistic Update + Idempotency Key + Invalidation: The Optimal Solution for High-Frequency Writes × Offline × Ordering

From here is the talk I most want to convey in this article. The design I adopted in a real project **instead of WebSocket broadcast, which I deliberately did not adopt.**

### 4.1 The Requirement: It Was a Case WebSocket Is "Unsuited For"

The [real-time match-recording app](/case-studies/realtime-sports-scoring-app) for amateur baseball (an Expo + Next.js + Supabase monorepo) has **multiple people scoring the same game simultaneously.** Decompose the requirements:

- **High-frequency writes**: pitches and at-bats increase by the second.
- **Ballparks with bad reception**: offline happens frequently. Even when a device is out of range, recording can't stop.
- **Ordering matters**: the progression of innings and the pitch order within an at-bat must not collapse.

Apply WebSocket broadcast to this requirement and it becomes this. The **delivery cost** of distributing high-frequency input to everyone, the **fragility on failure** that breaks when cut out of range, the **ordering problem** that collapses across reconnections — you'd deliberately take on the triple suffering WebSocket is worst at. So I **didn't adopt it.**

### 4.2 The Design I Adopted: A Deterministic Idempotency Key

The core of the alternative is a **deterministically-decided idempotency key.** Design it so "the same operation, no matter who sends it or how many times, becomes the same key."

- At-bat: `game:{id}:inn:{回}:{表裏}:seq:{n}`
- Pitch: `ab:{打席id}:pitch:{n}`

Make this key effective on the DB's unique constraint `(recording_team_id, idempotency_key)`, and absorb duplicates with `upsert(ignoreDuplicates)`. It becomes **order-independent and retry-safe**, converging no matter how many times a resend happens.

```ts
// 投球の記録。冪等キー → upsert(ignoreDuplicates) で重複を吸収し、収束させる。
type Pitch = {
  atBatId: string;
  pitchNo: number;
  result: "ball" | "strike" | "in_play";
  recordingTeamId: string;
};

// 同じ投球は、何度送っても必ず同じキーになる（決定的）。
function pitchIdempotencyKey(p: Pitch): string {
  return `ab:${p.atBatId}:pitch:${p.pitchNo}`;
}

async function recordPitch(supabase: SupabaseClient, p: Pitch): Promise<void> {
  const idempotencyKey = pitchIdempotencyKey(p);
  // (recording_team_id, idempotency_key) の一意制約に乗せ、
  // 重複は「無視」する。再送・同時送信・再起動をまたいでも衝突しない。
  const { error } = await supabase
    .from("pitches")
    .upsert(
      { ...p, idempotency_key: idempotencyKey, recording_team_id: p.recordingTeamId },
      { onConflict: "recording_team_id,idempotency_key", ignoreDuplicates: true },
    );
  if (error) throw error;
}
```

Only when, very rarely, the conflict of "both teams' scorers entered the same event with different values" occurs do I resolve "my team's row" with a **permission-scoped RPC.** That is, **absorb 99% silently with a unique constraint, and explicitly resolve only the genuine conflicts** — a design that confines complexity to the place it's needed (KISS / SRP).

### 4.3 Offline-First: Optimistic Update + Durable Queue + Drain Worker

Build the device side on an offline premise. **First reflect locally optimistically, and load the send onto a durable queue. A single drain worker sends safely with exponential backoff.**

```ts
// オフラインファーストな送信。UIは即反映、送信はキュー経由でバックオフ送出。
type QueuedWrite = { key: string; payload: Pitch; attempts: number };

class WriteQueue {
  private queue: QueuedWrite[] = [];
  private draining = false;

  // 1) 楽観的にUIへ即反映し、2) 永続キューに積むだけ。圏外でも止まらない。
  enqueue(p: Pitch) {
    applyOptimistic(p); // ローカルストアに先行反映（UIは待たせない）
    this.queue.push({ key: pitchIdempotencyKey(p), payload: p, attempts: 0 });
    void this.drain();
  }

  // 単一のドレインワーカー。並行送信で順序を乱さないため、必ず1本に絞る。
  private async drain() {
    if (this.draining) return;
    this.draining = true;
    try {
      while (this.queue.length > 0) {
        const item = this.queue[0];
        try {
          await recordPitch(supabase, item.payload); // 冪等なので再送安全
          this.queue.shift(); // 成功したものだけ外す
        } catch {
          item.attempts += 1;
          const backoff = Math.min(1000 * 2 ** item.attempts, 30_000);
          await sleep(backoff + Math.random() * 500); // ジッタ付き指数バックオフ
        }
      }
    } finally {
      this.draining = false;
    }
  }
}
```

**Narrowing the drain worker to 1** is the key. Send in parallel and the order is disturbed, but serial keeps the pitch order. And since sends are idempotent, **across restart, an off-by-one in the operation position, or duplicate sends, they don't collide on the key and converge.** "Ordering guarantee" is realized not in the connection layer but in **the shape of the data (idempotency key + serial drain).**

### 4.4 Reflecting Between Clients: Mutation-Driven Invalidation + Short-Interval Re-Fetch

"How to reflect another device's record on my screen" is, by here, simply **near-real-time** enough. When my own write (mutation) succeeds, invalidate the cache, and additionally re-fetch at short intervals. Apply **optimistic locking** with the `game_states.version` column, rejecting overwrites of an old version.

```ts
// TanStack Query 例：ミューテーション成功で無効化＋短間隔の再取得（準リアルタイム）。
const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: (p: Pitch) => recordPitch(supabase, p),
  // 楽観更新：成功を待たずUIへ反映し、失敗時はロールバック。
  onMutate: async (p) => {
    await queryClient.cancelQueries({ queryKey: ["game", p.atBatId] });
    const prev = queryClient.getQueryData(["game", p.atBatId]);
    queryClient.setQueryData(["game", p.atBatId], (old) => applyPitch(old, p));
    return { prev }; // ロールバック用スナップショット
  },
  onError: (_e, p, ctx) => {
    queryClient.setQueryData(["game", p.atBatId], ctx?.prev); // 失敗→巻き戻し
  },
  onSettled: (_d, _e, p) => {
    // 自分の書き込み後に無効化 → 正の値で取り直す（自分の楽観値を確定/訂正）。
    queryClient.invalidateQueries({ queryKey: ["game", p.atBatId] });
  },
});

// 他端末の更新を映す準リアルタイム：短間隔ポーリング。version で楽観ロック。
useQuery({
  queryKey: ["game", gameId],
  queryFn: () => fetchGameState(gameId),
  refetchInterval: 3_000, // 3秒。電波が悪い前提なので欲張らない
});
```

With this, **without stretching a single WebSocket broadcast**, I realized "concurrent multi-user editing that is offline-resistant, doesn't collapse in order, and reflects another device's update within seconds." Without delivery infrastructure, there are fewer points of failure, and operation is overwhelmingly easier.

> **The generalization of this chapter**: for the synchronization problem of high-frequency writes, "**write idempotently + re-fetch (pull)**" is more robust on the three points of offline, ordering, and failure than "deliver to everyone immediately (push)." WebSocket is good at "delivering" but "not breaking" is yours. Idempotency design builds "non-breakability" in from the start.

---

## 5. Production Design: Authentication, Ordering, Reconnection, Scale, Observability

Let me summarize the cross-cutting points you'll surely hit after choosing a method.

### 5.1 Authentication: How to Pass the Token in SSE / WebSocket

The browser's standard APIs share a constraint: **they can't attach custom HTTP headers.** Neither `new WebSocket(url)` nor `new EventSource(url)` can directly attach `Authorization: Bearer ...`. The handling differs by method.

- **SSE**: the royal road is to flow a **Cookie-based session** with `withCredentials: true`. Verify the Cookie on the server and the header problem doesn't arise (most straightforward if same-site).
- **WebSocket**: the Cookie is sent, but a **short-lived ticket scheme** is safe. First issue a "connection ticket valid for only tens of seconds" via normal HTTP, and pass it via `wss://.../ws?ticket=...`. Don't put a long-lived token in the URL (it remains in logs/history).
- The common principle: **always verify the token on the server**, and confirm the **authorization scope** to the subscription target (room, job, team) on every connection. The same philosophy as attaching a "permission scope" to the conflict-resolution RPC in the previous chapter's match app.

### 5.2 Ordering Guarantee: Don't Rely on the Channel, Guarantee It with Data

Ordering becomes **fragile if you try to guarantee it in the connection layer.** Because it easily collapses with reconnection, multiple connections, and distributed processing. Two patterns that worked in real projects:

1. **Idempotency key + serial processing** (the match app): design order-independent, and converge with the key.
2. **Absorb with monotonic increase** (the broadcaster): reject regression with "the meaning of the value," like `resolve_monotonic_progress_percent`.

Both share the point of **"absorbing the ordering problem in the shape of the data."** SSE's `id:` / Last-Event-ID help with resume, but don't depend on that alone; a design that doesn't collapse on the data side too is production quality.

### 5.3 Reconnection, Scale, Cost

- **Reconnection**: SSE is standard (interval instructed by `retry:`). WebSocket is yours (exponential backoff + jitter + heartbeat). This alone makes **SSE's operational load a fraction of WebSocket's.**
- **Scale**: WebSocket broadcast needs a mechanism (Pub/Sub) to share "who holds which connection" across multiple instances, and here is the main driver of cost and points of failure. SSE's delivery logic is simple, and the near-real-time method rides on existing CRUD, so **almost no added infrastructure is needed.**
- **Cost**: is the maintenance fee of persistent connections × count truly worth paying? If "a few seconds of delay is tolerable," not paying that fee is the correct answer.

### 5.4 Observability: What to Always Record

Real-time systems make "working / not" hard to see. **Leaving metadata in structured logs** is mandatory.

- Connection events: connection count, disconnect rate, average connection time, reconnection count.
- Messages: send/receive rate, drop count, sequence-gap detection count.
- Near-real-time: queue backlog count, drain failure rate, backoff occurrence count.
- **Don't leave PII in body logs** (a game's player names, a notification's body, etc.). Record up to IDs and metadata.

---

## 6. a11y / UX: The Manners of Optimistic Update and Live Regions

A real-time UI's crux is **the coexistence of speed and honesty.**

- **How to show an optimistic update**: since you reflect without waiting for success, visually indicate the "not yet confirmed" state (a faint color, a spinner, a "sending" badge). On failure, don't **silently roll back**; make the rollback explicit and surface a retry path. If a user feels "the record I entered vanished," you lose trust.
- **Live regions are `aria-live`**: attach `aria-live="polite"` (a non-interrupting update) to regions where scores or progress change dynamically, having the screen reader read out the change. For high-urgency notifications, `aria-live="assertive"`. The more real-time the UI, the more consideration is needed for users who can't rely on vision.
- **The honesty of near-real-time**: at a 3-second interval, design the UI on the premise of "up to 3 seconds of delay." Claiming "real-time" and being a few seconds late invites distrust, so don't over-promise.

---

## 7. Summary: A Selection Cheat Sheet

A quick reference for when you're lost. The knack is to **doubt, from the top, whether "a lighter option suffices."**

- **A periodic update every several seconds to minutes suffices** → **polling** (`refetchInterval` / thin out with `document.hidden`). Doubt this before adding a dedicated mechanism.
- **One-way server→client push** (progress, notifications, live updates) → **SSE.** Auto-reconnect + Last-Event-ID built in. `text/event-stream`, `data:`/`event:`/`id:`/`retry:`, blank-line-delimited. Don't forget to suppress the proxy buffer.
- **High-frequency writes × offline × ordering matters** → **idempotency key + optimistic update + invalidation (near-real-time).** Guarantee ordering not in the connection but in the shape of the data. Without delivery infrastructure, it's the least breakable.
- **True bidirectional, low-latency is mandatory** (chat, games, collaborative cursors) → **WebSocket.** But be prepared that reconnection, heartbeats, ordering, duplicates, and broadcast scale are all yours.
- **Common equipment**: tokens server-verified + authorization-scoped, ordering absorbed by data (idempotency key or monotonic increase), observability metadata-only (don't emit PII), optimistic update with rollback-on-failure and `aria-live`.

Behind the one phrase "I want to make it real-time" hide **the trade-offs of bidirectionality, update frequency, ordering, failure resistance, and cost.** Unraveling them from the requirements and **drawing the cheapest, least-breakable line** is the body of design.

I built a match-recording app where multiple people score simultaneously in ballparks with bad reception, with offline resistance **using idempotent near-real-time synchronization rather than relying on WebSocket broadcast** (with one person × generative AI, through from design to implementation and verification). On the same broadcaster project, I deliver the progress of long-running AI jobs to the UI via SSE + a monotonic-increase guarantee.

**"This feature of ours — does it really need WebSocket?" — I can organize from that question with you.** Feel free to consult me even from the requirements-inventory stage.

---

### References (Official Documentation)

- [WebSocket API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) — bidirectional communication, handshake, event model
- [WebSocket interface (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) — `open`/`message`/`error`/`close`, `send()`, `close()`, `readyState`
- [Using server-sent events (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) — SSE wire format (`data:`/`event:`/`id:`/`retry:`), `text/event-stream`, auto-reconnection
- [EventSource (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) — constructor, `readyState`, `withCredentials`, named events, `close()`
