A dashboard that updates itself, a progress bar for a long job, a shared counter that every open tab agrees on. Polling with setInterval gets you there, badly: each update arrives half an interval late on average, and every request that finds nothing new is wasted. The browser has two built-in ways to keep a connection open instead: server-sent events (SSE), where only the server talks, and WebSockets, where both sides do. You will build the same live counter with each, in components that reconnect when the connection drops and close it when they unmount.
The project is Nuxt 4.1, so pages, components and composables live under app/ and server code under server/. The client side uses useEventSource and useWebSocket from VueUse 13.9; install it with npm install @vueuse/core.
Choose SSE or a WebSocket by who talks
Server-sent eventsA plain HTTP response that never ends. The server pushes text events; the browser cannot send anything on the same connection. Reconnects by itself and works through every proxy and CDN that handles HTTP.
WebSocketsA separate protocol upgraded from HTTP. Both sides send messages, text or binary, at any time. Needs server support for the upgrade and a hosting platform that keeps the connection open.
Notifications, prices, build logs, "someone else edited this": the server talks, the browser listens, and SSE is enough. Chat, multiplayer cursors, a collaborative editor: the browser talks constantly too, and a WebSocket avoids a request per keystroke. A good test is whether the browser's messages are rare enough to be ordinary POST requests. If they are, SSE for the way down and $fetch for the way up is simpler and cheaper to host.
Stream events from a Nitro route
Nitro, the server inside Nuxt, exposes createEventStream from h3. It sets the text/event-stream headers and gives you a push method. This route emits a counter once a second:
export default defineEventHandler((event) => {
const stream = createEventStream(event)
let count = 0
const timer = setInterval(() => {
count++
stream.push({
event: 'tick',
data: JSON.stringify({ count, at: new Date().toISOString() }),
})
}, 1000)
stream.onClosed(async () => {
clearInterval(timer)
await stream.close()
})
return stream.send()
})
push accepts a string for an unnamed message, or an object with event, data and optionally id and retry. data must be a string, hence the JSON.stringify. send() returns the response and keeps it open.
The onClosed callback runs when the browser disconnects. Clearing the interval there is not optional: without it, every visitor who ever opened the page leaves a timer running on your server.
Open http://localhost:3000/api/ticks in a browser tab and you will see lines arrive: event: tick, data: {...}, a blank line, once a second. That is the whole protocol.
Read the stream with EventSource
The browser API is EventSource. Wrapping it in a composable ties the connection to the component that needs it:
type Tick = { count: number; at: string }
export function useTicks(url = '/api/ticks') {
const tick = shallowRef<Tick | null>(null)
const status = ref<'connecting' | 'open' | 'closed'>('connecting')
let source: EventSource | undefined
function open() {
if (import.meta.server) return
close()
source = new EventSource(url)
status.value = 'connecting'
source.onopen = () => { status.value = 'open' }
source.onerror = () => {
status.value = source?.readyState === EventSource.CLOSED ? 'closed' : 'connecting'
}
source.addEventListener('tick', (e) => {
tick.value = JSON.parse((e as MessageEvent).data)
})
}
function close() {
source?.close()
source = undefined
status.value = 'closed'
}
onScopeDispose(close)
open()
return { tick, status, open, close }
}
Four details carry the weight:
import.meta.serverstops the composable from opening a connection during server rendering, where there is noEventSourceand no user.statusstarts as'connecting'so that the server's HTML matches the browser's first render, whereopen()has already set that value. Start it at'closed'and the two differ: Vue logs a hydration mismatch when it takes over the server's HTML.addEventListener('tick', ...)listens for the named event; unnamed messages would arrive ononmessageinstead.onScopeDispose(close)closes the connection when the component unmounts. Forget it and navigating away leaves the stream open, the timer running, and the handler updating a ref nobody renders.
The onerror handler looks odd because EventSource reconnects on its own. A dropped connection puts it in the CONNECTING state and the browser retries after a delay, a few seconds by default or whatever the server sent in a retry field. Only a fatal failure, such as the server answering with an error status, moves it to CLOSED, and then it stays there.
Use it like any composable:
<script setup lang="ts">
const { tick, status } = useTicks()
</script>
<template>
<p>Connection: {{ status }}</p>
<p v-if="tick">Tick {{ tick.count }} at {{ tick.at }}</p>
</template>
Replace the composable with useEventSource
VueUse packages the composable above, with the server guard and the cleanup built in, as useEventSource:
<script setup lang="ts">
import { useEventSource } from '@vueuse/core'
type Tick = { count: number; at: string }
const { data, status, error } = useEventSource('/api/ticks', ['tick'], {
serializer: { read: (raw) => (raw ? (JSON.parse(raw) as Tick) : null) },
autoReconnect: {
retries: 10,
delay: 2000,
onFailed() { console.warn('Gave up on the tick stream') },
},
})
</script>
<template>
<p>Connection: {{ status }}</p>
<p v-if="error">Stream error</p>
<p v-if="data">Tick {{ data.count }} at {{ data.at }}</p>
</template>
The second argument lists the named events to listen for. serializer.read turns the raw string into your type, so data is a Tick | null and the template is typed. status is CONNECTING, OPEN or CLOSED; VueUse sets CLOSED on any error, even while the browser is still retrying, and OPEN again when it reconnects.
autoReconnect picks up where the browser stops: when the EventSource itself reaches CLOSED, VueUse closes it and opens a new one after delay milliseconds, up to retries times, then calls onFailed.
The connection closes when the component's scope is disposed. On the server open() does nothing and status stays at CONNECTING, the value the browser renders first, so this page hydrates without a mismatch.
Add a WebSocket handler to Nitro
Now the two-way version: any client can increment the counter, and every client sees the new value. Nitro's WebSocket support is experimental and needs a flag:
export default defineNuxtConfig({
nitro: {
experimental: { websocket: true },
},
})
The handler goes in server/routes/_ws.ts and is written with defineWebSocketHandler, as in the Nitro WebSocket guide. A peer is one connected client:
let count = 0
export default defineWebSocketHandler({
open(peer) {
peer.subscribe('counter')
peer.send(JSON.stringify({ count }))
},
message(peer, message) {
const text = message.text()
if (text === 'ping') {
peer.send('pong')
return
}
if (text === 'increment') {
count++
const payload = JSON.stringify({ count })
peer.send(payload)
peer.publish('counter', payload)
}
},
close(peer) {
peer.unsubscribe('counter')
},
})
subscribe and publish are the pub/sub pair from crossws, the library under Nitro's implementation. publish sends to every subscriber of the topic except the publishing peer, which is why the handler calls peer.send as well. The ping branch exists for the heartbeat you will add on the client in a moment. The route name _ws is a convention; any route file can accept a WebSocket upgrade.
The Node server preset runs this handler, and so does the development server. Serverless targets vary in whether they keep a connection open at all, so check your platform before relying on it in production.
Connect a component with useWebSocket
The counter component connects to /_ws, shows the count and sends increment when clicked:
<script setup lang="ts">
import { useWebSocket } from '@vueuse/core'
const requestUrl = useRequestURL()
const wsUrl = `${requestUrl.protocol === 'https:' ? 'wss' : 'ws'}://${requestUrl.host}/_ws`
const { data, status, send, open } = useWebSocket<string>(wsUrl, {
immediate: false,
autoReconnect: {
retries: 5,
delay: 2000,
onFailed() { console.warn('Could not reconnect') },
},
heartbeat: {
message: 'ping',
responseMessage: 'pong',
interval: 30_000,
pongTimeout: 5_000,
},
})
onMounted(open)
const count = computed(() =>
data.value ? (JSON.parse(data.value) as { count: number }).count : 0,
)
</script>
<template>
<p>Socket: {{ status }}</p>
<p>Count: {{ count }}</p>
<button type="button" :disabled="status !== 'OPEN'" @click="send('increment')">
Increment
</button>
</template>
The component's setup also runs during server rendering, where there is no window.location. useRequestURL() gives the page's URL on the server and in the browser alike. The protocol switch matters: a page served over HTTPS may only open wss://.
immediate: false and onMounted(open) connect only once the component has mounted in the browser. useWebSocket starts at CLOSED, which is what the server renders; opening during setup would make the browser's first render say CONNECTING, and Vue would log a hydration mismatch.
status is CONNECTING, OPEN or CLOSED, and the button is disabled until the socket is open. send would buffer the message until then anyway, but a button that does nothing visible is worse than a disabled one. data holds the last message as a string; the computed parses it. Every time any client clicks, the server publishes, data changes, and every open tab shows the new count. Open two browser windows side by side and click in one to watch the other follow.
The heartbeat sends ping every 30 seconds and expects pong within 5. If nothing comes back, VueUse closes the socket and, because autoReconnect is on, opens a new one. That catches the common failure where a laptop sleeps or a proxy drops an idle connection without telling anyone, and the socket looks open while being dead. The pong replies are swallowed by VueUse and never reach data.
As with useEventSource, the connection closes when the component unmounts; useWebSocket also closes it on beforeunload.
Spread out reconnects with backoff
Both composables retry with a fixed delay in VueUse 13.9. A fixed delay is fine for a handful of users. If a server restart disconnects a few thousand clients at once, you want them to come back spread out, which means a delay that grows with each attempt, called exponential backoff. You can build it from the hooks VueUse gives you. In LiveCounter.vue, replace the useWebSocket call with this one, copy the heartbeat option across, and keep onMounted(open):
let attempt = 0
let stopped = false
const { data, status, send, open } = useWebSocket<string>(wsUrl, {
immediate: false,
autoReconnect: false,
onConnected() { attempt = 0 },
onDisconnected() {
if (stopped) return
const wait = Math.min(30_000, 1000 * 2 ** attempt++)
setTimeout(() => { if (!stopped) open() }, wait)
},
})
onScopeDispose(() => { stopped = true })
The delay doubles from one second to a cap of 30 and resets on a successful connection. The stopped flag keeps an unmounted component from reopening its socket: onDisconnected checks it when the close arrives, and the timer checks it again in case the component unmounted while a retry was waiting.
For SSE, the browser's own reconnection already applies a delay, and the server can tune it with the retry field on any pushed message.