· development · 8 min read
REST vs WebSockets vs Server-Sent Events — choosing the right communication pattern
Not every feature needs a WebSocket. Not every feature should be a REST call. Here's a practical, side-by-side comparison of the three main server-client communication patterns in the JS/TS ecosystem — with real use cases, code examples, and the libraries that make each one shine.

Three tools, one job — getting data between server and client
Every web application needs to move data between the server and the browser. For years, the default answer was REST. Then WebSockets arrived and suddenly everything had to be “real-time.” More recently, Server-Sent Events have quietly become the go-to for streaming — powering everything from ChatGPT-style token delivery to live dashboards.
The truth is, all three patterns solve different problems. Picking the wrong one leads to over-engineered infrastructure, wasted connections, or sluggish UX. Let’s break down when to use each, what they look like in practice, and which JS/TS libraries make them production-ready.
All code examples in this article use Bun as the runtime. Bun ships with a high-performance HTTP server, native WebSocket support, and streaming primitives — all built in, no dependencies needed. If you’re on Node.js, the same patterns apply; you’ll just reach for libraries like Express or ws instead of the Bun built-ins.
REST — the workhorse
REST (Representational State Transfer) is the request-response model we all know. The client sends a request, the server sends back a response, and the connection closes. Stateless, cacheable, and universally understood.
When to use REST
- CRUD operations — creating, reading, updating, deleting resources
- One-off data fetches — loading a user profile, submitting a form, fetching search results
- Public APIs — REST’s statelessness and cacheability make it ideal for APIs consumed by third parties
- Anything where the client initiates every interaction — if the server never needs to push data unprompted, REST is the simplest choice
A minimal example
Server (Bun.serve):
interface Todo {
id: number;
title: string;
done: boolean;
}
const todos: Todo[] = [];
let nextId = 1;
Bun.serve({
port: 3000,
routes: {
'/todos': {
GET: () => Response.json(todos),
POST: async (req) => {
const { title } = await req.json();
const todo: Todo = { id: nextId++, title, done: false };
todos.push(todo);
return Response.json(todo, { status: 201 });
},
},
},
});Client:
const res = await fetch('http://localhost:3000/todos');
const todos: Todo[] = await res.json();No persistent connections, no handshake overhead, no third-party libraries. Simple.
The ecosystem
REST tooling in JS/TS is the most mature of the three:
| Layer | Libraries |
|---|---|
| Server frameworks | Hono, Elysia, Fastify, Express |
| Type-safe clients | tRPC, ts-rest, Zodios |
| Client-side fetching | TanStack Query, SWR |
| Schema & validation | Zod, OpenAPI / Swagger |
| API documentation | Scalar, Redocly |
Worth calling out: tRPC has fundamentally changed how TypeScript developers build REST-like APIs. You define procedures on the server, and the client gets fully typed function calls with zero code generation. If you’re building a full-stack TypeScript app, it’s hard to justify not using it.
REST’s limitations
- No server-initiated communication — the server can’t push updates; the client must poll
- Polling is wasteful — checking for updates every N seconds means most requests return nothing
- High latency for real-time features — even short polling intervals introduce noticeable delay
- One response per request — you can’t stream partial results (without switching to SSE or chunked transfer)
WebSockets — the full-duplex channel
WebSockets upgrade an HTTP connection into a persistent, bidirectional communication channel. Both client and server can send messages at any time without waiting for the other side to initiate. This makes them the only option when you need true two-way, low-latency communication.
When to use WebSockets
- Chat applications — both parties send messages at any time
- Multiplayer games — constant bidirectional state synchronization
- Collaborative editing — Google Docs-style real-time co-authoring
- Financial trading dashboards — streaming prices + user-initiated trades on the same connection
- Any scenario where the client and server both need to push data frequently
A minimal example
Server (Bun’s built-in WebSockets):
const server = Bun.serve({
port: 3001,
fetch(req, server) {
if (new URL(req.url).pathname === '/chat') {
if (server.upgrade(req)) return;
return new Response('Upgrade failed', { status: 500 });
}
return new Response('Not found', { status: 404 });
},
websocket: {
open(ws) {
ws.subscribe('chat');
server.publish('chat', 'Someone joined the chat');
},
message(ws, message) {
server.publish('chat', String(message));
},
close(ws) {
ws.unsubscribe('chat');
server.publish('chat', 'Someone left the chat');
},
},
});Client (browser-native API, no library needed):
const ws = new WebSocket('ws://localhost:3001/chat');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ user: 'Alice', text: 'Hello!' }));
});
ws.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
console.log(`${message.user}: ${message.text}`);
});Both sides can call send() at any time — that’s the key difference from REST. Bun’s built-in pub/sub system (subscribe, publish, unsubscribe) eliminates the need to manually loop over connected clients, a pattern that typically requires extra code in Node.js with the ws library.
The ecosystem
| Layer | Libraries |
|---|---|
| Low-level server | Bun built-in, ws, µWebSockets.js |
| Abstraction layers | Socket.IO, Sockette |
| Real-time platforms | Ably, Pusher, PartyKit |
| Collaborative / CRDT | Yjs, Liveblocks |
| Type-safe WS | tRPC subscriptions |
Socket.IO deserves special mention. It wraps WebSockets with automatic reconnection, room-based broadcasting, binary support, and graceful fallback to HTTP long-polling when WebSockets aren’t available. It’s battle-tested at scale and remains the most popular choice for WebSocket-based features in Node.js.
For collaborative applications, Yjs paired with a WebSocket provider gives you conflict-free replicated data types (CRDTs) out of the box — the foundational technology behind collaborative editors like Tiptap and many Notion-like tools.
WebSocket trade-offs
- Stateful connections — every connected client holds an open socket, consuming server memory; horizontal scaling requires sticky sessions or a pub/sub layer like Redis
- No built-in reconnection — the browser’s
WebSocketAPI doesn’t auto-reconnect; you need a wrapper or a library like Socket.IO - Overkill for one-way streaming — if the server pushes data but the client rarely sends anything back, WebSockets carry unnecessary complexity
- Proxy / firewall issues — some corporate proxies still interfere with WebSocket upgrades, though this has improved significantly
- No native multiplexing — one connection, one channel; you typically multiplex by adding your own topic/event routing (though Bun’s pub/sub topics help here)
Server-Sent Events — the underrated middle ground
Server-Sent Events (SSE) open a persistent HTTP connection where the server can push text-based events to the client. The client can’t send data back over the same connection — but it usually doesn’t need to. The browser provides automatic reconnection, event IDs for resume, and a dead-simple API via EventSource.
When to use SSE
- Live feeds — news tickers, social media timelines, notification streams
- AI/LLM token streaming — sending generated tokens as they’re produced (this is how ChatGPT, Claude, and most LLM APIs stream responses)
- Progress updates — file processing, CI/CD pipeline status, deployment logs
- Dashboard updates — server metrics, analytics, stock prices (when the client only reads)
- Any scenario where the server pushes data and the client just listens
A minimal example
Server (Bun with an async generator):
Bun.serve({
port: 3002,
routes: {
'/events': (req, server) => {
server.timeout(req, 0);
return new Response(
async function* () {
while (true) {
const data = JSON.stringify({
time: new Date().toISOString(),
cpu: Math.random() * 100,
});
yield `data: ${data}\n\n`;
await Bun.sleep(1000);
}
},
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
},
);
},
},
});Client (browser-native EventSource):
const source = new EventSource('http://localhost:3002/events');
source.addEventListener('message', (event) => {
const metrics = JSON.parse(event.data);
console.log(`CPU: ${metrics.cpu.toFixed(1)}% at ${metrics.time}`);
});
source.addEventListener('error', () => {
console.log('Connection lost, reconnecting...');
});That’s it. No handshake upgrade, no protocol negotiation, no third-party library. The text/event-stream content type tells the browser to keep the connection open, and EventSource handles reconnection automatically with the Last-Event-ID header. The server.timeout(req, 0) call disables Bun’s default 10-second idle timeout so the stream stays alive between events.
The ecosystem
| Layer | Libraries |
|---|---|
| Server helpers | better-sse |
| Framework support | Built-in in Hono, Elysia, Fastify |
| AI/LLM streaming | AI SDK, LangChain.js, OpenAI SDK |
| Type-safe SSE | tRPC subscriptions via SSE |
The AI SDK (formerly Vercel AI SDK) is particularly notable — it standardizes LLM response streaming over SSE with framework-agnostic hooks like useChat and useCompletion that handle event stream parsing, state management, and UI rendering in one package.
Since tRPC v11, subscriptions can use SSE as a transport instead of WebSockets. The tRPC docs actually recommend SSE over WebSockets for most subscription use cases because it’s easier to set up and doesn’t require a separate WebSocket server. You get type-safe, real-time server-to-client streaming without managing WebSocket infrastructure.
SSE trade-offs
- Unidirectional only — the server pushes; the client can’t send data back over the same connection (use a separate REST call if needed)
- Text-only — binary data must be Base64-encoded, adding overhead
- Connection limits — browsers cap the number of concurrent
EventSourceconnections per domain (typically 6 over HTTP/1.1; this limit effectively goes away with HTTP/2) - No built-in multiplexing — one connection per event stream, though you can multiplex via named events
Side-by-side comparison
| REST | WebSockets | SSE | |
|---|---|---|---|
| Direction | Client → Server → Client | Bidirectional | Server → Client |
| Connection | Short-lived | Persistent | Persistent |
| Protocol | HTTP | WS (upgraded from HTTP) | HTTP |
| Data format | Any | Any | Text (UTF-8) |
| Auto-reconnect | N/A | No (manual) | Yes (built-in) |
| Browser support | Universal | Universal | Universal (except IE) |
| Caching | Native HTTP caching | None | None |
| Scalability | Stateless, easy to scale | Stateful, needs sticky sessions | Stateful, but simpler than WS |
| HTTP/2 multiplexing | Yes | No (separate protocol) | Yes |
| Ideal for | CRUD, APIs | Chat, games, collaboration | Feeds, streaming, notifications |
Combining patterns — the pragmatic approach
In practice, most applications use more than one pattern. A typical architecture might look like:
- REST for all CRUD operations, authentication, and form submissions
- SSE for pushing notifications, live feed updates, or streaming AI responses
- WebSockets only for the features that genuinely need bidirectional, low-latency communication
This isn’t over-engineering — it’s using the right tool for the job. A chat feature needs WebSockets. A notification bell needs SSE. A profile update needs REST. Trying to funnel all three through a single WebSocket connection adds complexity that buys you nothing.
tRPC exemplifies this approach elegantly: queries and mutations use HTTP (REST-like), while subscriptions can use either WebSockets or SSE depending on your needs — all within the same type-safe router.
Decision flowchart
When choosing a communication pattern for a new feature, ask these two questions:
- Does the server need to push data to the client without being asked?
- No → REST
- Yes → continue
- Does the client also need to push frequent, low-latency data to the server?
- No → SSE
- Yes → WebSockets
That’s the 90% rule. The remaining 10% comes down to constraints like binary data requirements, connection limits, or infrastructure compatibility — but this flowchart will steer you right for most features.