# Next.js Realtime

You are adding realtime features (live feeds, notifications, presence, chat, collaborative editing) to a Next.js 16 App Router project with React 19 and TypeScript. Write code for the versions in `package.json` and for the host the app deploys to: realtime code that works on localhost often breaks behind a proxy or on serverless functions.

## Before you write code

- Find where the app runs (a serverless host, a container, a long-running Node server) and what it already uses for realtime (a managed service's SDK, Redis, Supabase, a WebSocket server). Extend that instead of adding a second channel.
- Read `node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md` and `02-guides/streaming.md` (streaming Route Handlers, proxies, buffering).
- Check `next.config.ts` for `cacheComponents`: with it, a `GET` Route Handler that reads no request data is prerendered at build time.
- Pick the transport per feature. Server to client only (notifications, feeds, progress, dashboards): Server-Sent Events from a Route Handler, or `router.refresh()` on an interval when seconds of delay are fine. Both directions at low latency (typing, cursors, multiplayer, shared editing): WebSockets from a managed service or a separate server.

## Server-Sent Events from a Route Handler

```ts
// app/api/events/route.ts
export async function GET(request: Request) {
  const session = await getSession(); // reads cookies, so it runs per request
  if (!session) return new Response("Unauthorized", { status: 401 });

  const encoder = new TextEncoder();
  let stop = () => {};
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      const send = (text: string) => {
        try {
          controller.enqueue(encoder.encode(text));
        } catch {
          stop();
        }
      };
      const unsubscribe = subscribe(session.user.id, (event) =>
        send(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`),
      );
      const heartbeat = setInterval(() => send(": ping\n\n"), 15_000);
      stop = () => {
        clearInterval(heartbeat);
        unsubscribe();
      };
      send("retry: 3000\n\n");
      request.signal.addEventListener("abort", () => stop(), { once: true });
    },
    cancel() {
      stop();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  });
}
```

`subscribe` is the project's pub/sub (see Scaling) and returns an unsubscribe function.

- An event is `event:`, `data:` and `id:` lines ending in a blank line; `JSON.stringify` keeps `data` on one line. Lines starting with `:` are comments: send one every 15 to 30 seconds so proxies and load balancers keep the connection open.
- Clean up on `request.signal` abort and in `cancel()`: clear timers, unsubscribe, release connections. Writing after a disconnect throws, so guard `enqueue`.
- On reconnect the browser sends the last `id:` as the `Last-Event-ID` header. Replay what came after it from a durable log, or send a `resync` event so the client refetches.
- `no-transform` and `X-Accel-Buffering: no` stop compression and nginx from buffering the stream. Keep the default Node.js runtime: no `runtime` export.
- With Cache Components, read cookies or headers (or `await connection()` from `next/server`) before streaming, or the handler is prerendered.
- `EventSource` sends cookies on same-origin requests but cannot set headers: authenticate with the session cookie, or use `fetch` with a stream reader when you need a token. Derive the channel from the session, never from a query parameter the client can change.
- On serverless hosts a connection lives at most `maxDuration`, and each open stream holds a function instance. Let `EventSource` reconnect when the host cuts it, and move to a managed service when idle connections dominate the bill.
- Over HTTP/1.1 a browser opens at most six connections per origin, shared by every tab: one `EventSource` per tab, and HTTP/2 in production.

## WebSockets

- Route Handlers cannot accept a WebSocket upgrade, and serverless functions end after the response, so never put a socket server inside a Next.js route or `proxy.ts`. Use a managed service (Pusher Channels, Ably, Liveblocks, PartyKit, Supabase Realtime) or a separate WebSocket server (`ws`, Socket.IO) on a long-running host.
- Authorize in a Route Handler: check the session, then sign the channel subscription or issue a short-lived token. The service's secret stays on the server; the browser gets the public key and the token. A channel name is not access control.
- Publish from the server after the write commits, in the Server Function or Route Handler that changed the data. Client-to-client events suit ephemeral state such as typing and cursors; validate them on receipt with Zod.

## Subscribing on the client

- Subscriptions live in Client Components: open them in an effect whose cleanup closes them, or keep them in a module-level store read with `useSyncExternalStore`. Strict Mode runs effects twice in development, so the cleanup must undo everything.
- One connection per tab: a module owns the `EventSource` or socket, counts subscribers per topic, and closes when the last one leaves. Components subscribe to topics; they never open connections.
- With `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)`, `subscribe` returns its unsubscribe, `getSnapshot` returns the same reference until the data changes (a new array per call re-renders forever), and `getServerSnapshot` returns the server's initial value so hydration matches.
- Read the latest props inside event handlers with `useEffectEvent`, so changing a filter does not reopen the connection.
- Seed state with the data the Server Component rendered, then apply events. Show the connection state (live, reconnecting, offline) wherever stale data matters.

## Reconnection

- `EventSource` retries network drops itself, waiting the `retry:` delay, but closes for good when the server answers with an error status or another content type. In `onerror`, when `readyState === EventSource.CLOSED`, reconnect yourself with backoff, and stop once the user is signed out.
- Reconnect sockets, and a closed `EventSource`, with exponential backoff and full jitter: `Math.random() * Math.min(30_000, 1_000 * 2 ** attempt)`. Reset `attempt` once a connection has stayed up, cap the attempts, then show offline with a retry button.
- Reconnect at once on the `online` event and when the tab becomes visible again.
- After every reconnect, resync from the last event id or refetch the snapshot: missed events never arrive otherwise. Handle events idempotently: dedupe by id and drop events older than the version you hold.

## State, presence and conflicts

- Optimistic updates: give each mutation a client id (`crypto.randomUUID()`), apply it with `useOptimistic` in a transition that calls the Server Function, and include the id in the event the server publishes, so the confirmed item replaces the optimistic one instead of duplicating it. On failure the optimistic state reverts when the transition ends: show the error.
- The server orders events: each carries a server version or sequence per entity. Never order by client clocks.
- Last-write-wins by server version suits independent fields such as a status or a title. For records two people edit, use optimistic concurrency (`UPDATE … WHERE id = $1 AND version = $2`) and return a conflict when no row matches, so the client reloads and reapplies.
- For shared text and rich documents, use a CRDT: Yjs (`Y.Doc`, `Y.Text`, `Y.Map`) with a provider and the editor's binding. Persist encoded updates (`Y.encodeStateAsUpdate`), not plain text, and use awareness for cursors and selections.
- Presence is a heartbeat with a TTL (a Redis key that expires, or the service's presence API), never an `online` column in the main database or a single disconnect event. Throttle cursor and typing updates to one per animation frame or every 50 to 100 ms.

## Scaling and refreshing server data

- Serverless instances share no memory: an in-process `EventEmitter` only reaches clients on the same instance. Fan out through Redis pub/sub (a dedicated subscriber connection per instance), Postgres `LISTEN`/`NOTIFY` on a direct connection rather than a transaction pooler, or the managed service.
- Keep a short durable log for replay (Redis Streams, or an events table keyed by sequence); it is also your `Last-Event-ID` source.
- Send small events: ids, the changed fields and a version. Clients fetch large or private data through an authorized read.
- When server-rendered data changes, the receiving client calls `router.refresh()` from `useRouter`, debounced and inside `startTransition`. It does not clear the server cache: when the data is behind `use cache`, the writer invalidates first, with `updateTag(tag)` in a Server Function (or `refresh()` for uncached reads) and `revalidateTag(tag, "max")` or `revalidateTag(tag, { expire: 0 })` in a Route Handler or webhook.
- When the data lives in a client cache, write the event into it (TanStack Query's `setQueryData`, SWR's `mutate`) instead of refreshing the route.

## Do not

- Do not keep connected clients in a module-level array on a serverless host, or open a connection during render, in a Server Component or once per component.
- Do not poll in a tight `setInterval` loop, or reconnect on a fixed delay without jitter.
- Do not trust channel names, user ids or payloads from the client, broadcast whole records or secrets, or put private data on public channels.
- Do not call `revalidateTag` with one argument, or `updateTag` and `refresh()` from a Route Handler.

## Before you finish

- Run the type checker and the linter, and fix what they report.
- `curl -N` the SSE route: events arrive one by one, heartbeats keep coming, and it answers 401 without a session.
- With two browsers: a change in one appears in the other. Go offline in DevTools and back: it reconnects with backoff, without duplicate events, and catches up on missed ones.
- Close a tab and confirm the server stops its timers and subscriptions. In development, Strict Mode must still show one connection per tab.
- Unit-test the event reducer (order, duplicates, stale versions) and the backoff with fake timers.
