WebSocket
WebSocket
Push real-time updates and notifications from Hono routes to the browser over a multiplexed, cookie-authenticated WebSocket connection.
VitNode provides one shared WebSocket connection at /api/ws. Browsers share a single connection across tabs, authenticated by session cookies, and every feature multiplexes its messages over it. Use it to push live updates from Hono routes: a toast for one user, a counter for everyone, or a message for one room.
Before you begin
- The
/api/wsendpoint. A single app turns it on withwebSocket: true, Vercel included; a standalone API mounts it next to its HTTP server. See Set up the endpoint. REDIS_URL, only if you run more than one API instance. Without it, pushes reach clients connected to the same instance.
Send a toast to a user
Push a transient toast to any signed-in user from any Hono route or service:
import { notificationsChannel } from "@vitnode/core/ws/notifications"
c.get("realtime").sendToUser(userId, notificationsChannel, {
title: "New Comment",
description: "Alex commented on your post.",
type: "info",
})The user receives an immediate sonner toast in all open tabs across devices.
notificationsChannel is for transient toasts: they reach open tabs only and
are gone on reload. For anything users should find later - with an inbox,
unread count, email and preferences - publish a persistent notification with
c.get("notifications").publish(). See
Notifications. The bell's unread count travels over
this same connection on notificationsStateChannel.
Define a channel
A channel is a typed id that the server and the browser both import. Create your plugin's channels in one file:
import { createWebSocketChannel } from "@vitnode/core/ws/types"
export interface MessagePayload {
roomId: string
message: string
senderId: number
}
export const chatChannel = createWebSocketChannel<never, MessagePayload>({
pluginId: "@acme/chat",
module: "rooms",
id: "messages",
})
export const visitorsChannel = createWebSocketChannel<never, { count: number }>({
pluginId: "@acme/chat",
module: "rooms",
id: "visitors",
})The first type parameter is what the browser sends, the second what it receives. never means the browser only listens. The channel id becomes {pluginId}_{module}_{id}.
Push to everyone
Broadcast a live update to every connected visitor, signed in or not:
import { visitorsChannel } from "@/ws/chat.channel"
c.get("realtime").broadcast(visitorsChannel, { count: 142 })Listen in the browser
Receive a channel's messages with useVitNodeWebSocket:
import { useVitNodeWebSocket } from "@vitnode/core/ws/use-websocket"
import { useState } from "react"
import { visitorsChannel } from "../ws/chat.channel"
export const VisitorsBadge = () => {
const [count, setCount] = useState<number | null>(null)
useVitNodeWebSocket(visitorsChannel, {
onMessage: payload => {
setCount(payload.count)
},
})
if (count === null) return null
return <p>{count} people online</p>
}The hook also returns { connectionId, readyState, send }. send talks to a server socket built with buildWebSocket, and connectionId changes on every reconnect. Rooms uses both.
c.get("realtime") methods
| Method | Behavior |
|---|---|
sendToUser(userId, channel, data) | Every open connection of one user, on all their devices |
sendToUsers(channel, messages) | A different payload per user, from { userId, data } items |
broadcast(channel, data) | Every open connection, guests included |
toRoom(room, channel, data) | The connections that joined room, see Rooms |
closeUserConnections(userId, deviceId?) | Closes a user's sockets, or only one device's |
Every method returns nothing and does not wait. A client that is not connected misses the message.
How the connection behaves
- One connection: Only one
/api/wssocket is opened per client. All features share it via message multiplexing. - Tab leader election: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, and it passes messages to the other tabs over
BroadcastChannel. - Automatic reconnect: A dropped connection retries after about a second, then backs off (with a little jitter) up to 30 seconds, so a restarted server is not stampeded by every open tab.
- Offline aware: When the browser goes offline, the socket closes and stops retrying. It reconnects the moment the network comes back, or when a tab becomes visible again after sleep, without waiting for the backoff.
- Heartbeat, both ways: The server pings every socket every 30 seconds and closes the ones that stopped answering, so vanished clients do not linger (the standalone API's
wsserver and Nitro's crossws both do). The browser sends its own ping every 25 seconds and reconnects when no reply arrives within 10 seconds, so a half-dead connection after a Wi-Fi drop never pretends to be online. - No replay: Messages sent while a socket is down are not queued. A feature that must not miss updates reloads its state when the socket opens again - see Reconnects and missed messages.
- Closed on revoke: A socket is tied to the session and device that opened it. Revoking sessions (signing out a device, changing or resetting a password, an admin revoke) closes those sockets on every instance through
closeUserConnections, so a revoked device stops receiving the user's pushes. - Multiple instances: With
REDIS_URLset,sendToUser,broadcastandtoRoomare relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance.