# Build Notion-style real-time presence with WebSockets on Vercel

**Author:** Anshuman Bhardwaj

---

Build a presence widget like the avatar faces at the top of a Notion page, which appear when a teammate opens the page and disappear when they leave. It runs on a [Hono](https://hono.dev/) WebSocket server, renders with React, deploys as a single Vercel project with [Services](https://vercel.com/docs/services), and uses Redis to keep each room consistent across Function instances.

In this guide, you'll start with the Hono Vercel template, add a React client, scope presence by room URL, then make the feature production-ready with Redis sorted sets, heartbeats, and pub/sub.

Deploy the template now, or read on for a deeper look at how it all works.

## Quick start with an AI coding agent

### Vercel plugin

Turn your agent into a Vercel expert with this [plugin](https://vercel.com/docs/agent-resources/vercel-plugin). It gives your coding agent current knowledge of the Vercel products this template uses. The plugin is optional; it is not required to use this template or for this guide.

`npx plugins add vercel/vercel-plugin`

## Prerequisites

- Node.js 22 or later
  
- [pnpm](https://pnpm.io/) or (npm / yarn)
  
- A [Vercel account](https://vercel.com/signup)
  
- [Vercel CLI](https://vercel.com/docs/cli) installed for local Services testing and deployment
  

## How it works

A WebSocket connection on Vercel is held by one Function instance for its lifetime. Broadcasting to sockets on that same instance is simple, but clients in the same room can land on different instances. Redis gives every instance a shared view of each room.

There are four moving parts:

- The Hono server accepts WebSocket connections at `/server/ws/:roomId`.
  
- The server keeps track of sockets connected to this Function instance.
  
- Redis stores one sorted set per room and publishes room change signals across instances.
  
- The React client reads the room from the URL, connects, reconnects, and renders the roster.
  

The final app has URLs like:

- `/rooms/lobby`
  
- `/rooms/design-review`
  
- `/rooms/launch-plan`
  

Each URL is its own presence room.

## Steps

### 1\. Create the project

Create a Hono backend at the project root:

`pnpm create hono hono-websockets-presence`

Select **Vercel** when prompted.

Create a Vite React frontend in `frontend/`:

`cd hono-websockets-presence pnpm create vite frontend --template react-ts cd frontend pnpm install pnpm add boring-avatars`

The backend owns the WebSocket server. The frontend owns the browser UI.

Add a backend dev script:

`{ "type": "module", "scripts": { "dev": "tsx watch src/server.ts" } }`

### 2\. Start with one in-memory room

Begin with the smallest useful version: one server process, one room map, and a full roster broadcast whenever someone joins or leaves.

`import "dotenv/config"; // Load .env before any module reads process.env. import { createServer } from "node:http"; import { createNodeWebSocket } from "@hono/node-ws"; import { Hono } from "hono"; import type { WSContext } from "hono/ws"; import { presence } from "./presence.js"; const app = new Hono(); const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); type SocketState = { roomId: string }; // Connections this instance currently holds. Each instance only broadcasts to // and heartbeats its own sockets; the shared roster for each room comes from Redis. const sockets = new Map<WSContext, SocketState>(); const localIds = new Map<string, Map<string, number>>(); function broadcast() { const members = [...sockets.values()] const message = JSON.stringify({ type: 'presence', count: members.length, members, }) for (const ws of sockets.keys()) ws.send(message) }` On open, the socket is registered. On the first `hello` frame, the browser's id is attached to that socket. On close, the socket is removed and the room is broadcast again. This is a simplified version of the presence feature: 1. A socket joins.     2. The server records an id.     3. The server sends the full roster to everyone.     ### 3\. Add the Hono WebSocket route The public URL shape is `/server/ws/:roomId`; the Hono route uses a wildcard and parses the last path segment so the WebSocket adapter matches the upgrade reliably. `app.get( '/server/ws/*', upgradeWebSocket((c) => { const roomId = normalizeRoomId(new URL(c.req.url).pathname.split('/').pop()) let id: string | undefined return { onOpen(_event, ws) { sockets.set(ws, { roomId }) }, async onMessage(event) { let data: unknown try { data = JSON.parse(String(event.data)) } catch { return } const msg = data as { type?: string; id?: string } if (msg.type === 'hello' && typeof msg.id === 'string' && id === undefined) { id = msg.id trackLocal(roomId, id) await presence.join(roomId, id) await broadcast(roomId) } }, async onClose(_event, ws) { sockets.delete(ws) if (id && untrackLocal(roomId, id)) { await presence.leave(roomId, id) } await broadcast(roomId) }, } }), )` `normalizeRoomId` keeps room names URL-safe and predictable. This template accepts lowercase letters, numbers, and hyphens, with `lobby` as the fallback. `function normalizeRoomId(value: string | undefined): string { const roomId = value?.toLowerCase() if (roomId && /^[a-z0-9-]{1,64}$/.test(roomId)) return roomId return 'lobby' }` The server broadcasts full rosters, not join/leave deltas. That makes reconnects simple: any client can miss a frame and still recover on the next roster. `src/index.ts` also has other helper functions, some of which are explained later on. You can see the full code on [GitHub](https://github.com/vercel-labs/hono-websockets-presence/blob/main/src/index.ts).

### 4\. Build the React client

The browser owns a per-tab client identity. `sessionStorage` is intentional: it survives reloads, but each tab still gets its own id, which makes local testing easy.

`function getClientId(): string { const KEY = 'presence:clientId' let id = sessionStorage.getItem(KEY) if (!id) { id = crypto.randomUUID() sessionStorage.setItem(KEY, id) } return id }`

The room comes from the URL. `/rooms/design-review` connects to `/server/ws/design-review`; any other path falls back to `lobby`.

`function getRoomId(): string { const [, prefix, room] = location.pathname.split('/') const candidate = prefix === 'rooms' ? room : undefined return candidate && /^[a-z0-9-]{1,64}$/.test(candidate) ? candidate : 'lobby' }` The connection sends `hello` as soon as the socket opens, keeps the socket warm with a lightweight ping, and reconnects with exponential backoff when the connection closes. ``const proto = location.protocol === 'https:' ? 'wss' : 'ws' socket = new WebSocket(`${proto}://${location.host}/server/ws/${roomId}`) socket.addEventListener('open', () => { setStatus('online') reconnectDelay = 1000 socket.send(JSON.stringify({ type: 'hello', id: selfId })) // Keep-alive so idle intermediaries don't drop the connection. pingTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'ping' })) } }, 25000) }) socket.addEventListener('close', () => { setStatus('offline') if (!stopped) { setTimeout(connect, reconnectDelay) reconnectDelay = Math.min(reconnectDelay * 2, 30000) } })`` The client stores the latest roster and computes the activity log locally. The first roster after each connection is seeded silently so the page does not show everyone as "joined" the moment you arrive. `function applyRoster(roster: string[]) { setMembers(roster) const next = new Set(roster) if (!seeded) { known = next seeded = true return } const fresh: LogEvent[] = [] for (const id of next) if (!known.has(id)) fresh.push({ id, kind: 'join', at: Date.now() }) for (const id of known) if (!next.has(id)) fresh.push({ id, kind: 'leave', at: Date.now() }) known = next setEvents((prev) => [...fresh.reverse(), ...prev].slice(0, 20)) }` For brevity, this guide does not inline every part of `frontend/src/Presence.tsx`. The [full file](https://github.com/vercel-labs/hono-websockets-presence/blob/main/frontend/src/Presence.tsx) also includes:

- React state for the live roster, connection status, and join/leave activity log.
  
- The `useEffect` wrapper that starts the socket connection and cleans it up on unmount.
  
- A keep-alive ping so idle intermediaries do not drop the socket.
  
- Avatar rendering with `boring-avatars`, including the blue ring for the current tab.
  
- The current-room label, status dot, empty activity state, and activity-list UI.
  

### 5\. Add room links

The important part is that each link changes the room in the URL, which changes the WebSocket endpoint the client opens. You can think of it as a separate document on Notion.

`import Presence from "./Presence"; export default function App() { return ( <main style={{ padding: 32, fontFamily: "system-ui, sans-serif", maxWidth: 720 }}> <h1>Real-time room presence with Hono & React</h1> <p style={{ color: "#4b5563", marginTop: -8, marginBottom: 24 }}> See who is viewing the same room, with live avatars and join/leave updates. </p> <nav style={{ display: "flex", gap: 12, marginBottom: 24 }}> <a href="/rooms/lobby">Lobby</a> <a href="/rooms/design-review">Design review</a> <a href="/rooms/launch-plan">Launch plan</a> </nav> <Presence /> </main> ); }`

At this point, the app is interesting enough to demo locally: open two tabs in the same room and the rosters match. Open a different room and it has its own roster.

### 6\. Move presence into a store

To keep the Hono route focused on socket lifecycle, not storage details, let’s refactor the code by putting presence behind a small interface.

`export interface PresenceStore { join(roomId: string, id: string): Promise<void> leave(roomId: string, id: string): Promise<void> heartbeat(roomId: string, ids: string[]): Promise<void> members(roomId: string): Promise<string[]> subscribe(onChange: (roomId: string) => void): void }` There are two implementations: - `MemoryPresenceStore` for single-process local development.    - `RedisPresenceStore` for production and multi-instance development.    The memory store is intentionally boring: a `Map` of room id to last-seen timestamps. `class MemoryPresenceStore implements PresenceStore { private rooms = new Map<string, Map<string, number>>() async join(roomId: string, id: string): Promise<void> { this.room(roomId).set(id, now()) this.notify(roomId) } async members(roomId: string): Promise<string[]> { this.prune(roomId) return [...this.room(roomId).keys()] } }` This fallback means you can build and test the feature without Redis. ### 7\. Add Redis for persistence To set up Redis, run the following command: `vercel link vercel integration add upstash` When prompted, select **Upstash for Redis**. Now, pull the development environment variables by running the following command: `vercel env pull` This command creates a `.env.local` file with the `REDIS_URL` environment variable. To make this app work on serverless instances, let’s use Redis as globally persistent storage. Redis stores one sorted set per room. The member is the browser id. The score is the last-seen timestamp. ``const STALE_MS = 30_000 const KEY_PREFIX = 'presence:online:' function key(roomId: string): string { return `${KEY_PREFIX}${roomId}` }`` Joining writes the id with the current timestamp. Leaving removes it. Reading members first prunes stale ids, then returns the live roster. `class RedisPresenceStore implements PresenceStore { constructor( private redis: Redis, private sub: Redis, ) {} async join(roomId: string, id: string): Promise<void> { await this.redis.zadd(key(roomId), now(), id) await this.redis.publish(channel(roomId), id) } async leave(roomId: string, id: string): Promise<void> { await this.redis.zrem(key(roomId), id) await this.redis.publish(channel(roomId), id) } async heartbeat(roomId: string, ids: string[]): Promise<void> { if (ids.length === 0) return const ts = now() const args = ids.flatMap((id) => [ts, id]) await this.redis.zadd(key(roomId), ...args) } async members(roomId: string): Promise<string[]> { await this.redis.zremrangebyscore(key(roomId), 0, now() - STALE_MS) return await this.redis.zrange(key(roomId), 0, -1) } }` The heartbeat is what makes presence self-healing. Clean closes are nice, but you cannot rely on them. A laptop can sleep, a network can drop, or a Function instance can be recycled. If a connection stops refreshing its timestamp, it falls out of the room after `STALE_MS`. In the final server, the heartbeat timer starts lazily when the first local socket identity is tracked. That keeps module import side effects small while still refreshing every connected id. `function trackLocal(roomId: string, id: string): void { ensureHeartbeatTimer(); let room = localIds.get(roomId); if (!room) { room = new Map(); localIds.set(roomId, room); } room.set(id, (room.get(id) ?? 0) + 1); } function startHeartbeatTimer(): void { setInterval(() => { void (async () => { for (const [roomId, ids] of localIds) { await presence.heartbeat(roomId, [...ids.keys()]); await broadcast(roomId); } })(); }, 5_000); }` The server tracks local ids with reference counts, not a plain set. That matters when the same tab identity briefly has two sockets during a reload or reconnect. One socket closing should not stop sending an id that is still connected locally. ### 8\. Add Redis pub/sub for cross-instance updates Heartbeats fix stale entries, but they do not make another instance update instantly. Pub/sub handles that. Every room has a change channel: ``const CHANNEL_PREFIX = 'presence:changed:' const CHANNEL_PATTERN = `${CHANNEL_PREFIX}*` function channel(roomId: string): string { return `${CHANNEL_PREFIX}${roomId}` }`` On join or leave, the instance publishes to that room's channel. Every instance pattern-subscribes and re-broadcasts only the room that changed. `subscribe(onChange: (roomId: string) => void): void { void this.sub.psubscribe(CHANNEL_PATTERN) this.sub.on('pmessage', (_pattern, matchedChannel) => { const roomId = matchedChannel.slice(CHANNEL_PREFIX.length) if (roomId) onChange(roomId) }) }` The sequence is: 1. A user joins `/rooms/design-review`.     2. That instance writes to `presence:online:design-review`.     3. It publishes on `presence:changed:design-review`.     4. Every instance re-reads and broadcasts only `design-review`.     No polling is needed for ordinary joins and leaves. The timer remains only for heartbeat refresh and stale cleanup. This guide is focusing on the main pieces for WebSockets, it does not inline every helper from `src/presence.ts`. The [full file](https://github.com/vercel-labs/hono-websockets-presence/blob/main/src/presence.ts) also includes:

- The complete `MemoryPresenceStore`, which keeps room rosters in process when `REDIS_URL` is not set.
  
- The complete `RedisPresenceStore`, including `leave`, `heartbeat`, and stale-member pruning.
  
- The `createStore` factory that chooses Redis in configured environments and memory otherwise.
  
- The warning shown during local development when the server is running without Redis.
  

### 9\. Combine frontend and backend with Vercel Services

[Vercel Services](https://vercel.com/docs/services) lets the Vite frontend and Hono server deploy as one project. The frontend owns `/` and the WebSocket server owns `/server/*`.

The backend service entrypoint is intentionally separate from the Hono app setup. `src/index.ts` creates the Hono app, creates a Node HTTP server, and injects WebSocket upgrade handling into that server. `src/server.ts` exports that server for Vercel, and only calls `listen()` during local development.

`import { listen, server } from "./index.js"; if (!process.env.VERCEL) { listen(); } export default server;`

`{ "services": { "web": { "root": "frontend/", "framework": "vite", "rewrites": [{ "source": "/(.*)", "destination": "/index.html" } ] }, "server": { "root": ".", "framework": "node", "entrypoint": "src/server.ts", "devCommand": "pnpm dev" } }, "rewrites": [ { "source": "/server/(.*)", "destination": { "service": "server" } }, { "source": "/(.*)", "destination": { "service": "web" } } ] }` Because the backend service is mounted at `/server`, the WebSocket route is `/server/ws/:roomId`. Vercel does not strip the prefix before the request reaches Hono. For local Vite development, proxy `/server` to the backend: `import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], server: { proxy: { "/server": { target: "http://localhost:3000", ws: true, changeOrigin: true, }, }, }, });` ### 10\. Run locally Run the local development command: `vercel dev -L` This command runs your frontend and backend app as Services, just how they would run on the Vercel platform. Open `http://localhost:3000` in 3 separate browser windows. You should see: - Tabs in the same room share one roster.    - Tabs in different rooms do not see each other.    - Reloading a tab keeps the same avatar.    - Closing a tab removes it from the roster.    ### 11\. Deploy and test live Deploy your project with one command: `vercel --prod` Open the deployment on two devices in the same room. The room roster should update over `wss://` with no CORS configuration because the frontend and backend share one origin. ## Troubleshooting ### The socket will not connect locally **Cause**: The frontend is trying to open `/server/ws/:roomId`, but the backend is not running or the Vite proxy is missing WebSocket support. **Fix**: Start the backend with `pnpm dev`, confirm it is listening on port 3000, and make sure `frontend/vite.config.ts` proxies `/server` with `ws: true`. ### Everyone appears in every room **Cause**: The server is using one global key or one global socket set without checking the room id. **Fix**: Make sure Redis keys include the room id (`presence:online:<roomId>`) and `broadcast(roomId)` sends only to sockets whose state has that room id. ### A closed tab stays visible for a while **Cause**: The socket did not close cleanly. Presence can only remove it once its heartbeat score goes stale. **Fix**: This is expected. The template prunes entries after `STALE_MS` (30 seconds). Lower the timeout if the room needs faster cleanup, but remember that lower values mean more heartbeat sensitivity. ### Different devices do not agree about who is online **Cause**: The app is running without Redis, or a deployment environment does not have `REDIS_URL`. **Fix**: Confirm `REDIS_URL` is set for the Vercel project and restart local development after pulling environment variables. ### Reloading creates join and leave noise **Cause**: The client is generating a fresh id on every render or every reload. **Fix**: Store the id in `sessionStorage` and read it through a stable `useRef`, so the tab keeps the same identity while it is open. ## Related resources - [WebSockets on Vercel Functions](https://vercel.com/docs/functions/websockets)
  
- [Vercel Services](https://vercel.com/docs/services)
  
- [Hono](https://hono.dev/)
  
- [`@hono/node-ws`](https://github.com/honojs/middleware/tree/main/packages/node-ws)
  
- [Redis sorted sets](https://redis.io/docs/latest/develop/data-types/sorted-sets/)
  
- [ioredis](https://github.com/redis/ioredis)
  
- [boring-avatars](https://github.com/boringdesigners/boring-avatars)

---

[View full KB sitemap](/kb/sitemap.md)
