Server-Sent Events (SSE)
Server-Sent Events (SSE) let a server push a continuous stream of text events to the browser over a single long-lived HTTP response. The client uses the EventSource API to open the connection, receive messages, and automatically reconnect when the stream drops.
SSE is one-directional (server → client), uses ordinary HTTP/HTTPS, and speaks a simpletext/event-stream format. That makes it a strong fit for notifications, live dashboards, log tails, progress updates, and LLM token streaming — cases where the client does not need a full-duplex WebSocket.
This guide covers SSE vs WebSocket vs polling, the EventSource constructor and withCredentials, readyState, open/message/error events, custom event types, lastEventId and reconnection, the retry field, server stream format, Node/Express and Next.js route handler examples, auth limitations and workarounds, CORS, connection limits, use cases, cleanup, fetch-based streaming alternatives, retry strategies, security, and best practices.
info
| Aspect | SSE | WebSocket | Polling |
|---|---|---|---|
| Direction | Server → client | Full duplex | Client pulls |
| Transport | HTTP response stream | WS upgrade | Repeated HTTP |
| Auto reconnect | Built into EventSource | DIY | Inherent (next poll) |
| Binary data | Text (encode yourself) | Text + binary | Anything |
| Custom headers | Not with EventSource | During handshake (limited) | Yes (fetch/XHR) |
| Proxy friendliness | Generally good (HTTP) | Sometimes blocked | Excellent |
| Complexity | Low | Medium | Lowest, least efficient |
| Best for | Feeds, logs, LLM tokens | Chat, games, collab | Rare updates, simple apps |
note
Create a connection with new EventSource(url) or new EventSource(url, { withCredentials: true }). Same-origin cookies are sent by default for same-origin URLs; cross-origin cookie auth requires withCredentials: true and matching CORS.
| 1 | // Same-origin stream |
| 2 | const source = new EventSource("/api/events"); |
| 3 | |
| 4 | // Cross-origin with cookies |
| 5 | const cross = new EventSource("https://events.example.com/stream", { |
| 6 | withCredentials: true, |
| 7 | }); |
warning
EventSource.readyState reports connection status using numeric constants mirrored on the instance:
| Constant | Value | Meaning |
|---|---|---|
| CONNECTING | 0 | Connecting or reconnecting |
| OPEN | 1 | Connection established |
| CLOSED | 2 | Closed — will not reconnect |
| 1 | function labelState(source) { |
| 2 | switch (source.readyState) { |
| 3 | case EventSource.CONNECTING: |
| 4 | return "connecting"; |
| 5 | case EventSource.OPEN: |
| 6 | return "open"; |
| 7 | case EventSource.CLOSED: |
| 8 | return "closed"; |
| 9 | default: |
| 10 | return "unknown"; |
| 11 | } |
| 12 | } |
Three built-in event channels cover most clients. open fires when the stream is ready. message fires for events without a custom event: field (type message). error fires on network failures and after connection loss — EventSource typically transitions to CONNECTING and retries unless you called close().
| 1 | const source = new EventSource("/api/events"); |
| 2 | |
| 3 | source.addEventListener("open", () => { |
| 4 | console.log("SSE connected"); |
| 5 | }); |
| 6 | |
| 7 | source.addEventListener("message", (event) => { |
| 8 | // event.data is always a string |
| 9 | const payload = JSON.parse(event.data); |
| 10 | renderUpdate(payload); |
| 11 | }); |
| 12 | |
| 13 | source.addEventListener("error", () => { |
| 14 | if (source.readyState === EventSource.CLOSED) { |
| 15 | console.error("Stream closed permanently"); |
| 16 | } else { |
| 17 | console.warn("Transient SSE error; browser may retry"); |
| 18 | } |
| 19 | }); |
Servers can set an event: field. Those events do not hit the default message listener — register a listener for that event name instead. This keeps progress, heartbeats, and domain events cleanly separated.
| 1 | source.addEventListener("progress", (event) => { |
| 2 | const { percent } = JSON.parse(event.data); |
| 3 | setProgress(percent); |
| 4 | }); |
| 5 | |
| 6 | source.addEventListener("heartbeat", () => { |
| 7 | lastBeat = Date.now(); |
| 8 | }); |
| 9 | |
| 10 | source.addEventListener("done", (event) => { |
| 11 | finishJob(JSON.parse(event.data)); |
| 12 | source.close(); |
| 13 | }); |
| 1 | event: progress |
| 2 | data: {"percent":42} |
| 3 | |
| 4 | event: heartbeat |
| 5 | data: ok |
| 6 | |
| 7 | event: done |
| 8 | data: {"id":"job_123","status":"succeeded"} |
When the server includes an id: field, the browser stores it as EventSource.lastEventId. On reconnect, the browser automatically sends that value in the Last-Event-ID HTTP header so the server can resume without duplicating or skipping events.
| 1 | id: 1001 |
| 2 | event: order |
| 3 | data: {"orderId":"A-1001","status":"shipped"} |
| 4 | |
| 5 | id: 1002 |
| 6 | event: order |
| 7 | data: {"orderId":"A-1002","status":"packed"} |
| 1 | // Server (Express-style) — resume from Last-Event-ID |
| 2 | app.get("/api/events", (req, res) => { |
| 3 | const lastId = req.header("Last-Event-ID"); |
| 4 | const events = lastId ? store.after(lastId) : store.recent(); |
| 5 | // write SSE frames for each event, including id: lines |
| 6 | }); |
best practice
A line like retry: 5000 tells the browser how many milliseconds to wait before reconnecting after a drop. Send it early in the stream (or periodically) to tune backoff without client-side timers.
| 1 | retry: 3000 |
| 2 | |
| 3 | data: {"hello":true} |
note
The response must use Content-Type: text/event-stream. Each event is one or more field lines ending with a blank line. Comments start with : and keep proxies/load balancers from timing out idle connections when used as heartbeats.
| Field | Purpose |
|---|---|
| data | Payload string (multiple data lines join with \\n) |
| event | Custom event type name |
| id | Event ID for Last-Event-ID resume |
| retry | Reconnect delay in ms |
| : comment | Ignored heartbeat / annotation |
| 1 | HTTP/1.1 200 OK |
| 2 | Content-Type: text/event-stream |
| 3 | Cache-Control: no-cache |
| 4 | Connection: keep-alive |
| 5 | |
| 6 | : heartbeat |
| 7 | |
| 8 | retry: 5000 |
| 9 | |
| 10 | id: 42 |
| 11 | event: notify |
| 12 | data: {"title":"Deploy finished"} |
| 13 | data: {"env":"production"} |
| 14 | |
| 15 | id: 43 |
| 16 | data: plain message with default type |
pro tip
Disable response buffering, set SSE headers, write frames, and clear timers when the client disconnects. Flush after each write when behind proxies that buffer.
| 1 | import express from "express"; |
| 2 | |
| 3 | const app = express(); |
| 4 | let seq = 0; |
| 5 | |
| 6 | app.get("/api/events", (req, res) => { |
| 7 | res.setHeader("Content-Type", "text/event-stream"); |
| 8 | res.setHeader("Cache-Control", "no-cache"); |
| 9 | res.setHeader("Connection", "keep-alive"); |
| 10 | res.flushHeaders?.(); |
| 11 | |
| 12 | res.write("retry: 3000\n\n"); |
| 13 | |
| 14 | const timer = setInterval(() => { |
| 15 | seq += 1; |
| 16 | const payload = JSON.stringify({ seq, at: Date.now() }); |
| 17 | res.write(`id: ${seq}\nevent: tick\ndata: ${payload}\n\n`); |
| 18 | }, 1000); |
| 19 | |
| 20 | req.on("close", () => { |
| 21 | clearInterval(timer); |
| 22 | }); |
| 23 | }); |
| 24 | |
| 25 | app.listen(3000); |
In the App Router, return a ReadableStream with SSE headers from a route handler. Respect request.signal for cancellation when the client disconnects.
| 1 | // app/api/events/route.ts |
| 2 | export const dynamic = "force-dynamic"; |
| 3 | |
| 4 | export async function GET(request: Request) { |
| 5 | let counter = 0; |
| 6 | const encoder = new TextEncoder(); |
| 7 | |
| 8 | const stream = new ReadableStream({ |
| 9 | start(controller) { |
| 10 | const send = (lines: string) => { |
| 11 | controller.enqueue(encoder.encode(lines)); |
| 12 | }; |
| 13 | |
| 14 | send("retry: 2000\n\n"); |
| 15 | |
| 16 | const timer = setInterval(() => { |
| 17 | counter += 1; |
| 18 | const data = JSON.stringify({ counter }); |
| 19 | send(`id: ${counter}\ndata: ${data}\n\n`); |
| 20 | }, 1000); |
| 21 | |
| 22 | request.signal.addEventListener("abort", () => { |
| 23 | clearInterval(timer); |
| 24 | controller.close(); |
| 25 | }); |
| 26 | }, |
| 27 | }); |
| 28 | |
| 29 | return new Response(stream, { |
| 30 | headers: { |
| 31 | "Content-Type": "text/event-stream", |
| 32 | "Cache-Control": "no-cache, no-transform", |
| 33 | Connection: "keep-alive", |
| 34 | }, |
| 35 | }); |
| 36 | } |
warning
Because EventSource cannot attach custom headers, Bearer-token APIs need an alternative. Common patterns:
| Approach | Pros | Cons |
|---|---|---|
| HttpOnly cookie | Works with EventSource + withCredentials | CSRF considerations on cookie APIs |
| Query token | Simple | Leaks into logs/Referer — use short-lived tokens |
| fetch stream | Full header control | DIY reconnect / Last-Event-ID |
| Ticket bootstrap | POST for one-time ticket, SSE with ticket | Extra round trip |
| 1 | // Short-lived query ticket (server validates + expires quickly) |
| 2 | async function connectSecureStream() { |
| 3 | const { ticket } = await fetch("/api/sse-ticket", { method: "POST" }).then((r) => |
| 4 | r.json(), |
| 5 | ); |
| 6 | return new EventSource(`/api/events?ticket=${encodeURIComponent(ticket)}`); |
| 7 | } |
Cross-origin SSE requires standard CORS headers on the stream response. If you use withCredentials: true, you cannot mirror * for Access-Control-Allow-Origin — echo the specific origin and set Access-Control-Allow-Credentials: true.
| 1 | res.setHeader("Access-Control-Allow-Origin", "https://app.example.com"); |
| 2 | res.setHeader("Access-Control-Allow-Credentials", "true"); |
| 3 | res.setHeader("Content-Type", "text/event-stream"); |
Browsers limit concurrent HTTP/1.1 connections per origin (often six). Each EventSource counts against that budget. Opening many SSE endpoints on the same origin can stall other requests. HTTP/2 multiplexing reduces the pain, but you should still multiplex logical channels over one SSE connection when possible.
danger
Notifications
Push unread counts, billing alerts, and collaboration pings without polling every few seconds.
LLM token streaming
Stream model tokens as data: chunks (or custom token events), then send a final done event. Many AI gateways already speak SSE.
Logs & progress
Tail build logs, import progress, and CI steps over one stream with id for resume after refresh.
| 1 | const source = new EventSource("/api/chat/stream?run=abc"); |
| 2 | |
| 3 | source.addEventListener("token", (event) => { |
| 4 | appendToTranscript(event.data); |
| 5 | }); |
| 6 | |
| 7 | source.addEventListener("done", () => { |
| 8 | source.close(); |
| 9 | markRunComplete(); |
| 10 | }); |
Call source.close() when the component unmounts, the user logs out, or the job completes. After close, readyState is CLOSED and the browser will not reconnect. On the server, listen for request close/abort to free timers and backpressure.
| 1 | useEffect(() => { |
| 2 | const source = new EventSource("/api/events"); |
| 3 | source.onmessage = (event) => setData(JSON.parse(event.data)); |
| 4 | return () => source.close(); |
| 5 | }, []); |
When you need Authorization headers or POST bodies, use fetch + ReadableStream and parse SSE frames yourself. You lose automatic reconnect unless you implement it.
| 1 | async function streamWithAuth(url, token, onEvent) { |
| 2 | const res = await fetch(url, { |
| 3 | headers: { |
| 4 | Accept: "text/event-stream", |
| 5 | Authorization: `Bearer ${token}`, |
| 6 | }, |
| 7 | }); |
| 8 | if (!res.ok || !res.body) throw new Error("SSE failed"); |
| 9 | |
| 10 | const reader = res.body.getReader(); |
| 11 | const decoder = new TextDecoder(); |
| 12 | let buffer = ""; |
| 13 | |
| 14 | while (true) { |
| 15 | const { value, done } = await reader.read(); |
| 16 | if (done) break; |
| 17 | buffer += decoder.decode(value, { stream: true }); |
| 18 | const parts = buffer.split("\n\n"); |
| 19 | buffer = parts.pop() ?? ""; |
| 20 | for (const part of parts) { |
| 21 | const dataLines = part |
| 22 | .split("\n") |
| 23 | .filter((l) => l.startsWith("data:")) |
| 24 | .map((l) => l.slice(5).trimStart()); |
| 25 | if (dataLines.length) onEvent(dataLines.join("\n")); |
| 26 | } |
| 27 | } |
| 28 | } |
info
EventSource retries automatically on transient failures. Still handle permanent errors: HTTP 401/403 often still surface as error events depending on browser — validate tickets before opening, and close the source if you detect auth failure via a side channel. For fetch-based streams, implement exponential backoff with jitter and honor Last-Event-ID.
| 1 | async function connectWithBackoff(factory, { maxAttempts = 8 } = {}) { |
| 2 | let attempt = 0; |
| 3 | while (attempt < maxAttempts) { |
| 4 | try { |
| 5 | await factory(); // resolves when stream ends cleanly |
| 6 | return; |
| 7 | } catch (err) { |
| 8 | attempt += 1; |
| 9 | const delay = Math.min(30_000, 500 * 2 ** attempt); |
| 10 | const jitter = Math.random() * 250; |
| 11 | await new Promise((r) => setTimeout(r, delay + jitter)); |
| 12 | } |
| 13 | } |
| 14 | throw new Error("SSE reconnect gave up"); |
| 15 | } |
Authenticate every stream
Never expose private feeds on unguessable URLs alone. Bind tickets to user, expiry, and scope.
Avoid sensitive query tokens
Query strings land in access logs and browser history. Prefer HttpOnly cookies or POST ticket exchange.
Sanitize event payloads
If you write event.data into the DOM, treat it like any other untrusted HTML — prefer textContent or strict sanitization.
Rate-limit open connections
Cap concurrent SSE connections per user/IP to reduce resource exhaustion attacks.
danger
| Practice | Why |
|---|---|
| Send id: on durable events | Resume after disconnect without gaps |
| Heartbeat with : comments | Defeat idle timeouts |
| One stream per app surface | Respect connection limits |
| JSON on a single data line | Simple, robust parsing |
| close() on unmount | Prevent leaks and ghost reconnects |
| Set Cache-Control: no-cache | Avoid intermediary caching of streams |
| Prefer cookies or tickets for auth | EventSource cannot set Authorization |
| Disable response buffering | Events arrive in real time |
best practice
note
note
note
info
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.