# Build a real-time chat app with WebSockets on Vercel

**Author:** Anshuman Bhardwaj

---

Build a global chat room that shows a live user count, who's typing, and messages in real time. It runs on [Next.js](https://nextjs.org/) (App Router) with [WebSockets on Vercel Functions](https://vercel.com/docs/functions/websockets), and shares state across instances using [Upstash Redis](https://vercel.com/marketplace/redis) so the room stays consistent no matter which Function instance a client lands on. There is no separate socket server to run because the WebSocket endpoint is a Function.

In this guide, you'll start with a Next.js app, add a WebSocket upgrade route to it, integrate Redis to persist state, and add a hub that tracks user presence and relays messages.

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 20 or later
  
- [pnpm](https://pnpm.io/) (or npm/yarn)
  
- A [Vercel account](https://vercel.com/signup)
  
- [Vercel CLI](https://vercel.com/docs/cli) 54.14.2 or later installed
  

## How it works

A WebSocket connection on Vercel is pinned to one Function instance for its lifetime. That makes intra-instance delivery trivial (broadcast to the local sockets) but also means two clients can land on different instances and never see each other directly.

There are four moving parts:

- The WebSocket route (`app/api/ws/route.ts`) accepts socket connections.
  
- The chat hub (`lib/chat.ts`) keeps track of sockets connected to this Function instance.
  
- Redis shares room events and presence across Function instances.
  
- The React client connects, reconnects, sends messages, and renders the room.
  

## Steps

### 1\. Create the project

Scaffold a new Next.js app with `create-next-app`.

`pnpm create next-app@latest nextjs-websocket-chat-app --yes cd nextjs-websocket-chat-app`

`--yes` takes the default answers (TypeScript, App Router, Tailwind), so you land in a runnable project with `app/` already in place. The rest of the guide adds files under `app/` and `lib/`.

#### Configure Redis

For this project, you’ll use Redis to persist chat channels and messages. 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.

### 2\. Install the dependencies

From the project root, add the runtime packages:

`pnpm add @vercel/functions boring-avatars ioredis ws pnpm add -D @types/ws`

`@vercel/functions` provides `experimental_upgradeWebSocket` and uses the `ws` (the Node WebSocket implementation). `ioredis` is the Redis client used both for writes and, on a duplicated connection, for the blocking reader. `boring-avatars` renders a deterministic avatar from a string seed.

> The `experimental_upgradeWebSocket` API is experimental and will change in the future. This will only work on Vercel and shouldn’t be used in place of the native Node.js APIs for WebSocket connection upgrade. For more information, see [docs](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#experimental_upgradewebsocket-limits-and-usage).

### 3\. Add the WebSocket upgrade route

This route handler performs the upgrade and dispatches each incoming frame to the hub. The handler is deliberately thin.

``import { experimental_upgradeWebSocket, type WebSocketData, } from "@vercel/functions"; import { join, postMessage, postTyping, register, unregister, } from "@/lib/chat"; type ClientEvent = | { type: "join"; clientId: string; name: string } | { type: "message"; text: string } | { type: "typing" }; export function GET() { return experimental_upgradeWebSocket((ws) => { // Register and attach listeners synchronously (no await before this), so a // `join` frame sent immediately on open is never dropped. register(ws); ws.on("message", (data: WebSocketData) => { let event: ClientEvent; try { event = JSON.parse(data.toString()); } catch { return; // ignore non-JSON frames } switch (event.type) { case "join": void join(ws, event.clientId, event.name); break; case "message": void postMessage(ws, event.text); break; case "typing": void postTyping(ws); break; } }); const close = () => void unregister(ws); ws.on("close", close); ws.on("error", close); }); }``

The order matters here: `register(ws)` and `ws.on('message', ...)` run in the same synchronous tick, with no `await` before them. A client sends its `join` frame the moment the socket opens; if you `await` anything before attaching the `message` listener, that first frame is dropped. `'error'` reuses the same `close` handler because errors may also be followed by close; make `unregister` idempotent if both handlers call it.

### 4\. Create the Redis client

With Redis, separate Function instances can share the same chat room. Without it, the app still works as a single-instance local chat.

One `ioredis` client serves the whole app. It connects over the native Redis protocol and returns `null` when not configured, so the app can run with no Redis. The hub will later call `redis.duplicate()` on it for the dedicated blocking-read connection.

``import Redis from "ioredis"; function createRedis(): Redis | null { const url = process.env.REDIS_URL; if (!url) { if (process.env.NODE_ENV !== "production") { console.warn("[chat] No Redis URL found. Running local-only. …"); } return null; } // TLS is implied by the `rediss://` scheme. `maxRetriesPerRequest: null` keeps // a long-lived blocking read from being failed by the per-command retry cap; // the hub re-issues XREAD from the saved cursor on error instead. return new Redis(url, { maxRetriesPerRequest: null, retryStrategy: (times) => Math.min(times * 200, 5_000), }); } export const redis = createRedis(); // Turn ioredis's flat `[field, val, field, val, …]` entry into `{ field: val }`. export function fieldsToObject(flat: string[]): Record<string, string> { const obj: Record<string, string> = {}; for (let i = 0; i + 1 < flat.length; i += 2) obj[flat[i]] = flat[i + 1]; return obj; }`` `maxRetriesPerRequest: null` is what lets a single `XREAD` block for seconds without ioredis's per-command retry cap killing it. `fieldsToObject` normalizes the flat field arrays ioredis returns from `XREAD`/`XREVRANGE` into a record the hub can read. ### 5\. Build the hub The hub is the server-side room manager. It knows which sockets are connected to this Function instance, sends events to those sockets, and uses Redis when the event needs to reach other instances too. Each Function instance has its own hub. That hub can talk directly to the sockets connected to the same instance. Redis is used only when state needs to be shared with other instances. Start with the shared message types, Redis key names, and a few tuning values. `export type Message = { id: string; kind: "chat" | "system"; clientId: string; name: string; text: string; ts: number; }; export type User = { clientId: string; name: string }; const CONNS_KEY = "chat:conns"; // ZSET connectionId -> lastSeen ms const META_KEY = "chat:connmeta"; // HASH connectionId -> { clientId, name } const MSG_STREAM = "chat:messages"; // durable message log + cross-instance relay const TYPING_STREAM = "chat:typing"; // transient typing relay const CONN_TTL_MS = 30_000; // a connection is gone if not refreshed within this window const HEARTBEAT_MS = 10_000; // how often we refresh our local connections const PRESENCE_MS = 3_000; // how often we recompute + broadcast presence const BLOCK_MS = 5_000; // XREAD BLOCK timeout. Wakes the loop to observe shutdown const MSG_MAXLEN = 200; // cap the message stream const TYPING_MAXLEN = 50; // cap the typing stream const HISTORY = 50; // messages replayed to a newly-joined client` There are two identities to keep straight: - `clientId` means "this user in this browser tab."    - `connectionId` means "this specific WebSocket connection."    That distinction matters because a user can reconnect or open more than one tab. The server tracks each socket separately, then groups sockets by `clientId` when it calculates the online user count. A socket connects in two phases: - `register` records the socket immediately. This must stay synchronous so the server is ready for the first `join` frame the browser sends as soon as the socket opens.    - `join` attaches the user's identity, sends recent history to that socket, updates presence, and posts a system message if this is the user's first active connection.    ``export function register(ws: WebSocket): void { hub.conns.set(ws, { connectionId: crypto.randomUUID(), clientId: "", name: "", }); void startStream(); } export async function join( ws: WebSocket, clientId: string, name: string, ): Promise<void> { const conn = hub.conns.get(ws); if (!conn) return; const cid = clamp(clientId, 64) || crypto.randomUUID(); const nm = clamp(name, 40) || "anon"; // Only announce if this user isn't already connected elsewhere (other tab or // instance), so reconnects/extra tabs don't spam "joined". const alreadyOnline = await isOnline(cid); conn.clientId = cid; conn.name = nm; await touch(conn); send(ws, { type: "history", messages: await loadHistory() }); await broadcastPresence(true); if (!alreadyOnline) await postSystem(`${nm} joined`); } export async function unregister(ws: WebSocket): Promise<void> { const conn = hub.conns.get(ws); hub.conns.delete(ws); if (redis && conn?.connectionId) { try { await redis.zrem(CONNS_KEY, conn.connectionId); await redis.hdel(META_KEY, conn.connectionId); } catch (err) { console.error("[chat] unregister cleanup failed", err); } } await broadcastPresence(true); // Announce a leave only once this user has no remaining connections anywhere. if (conn?.clientId && !(await isOnline(conn.clientId))) { await postSystem(`${conn.name} left`); } if (hub.conns.size === 0) stopStream(); }`` `unregister` does the reverse when a socket closes: it removes the connection, broadcasts the updated presence list, and only posts a "left" message if that user has no other active connections. Sending a message has two steps: - First, the hub broadcasts to sockets connected to this same Function instance. That makes local delivery immediate.    - Then, if Redis is configured, the hub writes the message to Redis. Other Function instances are listening for those Redis entries and will forward the message to their own local sockets.    `async function publishMessage(message: Message): Promise<void> { broadcast({ type: "message", message }); if (!redis) return; await redis.xadd( MSG_STREAM, "MAXLEN", "~", MSG_MAXLEN, // trim to ~200 entries "*", "d", JSON.stringify(message), "o", hub.instanceId, ); }` Each entry carries `d` (the JSON payload) and `o` (the originating instance's id). The `o` tag is how the reader avoids echoing an instance's own messages back to it. `MAXLEN ~ MSG_MAXLEN` trims the stream to approximately 200 entries, which is cheaper than exact trimming and fine for a capped log. Because this guide is following the main flow, it does not inline every helper from [`lib/chat.ts`](https://github.com/vercel-labs/nextjs-websocket-chat-app/blob/main/lib/chat.ts). The [full file](https://github.com/vercel-labs/nextjs-websocket-chat-app/blob/main/lib/chat.ts) also includes:

- `postTyping`, which relays transient typing events locally and through Redis.
  
- `loadHistory`, which reads the newest messages from Redis for a newly joined socket.
  
- `touch`, `computePresence`, `isOnline`, and `broadcastPresence`, which keep the online count and user list accurate across instances.
  
- `heartbeat`, which refreshes the last-seen time for local sockets so active users do not expire.
  

### 6\. Listen to messages from all instances

Each Function instance already sends messages to the sockets connected to it. We need Redis only when another instance writes a message or typing event. To receive those cross-instance events, each active instance keeps one Redis connection waiting for new stream entries. When Redis gets a new entry, the wait ends immediately, and the instance forwards that event to its own local sockets.

This gives us realtime delivery without polling:

1. A user sends a message.
   
2. Their Function instance broadcasts it locally.
   
3. That instance also writes the event to Redis.
   
4. Other Function instances wake up, read the event, and broadcast it locally.
   

The loop below is that listener implemented:

`async function runReadLoop(): Promise<void> { const client = hub.streamClient; if (!client) return; while (hub.streaming) { try { const res = (await client.xread( "BLOCK", BLOCK_MS, "STREAMS", MSG_STREAM, TYPING_STREAM, hub.lastMsgId, hub.lastTypingId, )) as Array<[string, Array<[string, string[]]>]> | null; if (!res) continue; // BLOCK timed out with no new entries for (const [key, entries] of res) { for (const [id, flat] of entries) { const fields = fieldsToObject(flat) as { d: string; o: string }; if (key === MSG_STREAM) { hub.lastMsgId = id; if (fields.o === hub.instanceId) continue; // our own, already delivered const message = parseJson<Message>(fields.d); if (message) broadcast({ type: "message", message }); } else if (key === TYPING_STREAM) { hub.lastTypingId = id; if (fields.o === hub.instanceId) continue; const typing = parseJson<User>(fields.d); if (typing) broadcast({ type: "typing", clientId: typing.clientId, name: typing.name, }); } } } } catch (err) { if (!hub.streaming) break; console.error("[chat] read loop failed", err); await sleep(1_000); // brief backoff; ioredis reconnects the socket under us } } }` Here’s how this works: - `XREAD BLOCK` means "wait until Redis has something new," not "check every few seconds."    - The timeout lets the loop wake up occasionally so it can shut down cleanly.    - Each event includes the originating instance id, so an instance skips events it already delivered locally.    - The cursor values (`lastMsgId` and `lastTypingId`) remember where this instance left off in each stream.    The loop's lifecycle is bounded by connections: ``async function startStream(): Promise<void> { if (!hub.heartbeat) hub.heartbeat = setInterval(() => void heartbeat(), HEARTBEAT_MS); if (!hub.presence) hub.presence = setInterval(() => void broadcastPresence(), PRESENCE_MS); if (hub.streaming) return; // reader already running if (!redis) return; // no Redis. Single-instance fallback (timers still run) // A blocking XREAD monopolizes its connection, so the reader runs on its own // duplicated connection. Claim it synchronously (no await before this) so a // second concurrent register() bails at the `hub.streaming` guard above. hub.streamClient = redis.duplicate(); hub.streaming = true; // Start the cursors at the current tail so we only pick up *new* entries. for (const [key, field] of [ [MSG_STREAM, "lastMsgId"], [TYPING_STREAM, "lastTypingId"], ] as const) { try { const tail = await redis.xrevrange(key, "+", "-", "COUNT", 1); hub[field] = tail[0]?.[0] ?? "0-0"; } catch { hub[field] = "0-0"; } } void runReadLoop(); } function stopStream(): void { hub.streaming = false; if (hub.streamClient) { void hub.streamClient.quit().catch(() => {}); hub.streamClient = null; } if (hub.heartbeat) { clearInterval(hub.heartbeat); hub.heartbeat = null; } if (hub.presence) { clearInterval(hub.presence); hub.presence = null; } }`` `startStream` runs on the first connection and is idempotent: the `hub.streaming` guard, set synchronously before any `await`, stops a second concurrent connection from opening a duplicate reader. It seeds the cursors from each stream's tail with `XREVRANGE` so a fresh instance only relays entries created after it started (history is handled separately, on join). `stopStream` runs from `unregister` once the last local connection closes, quitting the TCP client and clearing the timers so an idle instance holds nothing open. Presence stays on a timer rather than the stream because expiring a dead connection is inherently time-based: a client that drops without a clean close can only be detected by its sorted-set score going stale past `CONN_TTL_MS`. The `PRESENCE_MS` recompute prunes those and rebroadcasts the roster; the `HEARTBEAT_MS` timer refreshes the score of every local connection so live ones never expire. ### 7\. Build the client The client splits into identity (`ChatRoom`) and the live room (`Chat`). `ChatRoom` owns a per-tab `clientId` and display name in `sessionStorage`. `const ID_KEY = "chat:clientId"; const NAME_KEY = "chat:name"; let id = sessionStorage.getItem(ID_KEY); if (!id) { id = crypto.randomUUID(); sessionStorage.setItem(ID_KEY, id); }` `sessionStorage` (not `localStorage`) is the deliberate choice here because it makes each browser tab its own user. So, you can test multi-user behavior by opening two tabs, while still surviving a reload. When no name is stored, the join form is prefilled with a generated suggestion like `"Swift Otter"`. `Chat` opens the socket, sends `join` on open, reconnects with backoff, and handles the four server events. ``// (connection + reconnect) function wsUrl(): string { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; return `${protocol}://${window.location.host}/api/ws`; } // inside connect(): socket.addEventListener("open", () => { reconnectDelay = 1000; setStatus("online"); socket.send(JSON.stringify({ type: "join", clientId, name })); }); // Connections close when the Function reaches its max duration, so reconnect // with exponential backoff and re-send our join on open. socket.addEventListener("close", () => { if (cancelled) return; setStatus("offline"); reconnectTimer = setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, 30000); });`` A WebSocket on a Function closes when the Function hits its max duration, so the client will be disconnected periodically and must reconnect (backoff from 1s, doubling to a 30s ceiling) and re-send `join`. Outbound, the client sends `message` on submit and a `typing` frame throttled to once per `TYPING_THROTTLE_MS` (2000ms) while editing. The online count uses `status === 'online' ? Math.max(count, 1) : count`, so a connected user never sees "0 people online" before the first presence frame arrives. And a typing badge is cleared two ways: its own `TYPING_CLEAR_MS` (3000ms) timer, and immediately when that sender's message arrives. ### 8\. Run locally To get started, clone the [complete template](https://github.com/vercel-labs/nextjs-websocket-chat-app), which has the chat UI implemented.

When developing a Next.js app that uses `experimental_upgradeWebSocket()` locally, you must run the development server using `vercel dev` instead of `next dev` :

`vercel dev`

This will start your app on `http://localhost:3000`:

### 9\. Deploy and test live

Deploy to production and open it in two browser tabs.

`vercel --prod`

## Troubleshooting

### The socket won't connect locally

**Cause:** WebSocket upgrades using the `experimental_upgradeWebSocket` API require the Vercel runtime to inject the upgrade handler. Plain `next dev` doesn't provide it at all, and local `vercel dev` may not service the upgrade depending on your CLI version.

**Fix:** Upgrade to the latest Vercel CLI version and verify the code with `vercel dev`. When possible, you should handle WebSocket connections using native Node.js APIs instead. For more information, see [docs](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#experimental_upgradewebsocket-limits-and-usage).

### Messages from another tab or device don't appear

**Cause:** Within one instance, local broadcast covers everything, so this points at the cross-instance path. Without `REDIS_URL`, `newStreamClient()` returns `null`, `startStream` skips the reader, and instances never see each other's entries.

**Fix:** Confirm `REDIS_URL` is set in the deployment's environment, then re-run `vercel env pull` and `vercel dev`.

### The connection drops and reconnects every minute or so

**Cause:** This is expected. A WebSocket lives only as long as its Function instance, which closes at the max duration. The client reconnects with backoff and re-sends `join`.

**Fix:** You can increase the function’s [max duration](https://vercel.com/docs/functions/configuring-functions/duration). If it doesn't recover, check that the `close` handler's reconnect timer isn't being cancelled (the `cancelled` flag is only set on component unmount).

### The build fails resolving `ioredis`, `ws`, or Node built-ins

**Cause:** Next.js is trying to bundle a package that depends on Node.js built-ins (`net`, `tls`) instead of requiring it at runtime.

**Fix:** Add the package to `serverExternalPackages` in `next.config.ts` and rebuild.

### A typing indicator lingers after the message arrives

**Cause:** The `message` handler isn't clearing the sender from the typing map, so the badge only clears on its 3-second timer.

**Fix:** In the `message` handler, delete the sender's pending timer and remove the entry keyed by `message.clientId`.

## Related resources

- [WebSockets on Vercel Functions](https://vercel.com/docs/functions/websockets)
  
- [`@vercel/functions`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#experimental_upgradewebsocket) [reference](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#experimental_upgradewebsocket)
  
- [Redis commands reference](https://redis.io/docs/latest/commands)
  
- [ioredis GitHub repository](https://github.com/redis/ioredis)
  
- [Function max duration](https://vercel.com/docs/functions/limitations#max-duration)

---

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