Skip to content
Docs

Build a real-time chat app with WebSockets on Vercel

Build and deploy a single-room messaging app in Next.js with real-time chat, typing indicators, and live online user counts using WebSockets and Redis.

14 min read
Last updated June 25, 2026

Build a global chat room that shows a live user count, who's typing, and messages in real time. It runs on Next.js (App Router) with WebSockets on Vercel Functions, and shares state across instances using Upstash 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.

Next.js real-time chat app with WebSockets

A single-room chat app with real-time typing indicators, messages, and chat persistence.

Deploy Template
AI Assistance

I want to build a real-time chat app using the Next.js real-time WebSocket chat template. Read the setup instructions at https://agent-resources.dev/nextjs-websocket-chat-template.md and follow them. They will cover deploying the template, building on WebSockets on Vercel Functions, how everything works overall, and more.

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

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

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

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

From the project root, add the runtime packages:

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

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

app/api/ws/route.ts
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.

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.

lib/redis.ts
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.

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.

lib/chat.ts
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.
lib/chat.ts
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.
lib/chat.ts
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. The full file 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.

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:

lib/chat.ts
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:

lib/chat.ts
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.

The client splits into identity (ChatRoom) and the live room (Chat). ChatRoom owns a per-tab clientId and display name in sessionStorage.

app/components/ChatRoom.tsx
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.

app/components/Chat.tsx
// (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.

To get started, clone the complete template, 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 :

Terminal
vercel dev

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

Real-time chat app sending messages and live typing indicator

Deploy to production and open it in two browser tabs.

Terminal
vercel --prod

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.

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.

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

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.

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.

localStorage is shared across every tab in a browser, so two tabs would share one identity and avatar — and overwrite each other's stored name. sessionStorage is per-tab and survives reloads, which makes each tab a distinct user and lets you test multi-user behavior in one browser.

Yes, as a single-instance deployment. With no credentials, the Redis client returns null; messages and presence still work within a single Function instance via local broadcast, but there's no history and no cross-instance delivery. That's the intended fallback for local development before provisioning Redis.

Any Redis works. The app expects a Redis URL to connect to. Apart from that, the keys, streams, and sorted sets are all standard Redis. So, they will work the same everywhere.

The stream does double duty: it's both the cross-instance relay and the durable history a new client replays on join. Pub/sub would deliver live messages but keep no log, so you'd need a second structure for history anyway. Blocking on the stream gets real-time delivery and a replayable backlog from one place.

A blocking XREAD parks its connection until an entry arrives, and any other command issued on that same connection queues behind it. So, the reader needs its own connection (redis.duplicate()) while writes and presence run on the main one. It's one client library and one URL, but two TCP connections per instance. At high fan-out, watch your Redis provider's connection cap, since each instance holds two.

Was this helpful?

supported.