Server-Sent Events in React: Real-Time Data Without WebSockets
SSE gives you real-time server-to-client data in React without the overhead of WebSockets. Here's how to actually use it in production.
Why SSE Exists (And When to Actually Use It)
WebSockets are overkill for a lot of real-time use cases. If your server needs to push data to the client but the client never needs to send data back, you're carrying around a bidirectional protocol for a one-way job. That's like renting a cargo van to deliver a single pizza.
Server-Sent Events (SSE) is an HTTP-based protocol that's been in browsers since 2006. One connection, one direction — server to client. The browser handles reconnection automatically, you get named event types, and it works over standard HTTP/2. No special server setup, no handshake negotiation, just a persistent response stream with Content-Type: text/event-stream.
In practice, SSE shines for: live dashboards updating metrics every few seconds, AI chat streaming (think ChatGPT's typewriter effect), notification feeds, build log tailing, and stock tickers. If you're building a collaborative doc editor where users are typing at each other in real time? Yeah, use WebSockets. But for everything else, SSE is genuinely the better tool.
Worth noting: SSE respects HTTP/2 multiplexing, so you're not burning a whole connection per tab the way HTTP/1.1 SSE does. Modern deployments handle this well, especially on Vercel or any nginx-proxied stack.
The EventSource API — What the Browser Gives You
The browser's native EventSource interface is what powers SSE on the client side. It's been stable since Chrome 6 and Firefox 6 back in 2011. You get three event handlers out of the box: onopen, onmessage, and onerror. The reconnection logic is built in — if the connection drops, the browser retries after 3 seconds by default, and you can control that from the server with the retry: field.
Here's the simplest possible usage:
``js
const source = new EventSource('/api/stream');
source.onmessage = (event) => {
console.log('Received:', event.data);
};
source.onerror = (err) => {
console.error('SSE error:', err);
source.close();
};
``
You can also listen to named events, which is the pattern you'll use most in production. The server sends event: yourEventName before the data: line, and you subscribe with addEventListener instead of onmessage:
``js
source.addEventListener('notification', (event) => {
const payload = JSON.parse(event.data);
showNotification(payload);
});
``
One limit worth knowing: browsers cap concurrent SSE connections per origin at 6 in HTTP/1.1. In HTTP/2 that limit is effectively gone (hundreds of streams per connection). If you're testing locally on port 3000 with HTTP/1.1 and wondering why your sixth tab freezes — that's why. It's not a bug in your code.
Building the React Hook
You don't want raw EventSource calls scattered through your components. A custom hook is the right abstraction here. You want it to handle connection lifecycle, cleanup on unmount, and optionally expose connection status to the UI.
Here's a useSSE hook that covers the practical cases:
``tsx
import { useEffect, useRef, useState } from 'react';
type SSEOptions = {
onMessage?: (data: string) => void;
onError?: (err: Event) => void;
withCredentials?: boolean;
};
export function useSSE(url: string | null, options: SSEOptions = {}) {
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
const sourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!url) return;
const source = new EventSource(url, {
withCredentials: options.withCredentials ?? false,
});
sourceRef.current = source;
setStatus('connecting');
source.onopen = () => setStatus('open');
source.onmessage = (event) => {
options.onMessage?.(event.data);
};
source.onerror = (err) => {
setStatus('closed');
options.onError?.(err);
source.close();
};
return () => {
source.close();
setStatus('closed');
};
}, [url]);
return { status, close: () => sourceRef.current?.close() };
}
``
A few things to notice: the url parameter can be null, which lets you conditionally start the stream (useful if you need auth tokens before connecting). The cleanup function in useEffect closes the source on unmount — this is the one thing people forget and then wonder why they have zombie connections. The status return lets you render a connection indicator without lifting a ton of state.
Using it in a component is clean:
``tsx
function LiveFeed() {
const [messages, setMessages] = useState<string[]>([]);
const { status } = useSSE('/api/feed', {
onMessage: (data) => {
setMessages((prev) => [...prev.slice(-99), data]);
},
});
return (
<div>
<span>Status: {status}</span>
{messages.map((m, i) => <p key={i}>{m}</p>)}
</div>
);
}
``
That .slice(-99) keeps the message list bounded at 100 items. Obvious in hindsight, easy to forget — and your users will notice when their browser slows to a crawl after 10 minutes of an unbounded stream.
Writing the Server Endpoint (Node/Next.js)
The server side is simpler than most people expect. You need to set four headers, then write data in the SSE format and flush the response. The format is plain text: data: your payload\n\n. Two newlines end a message. Named events add event: name\n before the data line.
Here's a Next.js App Router route handler:
``ts
// app/api/feed/route.ts
import { NextRequest } from 'next/server';
export async function GET(req: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) => {
const chunk = event: ${event}\ndata: ${JSON.stringify(data)}\n\n;
controller.enqueue(encoder.encode(chunk));
};
// Send an initial ping
send('connected', { ts: Date.now() });
// Simulate periodic updates
const interval = setInterval(() => {
send('metric', { value: Math.random() * 100, ts: Date.now() });
}, 2000);
// Clean up when client disconnects
req.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
``
That X-Accel-Buffering: no header is the one people miss. Without it, nginx will buffer your response until it reaches a certain size before sending it downstream — which completely breaks streaming. Add it even if you're not sure you're behind nginx. It's a no-op otherwise.
Honestly, the req.signal abort listener is the most important part of this whole thing. If you don't clean up your intervals and database subscriptions when the client disconnects, you'll leak resources silently. In a high-traffic app that's a real problem — 1,000 abandoned connections with 1 interval each is 1,000 timers still running on your server.
Quick aside: if you're on a serverless platform with function timeouts, SSE has a hard ceiling. Vercel's Fluid functions give you up to 800 seconds, but their standard serverless cap is much lower. For truly long-lived streams you might need an edge runtime or a dedicated Node.js server. Know your platform's limits before you ship.
Handling Authentication and Custom Headers
Here's where SSE gets annoying: EventSource doesn't let you set custom request headers. You can't pass Authorization: Bearer ... the way you would with fetch. This trips up almost every team that builds SSE for the first time.
You've got three practical options. First, use withCredentials: true and cookie-based auth — the browser will send your session cookie automatically. This works great if you're already doing cookie auth. Second, put the token in the URL as a query param: /api/stream?token=xyz. Not ideal for security (tokens end up in logs), but widely used. Third, use a short-lived ticket: your client requests a one-time token from a normal REST endpoint, then uses that token as the URL param. The ticket expires after 30 seconds and can only be used once.
The ticket pattern is the cleanest for JWT-based auth:
``ts
// Client side
async function startStream() {
// Exchange your JWT for a short-lived stream ticket
const { ticket } = await fetch('/api/stream-ticket', {
method: 'POST',
headers: { Authorization: Bearer ${jwt} },
}).then(r => r.json());
const source = new EventSource(/api/stream?ticket=${ticket});
return source;
}
``
Look, none of these are perfect. The native API just wasn't designed with modern auth flows in mind. If this bothers you enough, there are community libraries like @microsoft/fetch-event-source that use fetch under the hood instead of EventSource — giving you full header control at the cost of losing the browser's built-in reconnection. Trade-offs either way.
SSE in the Wild: AI Streaming and Live Dashboards
The most visible use of SSE right now is AI response streaming. Every major AI chat product — Claude, ChatGPT, Gemini — uses a streaming response for that character-by-character effect. It makes the interface feel dramatically faster even when the total time-to-completion is identical. The data format is usually the same as what we've covered, just with data: [DONE] as a terminal sentinel.
Here's how you'd wire up a streaming AI response in React:
``tsx
function AIChatMessage({ prompt }: { prompt: string }) {
const [text, setText] = useState('');
const [done, setDone] = useState(false);
useEffect(() => {
const source = new EventSource(
/api/ai/stream?prompt=${encodeURIComponent(prompt)}
);
source.addEventListener('token', (e) => {
setText((prev) => prev + e.data);
});
source.addEventListener('done', () => {
setDone(true);
source.close();
});
return () => source.close();
}, [prompt]);
return (
<p>
{text}
{!done && <span className="animate-pulse">▋</span>}
</p>
);
}
``
For dashboards, SSE pairs really well with a metrics stream that pushes updates every 2-5 seconds instead of the client polling every second. You cut server load drastically — no thundering herd of clients all fetching at the same moment — and the UX is smoother. If you're building a live analytics dashboard or status page, this is the pattern.
You can combine SSE data with Empire UI's glassmorphism components for that polished look without much extra work — a frosted card with animated number counters that update via SSE is a common pattern in SaaS dashboards right now. Check the animated number counter component to see what the animation side looks like.
That said, don't reach for SSE just because it's cool. If you're updating data less than once per minute, a standard setInterval with fetch is simpler, cheaper, and easier to debug. SSE earns its complexity above maybe 10 updates per minute, or when you need truly push-based delivery (no polling delay at all).
Production Gotchas You'll Hit Eventually
Proxies and load balancers love to close idle connections. If your SSE stream is quiet for 30-60 seconds, a proxy like AWS ALB will kill it and your client will get an error with no useful message. The fix is a heartbeat: send a comment line (: ping\n\n) every 15-20 seconds to keep the connection alive. Comment lines are ignored by the browser but do prevent idle timeouts.
// Add to your server stream's start handler
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(': ping\n\n'));
}, 15_000);
req.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
clearInterval(interval);
controller.close();
});React 18's strict mode double-invokes effects in development. You'll see two SSE connections open in your network tab when you'd expect one. This is intentional — React is testing that your cleanup function works. Don't disable strict mode because of this. Just verify the cleanup runs and trust that production won't double-connect.
One more thing — if you're deploying to a platform that uses Vercel's Edge Network or Cloudflare Workers, test your SSE thoroughly before going live. Some edge deployments buffer responses differently or have different timeout semantics. A stream that works perfectly on local Node.js can behave unexpectedly behind an edge CDN with response buffering enabled. Check the Transfer-Encoding: chunked header is making it through, and test with a real network connection, not just localhost.
For complex apps where you need SSE alongside other patterns like optimistic updates or shared state, Zustand works well as the store that SSE events write into. Components subscribe to the store, not the SSE connection directly, which keeps your component logic clean and makes testing way easier.
FAQ
Yes — use a Route Handler that returns a Response with a ReadableStream and Content-Type: text/event-stream. Edge runtime works too, just watch for timeout limits. The example in this article targets App Router specifically.
Yes, and it's better on HTTP/2. The browser's 6-connection-per-origin limit from HTTP/1.1 doesn't apply — HTTP/2 multiplexes streams over a single connection, so you can have many SSE streams open at once without issues.
You can't — the native EventSource API doesn't support custom headers. Use cookies with withCredentials: true, pass a short-lived ticket as a URL param, or switch to @microsoft/fetch-event-source which uses fetch under the hood.
SSE is one-way (server to client) over HTTP, WebSockets are bidirectional over their own protocol. SSE is simpler, cheaper to set up, and the browser auto-reconnects. Use WebSockets when clients need to send data frequently — otherwise SSE is usually the better call.