State & data layer

Realtime and sync

WebSockets versus Server-Sent Events versus polling, Nitro's WebSocket and event-stream support, reconnection and resumption, keeping clients in sync with optimistic local state, and scaling realtime beyond one server.

"How would you add live updates" is a design question with three sensible answers and a lot of wrong confidence. The senior version names the transport trade-offs, the reconnection story, how the client state absorbs pushes without fighting user edits, and what breaks when there is more than one server instance.

Know

  • Polling (setInterval + fetch): simplest, cache-friendly, works everywhere; latency equals the interval; fine for slow-changing data, and with ETag/304 it is cheap. Long polling holds the request until data exists; rarely worth it today.
  • Server-Sent Events (EventSource): one-directional server → client over a plain HTTP response (text/event-stream), automatic reconnection with Last-Event-ID, text only, trivially cacheable/proxyable; limited to ~6 connections per origin on HTTP/1.1 (not on HTTP/2). Nitro: createEventStream(event) and stream.push({ id, event, data }). Right for feeds, notifications, progress, dashboards.
  • WebSockets: bidirectional, binary-capable, one persistent connection; no automatic reconnection or resumption (you write both); needs stateful servers or a managed layer. Nitro: experimental.websocket: true and defineWebSocketHandler({ open, message, close, error }) built on crossws with peer.subscribe(channel) / peer.publish(channel, payload); supported on Node, Bun, Deno and Cloudflare (Durable Objects), not on stateless serverless functions.
  • Client side: VueUse useWebSocket (auto-reconnect with retries, heartbeat, status, send) and useEventSource; pause when document.hidden, resume with the last seen id or version, and back off exponentially with jitter on reconnect.
  • Sync model: decide who is authoritative. Server-authoritative (client applies pushes verbatim) is simplest. Optimistic local applies user edits immediately, tags them with a client id, and reconciles when the server echo arrives (drop the echo of your own change, apply others'). Collaborative editing needs CRDTs (Yjs, Automerge) or OT rather than last-write-wins.
  • Ordering and idempotency: every message carries a monotonically increasing id or version; the client ignores stale or duplicate messages; reconnect asks for "everything since version N" or falls back to a full refetch.
  • Cross-tab: BroadcastChannel shares state between tabs so one WebSocket per browser is enough; SharedWorker centralises the connection.
  • Scaling: a WebSocket lives on one server, so with several instances you need a pub/sub backplane (Redis, NATS) or Cloudflare Durable Objects, and a load balancer that supports upgrades. Managed services (Pusher, Ably, Supabase Realtime, Liveblocks) are legitimate when realtime is not the product. Authenticate at the handshake (cookie or ticket), authorise per channel.
  • Payload discipline: send diffs or event names, not whole documents; batch bursts; compress on the wire; cap message size; treat the socket as untrusted input on the server just like a request body.

How it works

A Nitro WebSocket handler with rooms and authentication at the handshake:

server/routes/ws/board.ts
export default defineWebSocketHandler({
  async open(peer) {
    const session = await getUserSession(peer) // read the cookie sent with the upgrade request
    if (!session.user) return peer.close(4001, 'Unauthorized')
    const boardId = new URL(peer.request.url).searchParams.get('board')!
    peer.context.user = session.user
    peer.subscribe(`board:${boardId}`)
    peer.publish(`board:${boardId}`, { type: 'presence', user: session.user.name, joined: true })
  },
  message(peer, message) {
    const event = JSON.parse(message.text()) as { type: 'move', cardId: string, column: string, version: number }
    if (event.type !== 'move') return
    // Persist, then broadcast with the authoritative version; the sender applies its own echo idempotently.
    const saved = moveCard(event.cardId, event.column)
    const boardId = new URL(peer.request.url).searchParams.get('board')!
    peer.publish(`board:${boardId}`, { ...event, version: saved.version, by: peer.context.user.id })
    peer.send({ ...event, version: saved.version, by: peer.context.user.id })
  },
  close(peer) {
    // subscriptions are dropped automatically; announce departure if you track presence
  },
})
nuxt.config.ts
export default defineNuxtConfig({ nitro: { experimental: { websocket: true } } })

The client: reconnecting socket, optimistic moves, idempotent application by version:

app/composables/useBoardSync.ts
import { useWebSocket } from '@vueuse/core'

interface Card { id: string, column: string, version: number }

export function useBoardSync(boardId: string, cards: Ref<Card[]>) {
  const url = computed(() => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/board?board=${boardId}`)
  const { status, send } = useWebSocket(url, {
    autoReconnect: { retries: 10, delay: 1000 }, // add jitter for real deployments
    heartbeat: { message: 'ping', interval: 30_000 },
    onMessage(_ws, event) {
      const msg = JSON.parse(event.data) as { type: string, cardId?: string, column?: string, version?: number }
      if (msg.type !== 'move' || !msg.cardId) return
      const card = cards.value.find(c => c.id === msg.cardId)
      if (card && msg.version! > card.version) { // ignore stale or duplicate echoes
        card.column = msg.column!
        card.version = msg.version!
      }
    },
  })

  function move(cardId: string, column: string) {
    const card = cards.value.find(c => c.id === cardId)
    if (!card) return
    card.column = column // optimistic; the echo carries the authoritative version
    send(JSON.stringify({ type: 'move', cardId, column, version: card.version }))
  }

  return { status, move }
}

Server-Sent Events for a one-way progress feed, with resumption:

server/api/jobs/[id]/events.get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')!
  const lastId = Number(getHeader(event, 'last-event-id') ?? 0)
  const stream = createEventStream(event)

  const unsubscribe = jobBus.subscribe(id, (update) => {
    if (update.seq <= lastId) return // the client already has it
    stream.push({ id: String(update.seq), event: 'progress', data: JSON.stringify(update) })
  })
  stream.onClosed(() => { unsubscribe(); stream.close() })
  return stream.send()
})
app/composables/useJobProgress.ts
import { useEventSource } from '@vueuse/core'

export function useJobProgress(jobId: string) {
  const { data, status } = useEventSource(`/api/jobs/${jobId}/events`, ['progress'], { autoReconnect: true })
  const progress = computed(() => (data.value ? JSON.parse(data.value) as { seq: number, percent: number } : null))
  return { progress, status }
}
Gotcha· WebSockets on serverless

A Vercel or Netlify function ends when the response ends; there is nothing to hold a socket. Either use SSE (it is a response, so it streams within the function's time limit), a platform with stateful primitives (Cloudflare Durable Objects, a Node server), or a managed realtime service. This is the first thing to establish in a design interview.

Gotcha· Applying your own echo twice

If the client applies an optimistic change and then blindly applies the server's broadcast of the same change, list reorders jitter and counters double. Carry a version or a client id on every message and apply only what is newer than what you have.

Verify before the interview:

Nitro 2's experimental.websocket flag, the defineWebSocketHandler hooks, peer API (subscribe, publish, context) and the platforms listed as supported; Nitro 3 changes this surface.

docs ↗

Exercise

Exercise
  • Build the board sync with two browser windows; kill the dev server, edit in one window, restart, and confirm the other window reconnects and converges.
  • Replace the socket with SSE plus a POST for moves; compare the code size and what breaks when you open seven tabs on HTTP/1.1.
  • Add jitter to the reconnect delay and simulate 50 clients reconnecting at once with a small script; watch the server log with and without jitter.

Be able to say

Be able to say· WebSockets, Server-Sent Events or polling?

"Polling when data changes slowly and caching helps; it is the cheapest to operate. Server-Sent Events when the server pushes and the client mostly listens: notifications, progress, feeds. It is plain HTTP, reconnects automatically with Last-Event-ID, and works on serverless because it is just a streaming response. WebSockets when the client sends often too, or when latency and binary matter: chat, collaboration, games. They need stateful servers or Durable Objects, and I have to write reconnection, resumption and a pub/sub backplane for more than one instance. In Nitro both are built in: createEventStream for SSE and defineWebSocketHandler on crossws for sockets."

Be able to say· How do you keep client state consistent with realtime pushes?

"Every message carries a version; the client applies a message only if it is newer than what it holds, which makes duplicates and my own echoes harmless. User edits apply optimistically and are reconciled by the server echo that carries the authoritative version. On reconnect the client asks for everything since its last version or falls back to a full refetch. The server is authoritative for ordering; if the product is genuinely collaborative editing I would use a CRDT such as Yjs rather than invent conflict resolution."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.