WebSockets
Open a WebSocket inside a composable, write messages into refs, reconnect with backoff, and close it when the component goes away.
new WebSocket(url: string, protocols?: string | string[]): WebSocketA WebSocket is one connection that stays open and carries messages in both directions. The browser creates it with new WebSocket(url), over ws:// locally and wss:// in production. It exists because polling an HTTP endpoint spends a request per check and still shows the change late.
Where the connection lives
Put it in a composable: a plain function that uses Vue's reactivity APIs and returns refs. The composable opens the socket, keeps the connection state in a ref, and registers its own teardown with onScopeDispose(fn, failSilently?), so every component that calls it gets cleanup without writing any. The second argument of onScopeDispose arrived in Vue 3.5 and suppresses the warning when there is no active scope, which is what you want in a composable that may also be called outside a component.
Receiving a message means listening for message and writing event.data into a ref. The template updates because the ref changed. Note the shorthand: ws.onmessage = handler is the same subscription as ws.addEventListener('message', handler), with one difference, the property holds a single handler and assigning again replaces it.
Reconnecting
A WebSocket never reconnects by itself. On close, schedule connect() again after a delay that doubles per attempt and is capped: 1s, 2s, 4s, up to 30s. Reset the counter in the open handler.
What trips people up
send() throws InvalidStateError while readyState is WebSocket.CONNECTING (0), so test for WebSocket.OPEN (1) first.
Example
// src/composables/useSocket.ts
import { onScopeDispose, readonly, ref, shallowRef, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
export type SocketStatus = 'connecting' | 'open' | 'closed'
export function useSocket(url: MaybeRefOrGetter<string>) {
const status = ref<SocketStatus>('closed')
const lastMessage = ref<string | null>(null)
const socket = shallowRef<WebSocket | null>(null)
let attempts = 0
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
function connect() {
status.value = 'connecting'
const ws = new WebSocket(toValue(url))
socket.value = ws
ws.onopen = () => {
attempts = 0
status.value = 'open'
}
ws.onmessage = (event: MessageEvent<string>) => {
lastMessage.value = event.data
}
ws.onclose = () => {
if (socket.value !== ws) return
status.value = 'closed'
if (stopped) return
attempts += 1
timer = setTimeout(connect, Math.min(1000 * 2 ** (attempts - 1), 30000))
}
}
function send(data: string) {
const ws = socket.value
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data)
}
function close() {
stopped = true
clearTimeout(timer)
socket.value?.close(1000, 'client closed')
}
connect()
onScopeDispose(close, true)
return { status: readonly(status), lastMessage: readonly(lastMessage), send, close }
}When to use it
- Stream price or order-book updates into a trading panel instead of polling every second.
- Carry chat messages, presence and typing indicators in one connection.
- Push live progress from a long server job while the user keeps working.
- Recover a dashboard's feed after a laptop wakes from sleep, without a page reload.
More examples
A component using the composable
The panel shows the connection state and the most recent frame, and the Ping button is disabled until the status is open. Destructuring the object a composable returns is safe because the members are refs, unlike destructuring a reactive object, which loses reactivity. The button guard and the readyState check inside send() are two layers of the same protection: the disabled attribute stops the click, and the readyState check means that even an unguarded click while the socket is still connecting is dropped silently instead of throwing, which is what a bare ws.send() would do with InvalidStateError.
<!-- src/components/TickerPanel.vue -->
<script setup lang="ts">
import { useSocket } from '@/composables/useSocket'
const { status, lastMessage, send } = useSocket('wss://stream.example.com/ticks')
function ping() {
send(JSON.stringify({ type: 'ping' }))
}
</script>
<template>
<section class="panel">
<p>Connection: {{ status }}</p>
<p v-if="lastMessage">Last frame: {{ lastMessage }}</p>
<p v-else>Waiting for the first frame</p>
<button type="button" :disabled="status !== 'open'" @click="ping">Ping</button>
</section>
</template>
<style scoped>
.panel {
display: grid;
gap: 0.5rem;
}
</style>The backoff delay on its own
Pulling the delay out of the composable makes it a function you can assert on in a unit test, with no timers and no socket. Reach for this when the retry policy becomes a product decision rather than a detail. One addition production code usually makes: multiply the result by a small random factor, so a thousand browsers that dropped together do not all reconnect in the same millisecond. That makes the output nondeterministic, which is why it is not in the function above.
// src/lib/backoff.ts
export function backoffDelay(attempt: number, base = 1000, cap = 30000) {
return Math.min(cap, base * 2 ** (attempt - 1))
}
console.log([1, 2, 3, 4, 5, 6].map((n) => backoffDelay(n)))
// Output: [ 1000, 2000, 4000, 8000, 16000, 30000 ]
console.log(backoffDelay(3, 250, 5000))
// Output: 1000Adding a manual retry
A retry button needs a way in, and the order of these lines is the whole trick. connect() runs first, so socket.value already points at the new socket by the time the old one closes. The old socket's onclose handler then hits the `socket.value !== ws` guard and returns, instead of scheduling a backoff reconnect for a socket that has been replaced. Without that guard, every manual retry leaves a stray timer behind and you end up with two live connections delivering every message twice.
// src/composables/useSocket.ts (inside useSocket, next to connect and close)
function reconnect() {
clearTimeout(timer)
attempts = 0
stopped = false
const previous = socket.value
connect()
previous?.close(1000, 'reconnecting')
}
// then return it alongside the rest:
// return { status: readonly(status), lastMessage: readonly(lastMessage), send, close, reconnect }
Discussion