Vercel Can Do WebSockets Now. Here's Where a Chat Room Still Breaks It.
Published
15 Aug 2026
I built a team chat feature on a Vercel project this year and it worked perfectly in every demo. Then a conversation ran past five minutes. The socket would drop, quietly reconnect, and whoever had been mid-scroll would find the last few messages missing until the client caught up. Nothing was broken. Vercel does support WebSockets now. I'd just assumed "supports WebSockets" meant "holds a connection open for as long as the tab is open," and that turned out to be the wrong assumption.
Vercel finally shipped native WebSockets
In June 2026, Vercel put WebSocket support into public beta directly on Vercel Functions: RFC 6455 upgrade support built in, with ws, Socket.IO, FastAPI's WebSockets, python-socketio, Express, Hono, Nitro, and Nuxt all listed as supported. You accept the upgrade inside a normal Function. No separate WebSocket server, no separate deploy target, no noServer gymnastics on someone else's platform.
Under the hood it's still an invocation. The instance that accepts the handshake stays pinned to that one connection for as long as it's open, running on Fluid Compute, billed with Active CPU pricing, which only charges for the time you're actually processing a message, not the time the socket sits idle between them. For a chat app, where most of a connection's life is silence between messages, that billing model is genuinely a good deal.
The connection is still a function, and functions have a ceiling
Here's the part that caught me out. A WebSocket connection inherits the exact same duration limit as any other Function invocation, plan for plan:
| Plan | Default duration | Maximum |
|---|---|---|
| Hobby | 300s (5 min) | 300s (5 min) |
| Pro | 300s (5 min) | 800s, extended beta up to 1800s (30 min) |
| Enterprise | 300s (5 min) | 800s, extended beta up to 1800s (30 min) |
Every one of those is a hard stop, not a target to aim under. When the ceiling hits, Vercel closes the socket, and your client has to reconnect, with no guarantee the reconnect lands on the same instance. On Hobby and Pro's default 5-minute ceiling, any conversation that runs longer than that gets disconnected and reconnected on a timer, regardless of whether anyone's actively typing. The extended beta ceiling on Pro and Enterprise pushes that out to 30 minutes, but it's still a forced reconnect on a schedule, not a connection that stays open as long as the tab is open.
Where this genuinely works: one bounded exchange
The case native WebSockets are good at is a single client holding one connection to your backend for a request that naturally finishes inside the ceiling: an AI assistant streaming its reply token by token, for instance, which is one of the use cases Vercel calls out directly.
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (socket) => { socket.on('message', async (raw) => { const stream = await streamAssistantReply({ prompt: raw.toString() });
for await (const token of stream) { socket.send(token); }
socket.send(JSON.stringify({ type: 'done' })); });});One user, one connection, no need to fan a message out to anyone else, and the whole exchange is over long before any duration ceiling matters. That's the shape native WebSockets on Vercel were built for, and for that shape, they're the simplest option available: no extra service, no extra bill, one platform.
Where a real chat room breaks the model
A group chat is a different shape. Three things it needs, that a single pinned socket doesn't give you:
Fan-out across users. Each pinned instance only knows about the connections it's holding — there's no built-in cross-instance fan-out. There's no built-in way for a message that arrives on the instance handling user A to reach users B and C, who are sitting on completely different instances. Vercel's own guidance for sharing state across connections is to add Redis from the Marketplace and build the pub/sub layer yourself. That's a second managed service, wired in by hand, which is the exact amount of extra infrastructure native support was supposed to save you from.
Reconnect churn at scale. A five-person conversation running for an afternoon on Pro's default 5-minute ceiling generates dozens of forced reconnects per participant, each one a fresh handshake, a fresh instance, and a real chance the client briefly renders an empty thread before your reconnection logic replays history. The 30-minute extended beta ceiling pushes the problem further out. It doesn't remove it.
No presence. Native WebSocket support gives you a channel to send bytes down and nothing more. Knowing who's currently online, and reliably noticing when someone's tab closes without a clean disconnect, is something you build: heartbeats, timeouts, and somewhere to store the current member list that survives past any single function instance, because sibling instances don't share memory.
There's a scale problem hiding in the duration ceiling too. Because every socket a user opens is capped at roughly the same window, a busy chat app tends to produce reconnect bursts, hundreds or thousands of clients renegotiating within seconds of each other as their connections expire together. Vercel's concurrency scaling is built to absorb spikes like that (up to 30,000 concurrent executions on Hobby and Pro, 100,000 on Enterprise) but it's load your app is generating just to keep conversations alive, not load from anyone actually using the product.
None of this makes native WebSockets a bad feature. It makes them a good fit for a single bounded exchange and a poor fit for what most people mean by "chat app": a room with several people in it, open indefinitely.
The socket lives somewhere else
This is the gap Supabase Realtime is built for. Instead of pinning a connection to one of your Vercel function instances, the socket terminates on a separate always-on service, built on Phoenix Channels, that fans messages out to everyone in a channel and tracks who's connected, sitting outside your app's request/response cycle (and outside its duration limits) entirely.
Your Vercel functions can still open their own bounded WebSocket when that's genuinely the right shape, streaming an AI reply, running a single timed exchange. But for the always-on part of a chat app, the room itself, the message history, the online list, the browser talks to Supabase directly using the supabase-js client, and that connection is Supabase's problem to keep alive, not yours. It isn't a Function invocation, so none of the duration limits, reconnect churn, or fan-out problems above ever come into play.
Three tools that cover most real-time needs
Supabase Realtime isn't one feature, it's three, and picking the right one matters more than people expect.
Postgres Changes: subscribe to your database directly
This listens to your write-ahead log and pushes row-level INSERT/UPDATE/DELETE events to subscribed clients, filtered through the same Row Level Security policies already protecting your tables.
const channel = supabase .channel('room-42-messages') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: 'room_id=eq.42' }, (payload) => { setMessages((current) => [...current, payload.new]); }, ) .subscribe();No extra backend code. If a row is inserted, whether from your Next.js API route, a cron job, or the Supabase dashboard, subscribed clients see it. The catch: it's read straight off the replication stream, so it's tied to your table shape and doesn't scale cleanly once you have wide tables or a lot of concurrent listeners.
Broadcast: fast, ephemeral, and not tied to a table
Broadcast is plain pub/sub. Messages don't touch Postgres at all, they're relayed client-to-client (or server-to-client) over a channel. Use it for anything transient: typing indicators, cursor positions, game state.
const channel = supabase.channel('room-42', { config: { broadcast: { self: false } },});
channel.on('broadcast', { event: 'typing' }, ({ payload }) => { showTypingIndicator(payload.userId);});
channel.subscribe();
// elsewhere, when the user starts typingchannel.send({ type: 'broadcast', event: 'typing', payload: { userId: currentUser.id },});Because nothing is written to disk, Broadcast is the lowest-latency option Supabase offers. It's also the right call whenever the event doesn't need to be durable, a typing indicator nobody needs to replay after a page refresh.
Presence: who's here right now
Presence tracks shared state per connected client and automatically reconciles it as people join and leave, which is exactly what you want for an "online now" list.
const channel = supabase.channel('room-42-presence', { config: { presence: { key: currentUser.id } },});
channel .on('presence', { event: 'sync' }, () => { const state = channel.presenceState(); setOnlineUsers(Object.keys(state)); }) .subscribe(async (status) => { if (status === 'SUBSCRIBED') { await channel.track({ userId: currentUser.id, joinedAt: Date.now() }); } });Close the tab and Supabase removes you from the presence state automatically. You don't write any cleanup logic for disconnects, that's the part people usually get wrong when they hand-roll this with plain WebSockets.
Broadcast from Database: the production fix for Postgres Changes
Postgres Changes is convenient, but it reads the WAL directly, and that gets expensive once you have many tables, wide rows, or a lot of concurrent subscribers all filtering the same stream. Broadcast from Database solves this by moving the decision of what to send into a Postgres trigger, using realtime.broadcast_changes(), so you control the payload and the topic instead of Realtime inferring it from the raw change.
create or replace function notify_message_change()returns triggerlanguage plpgsqlas $$begin perform realtime.broadcast_changes( 'room-' || coalesce(new.room_id, old.room_id)::text, -- topic tg_op, -- event tg_op, -- operation tg_table_name, tg_table_schema, new, old ); return null;end;$$;
create trigger broadcast_message_changesafter insert or update or delete on messagesfor each row execute function notify_message_change();The client side is identical to Broadcast, you subscribe to a channel and get pushed events, but now the trigger controls exactly which rows generate events and how the topic is shaped. That's the version I'd reach for on anything beyond a prototype.
Room-42, solved
Go back to the three things a real chat room needed that native WebSockets on Vercel couldn't give it: fan-out across users, presence, and a connection that doesn't reset on a timer. One Supabase channel, combining Postgres Changes, Broadcast, and Presence, covers all three at once.
'use client';
import { useEffect, useRef, useState } from 'react';import { supabase } from '@/lib/supabase-client';
export function useChatRoom({ roomId, user }) { const [messages, setMessages] = useState([]); const [onlineUsers, setOnlineUsers] = useState([]); const channelRef = useRef(null);
useEffect(() => { const channel = supabase.channel(`room-${roomId}`, { config: { broadcast: { self: false }, private: true, presence: { key: user.id } }, });
channel .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` }, (payload) => setMessages((current) => [...current, payload.new]), ) .on('broadcast', { event: 'typing' }, ({ payload }) => { showTypingIndicator(payload.userId); }) .on('presence', { event: 'sync' }, () => { setOnlineUsers(Object.keys(channel.presenceState())); }) .subscribe(async (status) => { if (status === 'SUBSCRIBED') { await channel.track({ userId: user.id }); } });
channelRef.current = channel;
return () => supabase.removeChannel(channel); }, [roomId, user.id]);
function sendMessage(text) { return supabase.from('messages').insert({ room_id: roomId, user_id: user.id, text }); }
function notifyTyping() { return channelRef.current?.send({ type: 'broadcast', event: 'typing', payload: { userId: user.id } }); }
return { messages, onlineUsers, sendMessage, notifyTyping };}Drop it into a component and the three gaps are gone:
function ChatRoom({ roomId, user }) { const { messages, onlineUsers, sendMessage, notifyTyping } = useChatRoom({ roomId, user });
return ( <div> <p>{onlineUsers.length} people here</p> <MessageList messages={messages} /> <MessageInput onSend={sendMessage} onChange={notifyTyping} /> </div> );}Fan-out is free. sendMessage just inserts a row, and every client subscribed to room-42 gets that insert pushed to it, whoever wrote it and whichever of Supabase's instances happens to be handling their connection. You never wire up Redis, there's no pub/sub layer to build, Realtime already fans the event out to the whole channel. Presence updates itself as people join and leave, no heartbeat code, no manual cleanup when a tab closes uncleanly. And this channel isn't a Function invocation, so there's no 5-minute ceiling forcing a disconnect. supabase-js reconnects on an actual network blip, not on a schedule, so the Friday-afternoon conversation just keeps running instead of quietly losing messages every few minutes.
That's the same chat feature, same room, same three requirements, built on infrastructure designed to hold the connection instead of one designed to time it out.
Locking channels down with Realtime Authorization
Broadcast and Presence channels aren't tied to a table, so RLS on your messages table doesn't automatically protect them. Supabase closes that gap with the realtime.messages table: mark a channel private, and Supabase evaluates RLS policies against it using the claims in the client's JWT before letting a connection join.
create policy "users can join rooms they belong to"on realtime.messagesfor selectto authenticatedusing ( exists ( select 1 from room_members where room_members.user_id = auth.uid() and 'room-' || room_members.room_id::text = realtime.topic() ));const channel = supabase.channel('room-42', { config: { private: true },});That policy check runs once, on subscribe, and the result is cached on the server, so it doesn't add per-message latency, it just gates who's allowed into the channel in the first place.
Where Vercel still does the work
None of this replaces your Vercel deployment, it just narrows what it's responsible for. Mutations, auth, payments, anything that needs a service role key or server-side validation, still goes through a Next.js API route or Server Action exactly like before. That code runs in seconds, well inside any Vercel duration limit, because it's a normal request that returns a normal response.
If a server action needs to notify clients itself rather than relying on a database trigger, it can call Supabase's REST broadcast endpoint and be done in one HTTP call, no socket held open on your side:
await supabase.channel('room-42').send({ type: 'broadcast', event: 'system-message', payload: { text: 'A moderator joined the room' },});The function still terminates the moment it responds. The only long-lived connection in the whole system is the one the browser opened directly to Supabase.
The limits, so you're not surprised in production
| Free | Pro | |
|---|---|---|
| Concurrent connections | 200 | 500 included, scales from there |
| Messages | 2 million / month | Usage-based above included volume |
| Max message size | 256 KB | 256 KB |
Realtime is also part of the open-source Supabase stack, so self-hosting is on the table if you outgrow the hosted limits or want the service running in your own infrastructure. For most projects that's a later problem, not a launch-day one.
Was it worth switching?
For the chat room itself, yes, but not because native WebSockets on Vercel are bad. They're a genuinely good answer to a narrower question: one client, one bounded exchange, cheap Active CPU billing, no separate platform to run. Keep using them for that. If you're building the UI side of an AI streaming response on Vercel, the animated gradient loading border pattern covers the loading state that works well with this kind of interface.
The moment a feature needs more than one person in the same conversation for more than a few minutes, though, you need fan-out, presence, and connections that don't reset on a timer, and that's a different job than a Function was built to do. Supabase Realtime happens to fit it well because it's usually the same project you're already using for Postgres and auth, so adding Broadcast, Presence, and Postgres Changes costs nothing architecturally, no new vendor, no new client library to learn.
If you're not on Supabase and only need sockets, Ably or Pusher will do the same job without pulling in a database. But whichever you pick, decide upfront which parts of your chat feature are a single bounded exchange and which parts are a shared, always-on room. Native WebSockets handle the first. They don't handle the second, and finding that out mid-conversation on a Friday afternoon is a worse way to learn it than reading this sentence.
Similar articles

What Australian CTOs Should Put in the AI Governance Pack
The board is asking. The regulator is watching. The CTO is the one who has to translate "AI governance" from a policy concept into a document set that actually governs something. Here is what a credible AI governance pack looks like for an Australian organisation in 2026 — and what each section needs to do to survive scrutiny.
21 Sept 2026

Governing Agentic AI: Human-in-the-Loop Patterns When Tools Can Act (MCP Edition)
Agentic AI doesn't just recommend — it acts. That changes governance from a policy question into an engineering question: which patterns actually limit blast radius, satisfy a human reviewer, and survive scrutiny from a regulator or court? A practical playbook for MCP-connected agents, with a checklist for regulated industries.
16 Sept 2026

AI-First Programming Languages: What They Are, and Whether You Should Use One Yet
In the last year a genuinely new category of programming language appeared: ones where the AI, not you, is the intended author. Here's what the categories are, which projects actually have traction, and which of them I'd be willing to put in production today.
9 Aug 2026

lucide-animated: Icon Motion That Knows When to Stop
lucide-animated wraps the entire lucide-react set in small, Motion-powered animations, same names, same currentColor stroke, one extra letter in the import. I wired it into a few real spots on this site and worked out where it earns its place and where it's just noise.
2 Aug 2026
