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.
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.
- Node.js 22 or later
- pnpm or (npm / yarn)
- A Vercel account
- Vercel CLI installed for local Services testing and deployment
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:
Select Vercel when prompted.
Create a Vite React frontend in frontend/ :
The backend owns the WebSocket server. The frontend owns the browser UI.
Add a backend dev script:
Begin with the smallest useful version: one server process, one room map, and a full roster broadcast whenever someone joins or leaves.
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:
- A socket joins.
- The server records an id.
- 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.
normalizeRoomId keeps room names URL-safe and predictable. This template accepts lowercase letters, numbers, and hyphens, with lobby as the fallback.
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.
The room comes from the URL. /rooms/design-review connects to /server/ws/design-review; any other path falls back to 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.
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.
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
useEffectwrapper 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.
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.
There are two implementations:
MemoryPresenceStorefor single-process local development.RedisPresenceStorefor production and multi-instance development.
The memory store is intentionally boring: a Map of room id to last-seen timestamps.
This fallback means you can build and test the feature without Redis.
To set up Redis, run the following command:
When prompted, select Upstash for Redis.
Now, pull the development environment variables by running the following command:
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.
Joining writes the id with the current timestamp. Leaving removes it. Reading members first prunes stale ids, then returns the live roster.
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.
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:
On join or leave, the instance publishes to that room's channel. Every instance pattern-subscribes and re-broadcasts only the room that changed.
The sequence is:
- A user joins
/rooms/design-review. - That instance writes to
presence:online:design-review. - It publishes on
presence:changed:design-review. - 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 whenREDIS_URLis not set. - The complete
RedisPresenceStore, includingleave,heartbeat, and stale-member pruning. - The
createStorefactory 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.
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:
Run the backend and frontend in two terminals.
On another terminal run:
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.

Deploy your project with one command:
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.
- WebSockets on Vercel Functions
- Vercel Services
- Hono
@hono/node-ws- Redis sorted sets
- ioredis
- boring-avatars
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.