Real-time features in Vue: practical tips and trade-offs
Building live-updating interfaces feels like magic to users, but the engineering underneath requires careful thought about performance, reliability, and state management. We've found that adding WebSocket support to a Vue application forces us to confront questions we might otherwise avoid: how do we keep the UI synchronized when data flows in from the server unpredictably? What happens when the connection drops? And how do we scale this without turning our Pinia store into a tangled mess?
This guide covers the practical trade-offs we've learned shipping real-time features in Vue and Nuxt, from composable patterns to reconnection strategies, with code you can adapt to live-update your own interfaces.
WebSocket composables: the foundation
The cleanest way to manage WebSocket lifecycle in Vue is through a composable. Rather than scattering connection logic across components, we encapsulate it in one place and let components subscribe to messages as they need them.
Here's a solid starting point:
// composables/useWebSocket.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useWebSocket(url: string) {
const ws = ref(null)
const isConnected = ref(false)
const lastMessage = ref(null)
const listeners: Record> = {}
const connect = () => {
ws.value = new WebSocket(url)
ws.value.onopen = () => {
isConnected.value = true
console.log('WebSocket connected')
}
ws.value.onmessage = (event) => {
lastMessage.value = JSON.parse(event.data)
const eventType = lastMessage.value.type
if (listeners[eventType]) {
listeners[eventType].forEach(callback => callback(lastMessage.value))
}
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
isConnected.value = false
}
ws.value.onclose = () => {
isConnected.value = false
console.log('WebSocket disconnected')
}
}
const on = (eventType: string, callback: Function) => {
if (!listeners[eventType]) {
listeners[eventType] = new Set()
}
listeners[eventType].add(callback)
return () => {
listeners[eventType].delete(callback)
}
}
const send = (data: any) => {
if (ws.value && isConnected.value) {
ws.value.send(JSON.stringify(data))
}
}
const close = () => {
if (ws.value) {
ws.value.close()
}
}
onMounted(() => {
connect()
})
onUnmounted(() => {
close()
})
return {
isConnected,
lastMessage,
on,
send,
close
}
}
This composable gives you a publisher-subscriber pattern without external dependencies. Each component can call on() to listen for specific message types, and unsubscribe automatically when it unmounts. In a ride-hailing app, for instance, a driver's location component might listen only for location_update messages while ignoring order notifications.
Update Pinia stores from messages
Real-time data should flow through your state management layer. Listening to WebSocket messages and updating a Pinia store keeps your component logic clean and your data flow predictable.
Here's how we typically structure this:
// stores/orderStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useOrderStore = defineStore('orders', () => {
const orders = ref([])
const ordersById = computed(() =>
Object.fromEntries(orders.value.map(o => [o.id, o]))
)
const updateOrder = (updatedOrder: Order) => {
const index = orders.value.findIndex(o => o.id === updatedOrder.id)
if (index !== -1) {
orders.value[index] = updatedOrder
} else {
orders.value.push(updatedOrder)
}
}
const setOrders = (newOrders: Order[]) => {
orders.value = newOrders
}
return {
orders,
ordersById,
updateOrder,
setOrders
}
})
Then in a composable or setup, connect the WebSocket to this store:
// composables/useOrderUpdates.ts
import { useWebSocket } from './useWebSocket'
import { useOrderStore } from '@/stores/orderStore'
export function useOrderUpdates() {
const { on } = useWebSocket(import.meta.env.VITE_WS_URL)
const orderStore = useOrderStore()
on('order_updated', (message) => {
orderStore.updateOrder(message.order)
})
on('order_created', (message) => {
orderStore.updateOrder(message.order)
})
return {
orders: computed(() => orderStore.orders)
}
}
Components then simply use the store, completely decoupled from WebSocket mechanics. A real-time order dashboard might display orders.value and reactively update whenever order_updated arrives from the server.
Reconnect logic: handling the real world
Network interruptions are guaranteed. We've seen WebSocket connections drop due to browser tab suspension, network switching, proxy timeouts, and a hundred other reasons. Automatic reconnection with exponential backoff is non-negotiable for production.
// composables/useWebSocket.ts (enhanced with reconnection)
import { ref, onMounted, onUnmounted } from 'vue'
export function useWebSocket(url: string) {
const ws = ref(null)
const isConnected = ref(false)
let reconnectAttempts = 0
const maxReconnectAttempts = 10
let reconnectTimeout: ReturnType
const reconnect = () => {
if (reconnectAttempts >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached')
return
}
reconnectAttempts++
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 30000)
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`)
reconnectTimeout = setTimeout(() => {
connect()
}, delay)
}
const connect = () => {
ws.value = new WebSocket(url)
ws.value.onopen = () => {
isConnected.value = true
reconnectAttempts = 0
console.log('WebSocket connected')
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
isConnected.value = false
}
ws.value.onclose = () => {
isConnected.value = false
reconnect()
}
}
const close = () => {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
if (ws.value) {
ws.value.close()
}
}
onMounted(() => {
connect()
})
onUnmounted(() => {
close()
}
return { isConnected, /* ... */ }
}
This implementation exponentially backs off, doubling the delay each time (1s, 2s, 4s, 8s...) up to 30 seconds. After 10 failed attempts, it stops trying and logs an error, allowing you to show a user-facing alert. In a trading platform, this prevents connection storms when the market data service is down.
Nuxt server routes: WebSocket on the server side
Nuxt server routes (or API routes in middleware) can also handle WebSocket connections. This is powerful when you need to transform or aggregate real-time data before sending it to the client, or when you're running Nuxt in SSR mode and need a bridge between the server and browser.
Node.js's ws library works well here:
// server/api/subscribe.ts
import { WebSocket, WebSocketServer } from 'ws'
let wss: WebSocketServer
export default defineEventHandler(async (event) => {
// Initialize WebSocket server once
if (!wss) {
wss = new WebSocketServer({ noServer: true })
wss.on('connection', (ws) => {
console.log('Client connected')
// Relay messages from upstream source
ws.on('message', (data) => {
console.log('Received:', data)
// Process and broadcast if needed
})
ws.on('close', () => {
console.log('Client disconnected')
})
})
}
const { node } = event
if (node.req.headers.upgrade === 'websocket') {
wss.handleUpgrade(node.req, node.socket, Buffer.alloc(0), (ws) => {
wss.emit('connection', ws, node.req)
})
}
})
On the client, connect to wss://yourdomain.com/api/subscribe instead of a raw upstream WebSocket. The server can then normalize message formats, filter sensitive data, or aggregate updates from multiple upstream sources before fanning out to clients. A travel app might consolidate flight updates from three different suppliers into a single message format.
Common mistakes
- Forgetting to unsubscribe from listeners. If you attach a listener in setup but don't clean it up in unmount, the callback remains in memory. Use the return value from
on()to unsubscribe, or do it explicitly inonUnmounted. - Not handling message ordering. Real-time updates can arrive out of order, especially with reconnection. Always include a timestamp or version number in your messages and reconcile state accordingly.
- Treating WebSocket state as infallible. Even with reconnection logic, assume the connection can fail at any moment. Never rely solely on WebSocket to persist data; always pair it with REST endpoints for reconciliation.
- Allowing memory leaks in the listener map. If you register many listeners and never unsubscribe, the
listenersobject grows indefinitely. Always clean up when components unmount or features are disabled. - Broadcasting too aggressively. Sending a message on every keystroke or scroll event floods the network. Throttle or debounce client-side events before sending.
Key takeaways
- Encapsulate WebSocket logic in a reusable composable with a simple pub-sub interface; keep components ignorant of connection details.
- Route real-time messages through Pinia stores to maintain a single source of truth and keep state updates predictable.
- Implement exponential backoff reconnection with a maximum retry limit to handle network hiccups gracefully without overwhelming the server.
- Consider a Nuxt server route as a WebSocket proxy to normalize upstream data, enforce security policies, and aggregate multiple sources.
- Always pair WebSocket with REST endpoints; assume connections can fail and design for eventual consistency.
- Profile message throughput and unsubscription behavior in production to catch memory leaks and connection pool exhaustion early.






Comments
Loading comments…