|$ curl https://forge-ai.dev/api/markdown?path=docs/html/sse
$cat docs/server-sent-events-(sse).md
updated This week·30 min read·published

Server-Sent Events (SSE)

HTMLAPISSEEventSourceAdvancedAdvanced🎯Free Tools
Introduction

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

Choose SSE when you need reliable server→client updates with automatic reconnect and minimal protocol complexity. Choose WebSockets when you need frequent client→server messages on the same connection.
SSE vs WebSocket vs Polling
AspectSSEWebSocketPolling
DirectionServer → clientFull duplexClient pulls
TransportHTTP response streamWS upgradeRepeated HTTP
Auto reconnectBuilt into EventSourceDIYInherent (next poll)
Binary dataText (encode yourself)Text + binaryAnything
Custom headersNot with EventSourceDuring handshake (limited)Yes (fetch/XHR)
Proxy friendlinessGenerally good (HTTP)Sometimes blockedExcellent
ComplexityLowMediumLowest, least efficient
Best forFeeds, logs, LLM tokensChat, games, collabRare updates, simple apps
📝

note

Long polling is a middle ground: the server holds a request until an event exists. SSE is usually cleaner — one connection, many events, standard framing.
EventSource Constructor & withCredentials

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.

eventsource-constructor.js
JavaScript
1// Same-origin stream
2const source = new EventSource("/api/events");
3
4// Cross-origin with cookies
5const cross = new EventSource("https://events.example.com/stream", {
6 withCredentials: true,
7});

warning

EventSource cannot set custom headers such as Authorization: Bearer …. Use cookies, query tokens, or a fetch-based stream reader when you need header auth.
readyState

EventSource.readyState reports connection status using numeric constants mirrored on the instance:

ConstantValueMeaning
CONNECTING0Connecting or reconnecting
OPEN1Connection established
CLOSED2Closed — will not reconnect
readystate.js
JavaScript
1function 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}
Events: open, message, error

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().

sse-events.js
JavaScript
1const source = new EventSource("/api/events");
2
3source.addEventListener("open", () => {
4 console.log("SSE connected");
5});
6
7source.addEventListener("message", (event) => {
8 // event.data is always a string
9 const payload = JSON.parse(event.data);
10 renderUpdate(payload);
11});
12
13source.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});
Custom Event Types

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.

custom-events.js
JavaScript
1source.addEventListener("progress", (event) => {
2 const { percent } = JSON.parse(event.data);
3 setProgress(percent);
4});
5
6source.addEventListener("heartbeat", () => {
7 lastBeat = Date.now();
8});
9
10source.addEventListener("done", (event) => {
11 finishJob(JSON.parse(event.data));
12 source.close();
13});
custom-events-stream.txt
TEXT
1event: progress
2data: {"percent":42}
3
4event: heartbeat
5data: ok
6
7event: done
8data: {"id":"job_123","status":"succeeded"}
lastEventId & Reconnection

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.

last-event-id.txt
TEXT
1id: 1001
2event: order
3data: {"orderId":"A-1001","status":"shipped"}
4
5id: 1002
6event: order
7data: {"orderId":"A-1002","status":"packed"}
resume-from-last-id.js
JavaScript
1// Server (Express-style) — resume from Last-Event-ID
2app.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

Always assign monotonic event IDs for durable streams (orders, notifications, audit feeds). Without IDs, clients reconnect blind and may miss updates that occurred during downtime.
The retry Field

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.

retry-field.txt
TEXT
1retry: 3000
2
3data: {"hello":true}
📝

note

Clients can still call close() to stop forever. retry only affects automatic reconnection behavior.
Server Stream Format

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.

FieldPurpose
dataPayload string (multiple data lines join with \\n)
eventCustom event type name
idEvent ID for Last-Event-ID resume
retryReconnect delay in ms
: commentIgnored heartbeat / annotation
sse-wire-format.txt
TEXT
1HTTP/1.1 200 OK
2Content-Type: text/event-stream
3Cache-Control: no-cache
4Connection: keep-alive
5
6: heartbeat
7
8retry: 5000
9
10id: 42
11event: notify
12data: {"title":"Deploy finished"}
13data: {"env":"production"}
14
15id: 43
16data: plain message with default type
🔥

pro tip

Multiline data: lines are concatenated with a newline. Prefer a single JSON object on one data: line for simpler client parsing.
Node / Express Example

Disable response buffering, set SSE headers, write frames, and clear timers when the client disconnects. Flush after each write when behind proxies that buffer.

express-sse.js
JavaScript
1import express from "express";
2
3const app = express();
4let seq = 0;
5
6app.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
25app.listen(3000);
Next.js Route Handler Example

In the App Router, return a ReadableStream with SSE headers from a route handler. Respect request.signal for cancellation when the client disconnects.

app/api/events/route.ts
TypeScript
1// app/api/events/route.ts
2export const dynamic = "force-dynamic";
3
4export 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

Some serverless platforms buffer or time out long responses. Prefer edge/runtime configs that support streaming, or run SSE on a long-lived Node server / separate events service.
Auth Limitations & Workarounds

Because EventSource cannot attach custom headers, Bearer-token APIs need an alternative. Common patterns:

ApproachProsCons
HttpOnly cookieWorks with EventSource + withCredentialsCSRF considerations on cookie APIs
Query tokenSimpleLeaks into logs/Referer — use short-lived tokens
fetch streamFull header controlDIY reconnect / Last-Event-ID
Ticket bootstrapPOST for one-time ticket, SSE with ticketExtra round trip
sse-ticket.js
JavaScript
1// Short-lived query ticket (server validates + expires quickly)
2async 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}
CORS

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.

sse-cors.js
JavaScript
1res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
2res.setHeader("Access-Control-Allow-Credentials", "true");
3res.setHeader("Content-Type", "text/event-stream");
Connection Limits

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

Do not open a new EventSource per widget on a dashboard. Fan out one authenticated stream in the client (or use a shared worker) and dispatch events by type.
Use Cases

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.

llm-sse-client.js
JavaScript
1const source = new EventSource("/api/chat/stream?run=abc");
2
3source.addEventListener("token", (event) => {
4 appendToTranscript(event.data);
5});
6
7source.addEventListener("done", () => {
8 source.close();
9 markRunComplete();
10});
Closing & Cleanup

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.

react-cleanup.js
JavaScript
1useEffect(() => {
2 const source = new EventSource("/api/events");
3 source.onmessage = (event) => setData(JSON.parse(event.data));
4 return () => source.close();
5}, []);
fetch Streaming Alternative

When you need Authorization headers or POST bodies, use fetch + ReadableStream and parse SSE frames yourself. You lose automatic reconnect unless you implement it.

fetch-sse.js
JavaScript
1async 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

Libraries like @microsoft/fetch-event-source wrap this pattern with reconnect and header support. Prefer native EventSource when cookies suffice.
Errors & Retry Strategies

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.

backoff.js
JavaScript
1async 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}
Security

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

SSE is still HTTP. Use HTTPS everywhere, set tight CORS origins, and do not put secrets in event payloads destined for the browser beyond what the user is allowed to see.
Best Practices
PracticeWhy
Send id: on durable eventsResume after disconnect without gaps
Heartbeat with : commentsDefeat idle timeouts
One stream per app surfaceRespect connection limits
JSON on a single data lineSimple, robust parsing
close() on unmountPrevent leaks and ghost reconnects
Set Cache-Control: no-cacheAvoid intermediary caching of streams
Prefer cookies or tickets for authEventSource cannot set Authorization
Disable response bufferingEvents arrive in real time

best practice

Design event schemas explicitly (event names + JSON version field). Evolving a free-form message dump becomes painful once multiple clients depend on the stream.
FAQ
📝

note

Can SSE send binary? Not natively. Base64-encode small binaries or use WebSockets / separate HTTP downloads for files.
📝

note

Does SSE work through CDNs? Yes if the CDN supports streaming responses and you disable buffering. Test heartbeats end-to-end.
📝

note

POST + EventSource? EventSource is GET-only. Use fetch streaming for POST bodies (common for LLM chat requests).

info

How do I test locally? Use curl: curl -N -H 'Accept: text/event-stream' http://localhost:3000/api/events

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.