Skip to content
Docs

Build Notion-style real-time presence on Vercel

Build the avatar faces that appear when a teammate opens a page and vanish when they leave. Powered by a Hono WebSocket server with React, deployed as a single Vercel project with Services.

11 min read
Last updated June 25, 2026

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 WebSocket server, renders with React, deploys as a single Vercel project with 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.

AI Assistance

I want to build a room-based presence app using the Hono WebSockets presence template. Read the setup instructions at https://agent-resources.dev/hono-websockets-presence-template.md and follow them. They cover deploying the template with Redis, running it locally, building on Hono WebSockets and Vercel Services, and understanding how room presence works overall.

Turn your agent into a Vercel expert with this 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.

Terminal
npx plugins add vercel/vercel-plugin

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.

Create a Hono backend at the project root:

Terminal
pnpm create hono hono-websockets-presence

Select Vercel when prompted.

Create a Vite React frontend in frontend/ :

Terminal
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:

package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts"
}
}

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

src/index.ts
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.

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.

src/index.ts
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.

src/index.ts
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.

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.

frontend/src/Presence.tsx
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.

frontend/src/Presence.tsx
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.

frontend/src/Presence.tsx
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.

frontend/src/Presence.tsx
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 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.

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.

frontend/src/App.tsx
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.

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

src/presence.ts
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.

src/presence.ts
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.

To set up Redis, run the following command:

Terminal
vercel link
vercel integration add upstash

When prompted, select Upstash for Redis.

Now, pull the development environment variables by running the following command:

Terminal
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.

src/presence.ts
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.

src/presence.ts
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.

src/index.ts
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.

Heartbeats fix stale entries, but they do not make another instance update instantly. Pub/sub handles that.

Every room has a change channel:

src/presence.ts
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.

src/presence.ts
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 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.

Vercel Services lets the Vite frontend and Hono server deploy as one project. The frontend owns / and the WebSocket server owns /server.

vercel.json
{
"experimentalServices": {
"web": {
"entrypoint": "frontend",
"routePrefix": "/",
"framework": "vite"
},
"server": {
"entrypoint": "src/index.ts",
"routePrefix": "/server"
}
}
}

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:

frontend/vite.config.ts
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,
},
},
},
});

Run the backend and frontend in two terminals.

Terminal
pnpm install
pnpm dev

On another terminal run:

Terminal
cd frontend
pnpm install
pnpm dev

Open http://localhost:5173/rooms/lobby in two tabs. Then open http://localhost:5173/rooms/design-review in a third tab.

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.
CleanShot 2026-06-25 at 10.00.16@2x.png
Presence app showing avatars and room activity

Deploy your project with one command:

Terminal
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.

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.

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.

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.

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.

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.

No. The memory store is enough for one local backend process. Redis is needed when room presence must be shared across Function instances, devices, or production deployments.

sessionStorage is per-tab and survives reloads. That means each tab behaves like a separate visitor, while a reload keeps the same avatar.

The same shape works for document viewers, admin dashboards, live workshops, issue pages, and collaborative editors.

Yes. Validate the user before calling presence.join(roomId, id). You can check a cookie or token during the WebSocket flow, then close the socket if the user is not allowed in that room.

A full roster is easier to make correct. A newly connected client immediately gets the complete state, and a client that misses one frame can recover on the next broadcast. You can implement custom logic to further optimize this template.

Was this helpful?

supported.