Documentation

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/ws endpoint. A single app turns it on with webSocket: 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.

Need it to stick around? Use notifications

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:

plugins/acme-chat/src/ws/chat.channel.ts
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:

plugins/acme-chat/src/views/visitors-badge.tsx
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

MethodBehavior
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/ws socket 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 ws server 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_URL set, sendToUser, broadcast and toRoom are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance.