n4nAI

React streaming chat UI: SSE vs WebSockets vs fetch streams

A head-to-head comparison of react sse vs websockets vs fetch streaming for building chat UIs: latency, cost, ergonomics, and which to choose.

n4n Team4 min read943 words

Audio narration

Coming soon — every post will get a voice note here.

Building a responsive chat interface forces a foundational transport decision before you write a single component. The debate of react sse vs websockets vs fetch streaming comes down to how you trade off simplicity, bidirectional needs, and infrastructure cost.

Capabilities

SSE (Server-Sent Events) is unidirectional: server to client only. For a chat UI where the user sends a message via a normal HTTP POST and receives token streams back, that is enough. SSE gives you automatic reconnection and event IDs built into the browser’s EventSource.

WebSockets provide full duplex. If you need the server to push unsolicited updates (typing indicators, multi-user presence, cancellation commands mid-stream) over the same connection, WS wins. But most LLM chat flows are request/response; the user prompt is a discrete POST.

Fetch streaming is also unidirectional but rides on the standard Fetch API and a ReadableStream. It does not have built-in reconnection, but it lets you use one HTTP method for both sending the prompt (as a body) and reading the streamed response, avoiding a separate connection for the POST.

If you front your model calls with an OpenAI-compatible gateway like n4n.ai, the default streaming response is SSE, so client-side fetch or EventSource consumes it directly without extra protocol negotiation.

Code shape

SSE with EventSource (note: EventSource only does GET, so you need a GET endpoint or use fetch):

const es = new EventSource('/chat?prompt=hello');
es.onmessage = (e) => setTokens((t) => t + e.data);

Fetch streaming (POST + stream):

const res = await fetch('/chat', {
  method: 'POST',
  body: JSON.stringify({ prompt: 'hello' }),
  headers: { 'content-type': 'application/json' },
});
const reader = res.body!.getReader();
const dec = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  setTokens((t) => t + dec.decode(value));
}

WebSocket:

const ws = new WebSocket('wss://api.example.com/chat');
ws.onopen = () => ws.send(JSON.stringify({ prompt: 'hello' }));
ws.onmessage = (e) => setTokens((t) => t + e.data);

Cost model

SSE and fetch streaming run over HTTP/1.1 or HTTP/2, which most CDNs and serverless platforms bill by request and egress bytes. There is no persistent connection surcharge. A chat turn is one request, one response stream; you pay for the tokens transferred and the compute behind them.

WebSockets often incur connection minutes. Managed services (Azure Web PubSub, AWS API Gateway WebSocket) charge per connection-hour and per message. If you keep a socket open idle between messages, you bleed cost. For a chat app with sporadic usage, that adds up.

Self-hosting WebSockets requires a stateful proxy or sticky sessions, increasing operational overhead. SSE/fetch work with stateless lambda functions behind standard API gateways.

Latency and throughput

Time-to-first-token is comparable across all three if the server flushes immediately. SSE has slight framing overhead (:\n\n comments, data: prefixes). Fetch streaming is raw bytes; you parse the body yourself, which can be lower latency if you control the serialization.

WebSockets have a one-time handshake (HTTP upgrade) cost, then frames are lightweight. For high-frequency bidirectional small messages, WS has less per-message overhead than HTTP headers repeated on each fetch. But LLM tokens are typically aggregated into chunks of tens of bytes to kilobytes; the difference is negligible.

Throughput is bounded by the browser’s per-domain connection limit. SSE via EventSource historically blocks a connection (six per domain in Chrome). Fetch streams do not count against the EventSource slot but still consume a fetch slot. WebSockets are independent but count toward the same six-connection limit in practice.

Ergonomics

SSE is the laziest path for read-only streams: EventSource handles reconnection, last-event-id, and decoding. The downside: it only supports GET, so you must encode prompts in query params or use the fetch-based SSE pattern.

Fetch streaming is verbose but flexible. You get full control over headers (authorization, trace ids), method, and abort signals. In React, wrapping it in an AbortController for a “Stop” button is trivial:

const ctrl = new AbortController();
fetch('/chat', { signal: ctrl.signal, ... });
// later: ctrl.abort();

WebSockets require managing connection lifecycle, readyState, and often a JSON RPC envelope. In React, you must avoid reconnect storms and stale closures. The mental model is heavier.

Ecosystem

SSE is native in browsers; no library needed. Node/Express, FastAPI, and Go stdlib all emit text/event-stream easily.

Fetch streaming is universal; every modern browser and fetch polyfill supports ReadableStream.

WebSockets have mature libraries (ws, socket.io) but socket.io adds its own protocol atop WS, which is overkill for token streaming. For React specifically, @tanstack/query supports fetch streams via onStream experimentally; SSE has community hooks; WS has many context providers.

Limits and operational constraints

SSE through proxies: some corporate proxies buffer responses, killing streaming. Nginx needs proxy_buffering off;. Fetch streams can suffer the same if middleware buffers.

WebSockets are often blocked by strict firewalls (only 80/443 with upgrade). They also require server-side heartbeat to detect dead connections.

Browser limits: EventSource does not allow custom headers, so auth must be in URL or cookie. Fetch allows headers. WS allows headers at handshake.

Head-to-head comparison

Dimension SSE WebSockets Fetch streaming
Direction Server→Client Full duplex Server→Client (req body up)
Built-in reconnect Yes (EventSource) No No
Auth headers Cookie/URL only Handshake headers Any request header
Cost basis Request + egress Connection-min + msg Request + egress
Serverless fit Good Poor (stateful) Good
Client code Tiny (GET) Stateful envelope Verbose but flexible
Proxy pitfalls Buffering Upgrade blocked Buffering

Which to choose

Single-user LLM chat with prompt POST and token stream: Use fetch streaming. You keep one POST, send auth headers, abort easily, and parse the OpenAI-style data: {json}\n\n chunks. If your backend only exposes GET SSE, use EventSource for simplicity.

Multi-user collaborative chat with presence, typing, and server push: WebSockets earn their keep. The duplex channel avoids polling and gives instant server-initiated events. Accept the connection cost and build a heartbeat.

Serverless, cost-sensitive, low-frequency: SSE or fetch over HTTP/2. Avoid WS connection-minute billing. Stateless functions scale to zero.

Strict firewall / legacy proxy environment: Fetch streaming with chunked transfer and X-Accel-Buffering: no is the most proxy-friendly; avoid WS upgrades. Test with your actual edge.

If you already call an OpenAI-compatible endpoint: Most gateways stream via SSE. Consume with fetch and a stream reader; you avoid standing up a WS server entirely.

Pick the transport that matches your data flow, not the hype. For the common React chat UI, fetch streaming is the default; reach for WebSockets only when the server must talk first.

Tagsreactssewebsocketsstreamingcomparison

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All react streaming chat ui patterns posts →