Real-time with WebSockets and SSE
Push updates from a Nuxt server with server-sent events or a WebSocket, read them with VueUse's useEventSource() and useWebSocket(), and handle reconnects, heartbeats, repeated messages and proxies.
What you'll learn
When you finish, you can do these.- 1Choose SSE, a WebSocket or polling for a live feature
- 2Stream progress with SSE and stop it once it is done
- 3Build a WebSocket chat that reconnects and stays open
- 4Fix a chat that skips repeats or fails on the server
A spreadsheet export that takes a minute, a support chat, a count of today's orders: each shows data that changes while the page is open. When the screen must change the moment the server knows, the server has to push, and the browser has two ways to listen: server-sent events (SSE) and WebSockets.
The Code tab holds the files behind /live, one page of a Nuxt app (Nuxt is the framework built on Vue that also runs your server code). The page shows an export's progress bar fed by SSE and a support chat over a WebSocket, both read with VueUse, a library of ready-made Vue functions. You need one idea from the lesson Mutations, optimistic updates and pagination: after the visitor saves a change, the page fetches its data again, but a change made elsewhere tells it nothing.
Pick the channel by direction
The channel follows from which side talks.
Server-sent events are one long HTTP response. The server keeps it open and writes small text events into it, and the browser's built-in EventSource object reads them. Traffic goes one way, from server to browser, and when the connection drops the browser reconnects by itself.
A WebSocket starts as an HTTP request and becomes a connection that stays open in both directions. Either side can send at any time, but nothing reconnects unless you add it, and your host must keep the server running between requests.
Three jobs:
- Export progress: the server talks and the page listens, so SSE.
- A support chat: both sides send at any moment, so a WebSocket.
- Today's orders, which change every few minutes: polling, a refetch on a timer such as
refetchInterval: 60_000(one minute) in TanStack Query, a data-fetching library. No connection stays open.
A progress stream with SSE
The progress bar needs a server route, a file that answers one URL, to write the stream, and a component to read it. The route, server/api/export-progress.get.ts, makes the stream with createEventStream(), which Nitro, Nuxt's server engine, imports for you. A timer adds 10 to percent every second:
const stream = createEventStream(event)
const timer = setInterval(async () => {
percent += 10
await stream.push({ id: String(percent), event: 'progress', data: JSON.stringify({ percent }) })
if (percent >= 100) await stream.close()
}, 1000)
stream.onClosed(() => clearInterval(timer))
return stream.send()
Each push() writes one event: an id (the percent), a name and the data as text. onClosed stops the timer when the visitor leaves.
- Nitro routecreateEventStream
- Open HTTP response
- EventSource
- data ref
- Template
The component that reads the stream, ExportProgress.vue, uses VueUse's useEventSource(), which wraps EventSource and closes it when the component leaves the page:
const { data, status } = useEventSource('/api/export-progress', ['progress'])
const percent = computed(() => (data.value ? JSON.parse(data.value).percent : 0))
data holds the text of the latest progress event, percent reads the number out of it, and status is CONNECTING, OPEN or CLOSED. The page shows Export: 10 % (OPEN) above a progress bar, climbing by 10 each second.
When a stream drops, EventSource waits about three seconds and reconnects, sending the last id it received, here the percent, in a Last-Event-ID header, so the route can carry on.
It reconnects even after the route closes the stream at 100 %, because every ended response looks dropped to it. Only an answer that is not a 200 OK event stream stops it, and 204 No Content is the usual "finished". The route's first two lines carry on or finish:
let percent = Number(getHeader(event, 'last-event-id') ?? 0)
if (percent >= 100) return sendNoContent(event)
After 100 %, the reconnect gets the 204 and stops for good.
A chat with WebSockets
The support chat needs a route that holds a connection per visitor and a component that sends and shows messages. With nitro.experimental.websocket on in nuxt.config.ts, the settings file, Nitro serves the route server/routes/_ws.ts at /_ws.
Its upgrade hook, a function Nitro calls before the connection opens, checks who is signed in. A browser cannot add headers to a WebSocket, but it does send cookies: userFromCookie() in server/utils/session.ts, a stand-in for a real login check, reads a name from a demo_user cookie. With no name, a thrown 401 refuses the socket:
async upgrade(request) {
const user = await userFromCookie(request.headers.get('cookie'))
if (!user) throw new Response('Unauthorized', { status: 401 })
request.context.user = user
},
Refused, the chat's status ends at CLOSED, and Chrome logs WebSocket connection to 'ws://localhost:3000/_ws' failed: HTTP Authentication failed; no valid credentials available. With a cookie such as demo_user=Asha, the socket opens.
In the route's open and message hooks, each connection is a peer: peer.subscribe('chat') joins a room, peer.publish() sends a line such as Asha: ok to everyone else in it and peer.send() to the sender.
In the browser, ChatRoom.vue connects with VueUse's useWebSocket(). Current browsers turn its relative URL, a bare path, into ws:// or wss://, WebSocket's own http:// and https://, on the page's host:
const { status, send } = useWebSocket('/_ws', {
autoReconnect: { retries: 5, delay: 2000 },
heartbeat: {
message: 'ping',
responseMessage: 'pong',
scheduler: cb => useIntervalFn(cb, 30_000),
},
onMessage: (_ws, event) => log.value.push(String(event.data)),
})
autoReconnect is off by default, so the chat turns it on. The heartbeat, a ping every 30 seconds set by its scheduler, keeps proxies from closing a quiet socket (without a scheduler, VueUse 15 pings every second). responseMessage: 'pong' keeps the server's answer out of log, the list onMessage fills.
The page itself, app/pages/live.vue, renders the chat inside <ClientOnly>, only in the browser, where its socket opens. Rendered on the server, its status would read CLOSED in the server's HTML and CONNECTING once Vue takes over in the browser: a hydration mismatch.
What goes wrong
The first mistake reads messages with a watcher, which runs when a ref's value changes:
const { status, data, send } = useWebSocket('/_ws')
watch(data, (msg) => { if (msg) log.value.push(String(msg)) })
Asha sends ok twice, and every chat shows Asha: ok once. data holds the latest message, and the same text again is no change, so the watcher skips the second one. onMessage runs for every message:
const { status, send } = useWebSocket('/_ws', {
onMessage: (_ws, event) => log.value.push(String(event.data)),
})
Both lines now reach the log.
The second mistake builds the URL during setup from location, the browser's object for the page's address:
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_ws`
const { status, send } = useWebSocket(url)
Rendered outside <ClientOnly>, the page fails with a 500 and Cannot read properties of undefined (reading 'protocol'): setup runs on the server too, and there is no location there. A relative URL needs no location at all:
const { status, send } = useWebSocket('/_ws')
The server render passes, and the browser adds the host.
What to remember
- Choose by direction: SSE when the server talks, a WebSocket when both sides send often, polling when a minute of delay is fine.
EventSourcereconnects by itself and sendsLast-Event-ID. Answer 204 when there is nothing more to send.useWebSocket()reconnects only withautoReconnect. Give the heartbeat aresponseMessageand ascheduler.- Read every message in
onMessage.watch(data)misses a message identical to the one before. - Setup runs on the server: build socket URLs without
location, and render socket UIs inside<ClientOnly>.
In your project
Do these in a real project. Each one says how to confirm it worked.- Open
/livewith the Network tab filtered toexport-progress. The percent climbs by 10 each second, status OPEN. At 100 % it turns CLOSED; about three seconds later a second request carriesLast-Event-ID: 100and gets a 204. Nothing reconnects after that. - On
/live, the chat status ends at CLOSED, with theHTTP Authentication failederror in the console once per attempt. Rundocument.cookie = 'demo_user=Asha'there and reload: the status says OPEN. Send ok twice and the log showsAsha: oktwice. Swap in thewatch(data)version: the second ok is missing. - Deploy, then run
curl -N https://your-site/api/export-progress: anid:,event: progressanddata:block should print every second, and all of them at once at the end means a proxy is buffering. Open the deployed chat in two browsers, leave both idle for five minutes and send a message: it arrives with the status still OPEN.