"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.
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.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.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.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.BroadcastChannel shares state between tabs so one WebSocket per browser is enough; SharedWorker centralises the connection.A Nitro WebSocket handler with rooms and authentication at the handshake:
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
},
})
export default defineNuxtConfig({ nitro: { experimental: { websocket: true } } })
The client: reconnecting socket, optimistic moves, idempotent application by version:
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:
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()
})
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 }
}
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.
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.
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.
"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."
"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."
Nothing yet. Notes filed here show up on this page and the section overview.
Forms and validation
One schema for client and server with Standard Schema libraries, Nuxt UI's UForm, VeeValidate and FormKit compared, field components with defineModel, async validation, accessible error display, and mapping server errors back to fields.
URL, cookies and browser persistence
The URL as state, cookies for what the server render needs, localStorage and IndexedDB without hydration mismatches, versioned schemas, cross-tab sync, export/import, and the offline basics.