Connect to any SSE endpoint with custom headers, method and body. Watch events arrive in real time, inspect the raw stream, and learn the protocol — all client-side, nothing leaves your machine.
No events yet. Enter an SSE endpoint URL on the left and hit Connect — or click the demo link to see a live stream instantly.
Everything a developer needs to know about SSE — the wire format, how it compares to WebSockets, working client and server code, and the pitfalls that bite everyone at least once.
Server-Sent Events (SSE) is a standard for servers to push data to browsers over plain HTTP. The client makes a single GET request, the server responds with Content-Type: text/event-stream and simply keeps writing. The connection stays open indefinitely, and each "event" is a small block of text.
SSE is a one-way street: server → client only. If the client needs to send data, it issues ordinary HTTP requests alongside. This sounds like a limitation, but for most push scenarios — notifications, live dashboards, AI/LLM token streaming — it is exactly what you want, and it is radically simpler than a bidirectional socket.
EventSource API has existed since 2006 and works everywhere.Last-Event-ID resume mechanism.An SSE stream is UTF-8 text. The response body is a sequence of events separated by blank lines. Each line inside an event is field: value. There are only four fields:
data: hello worldevent: user-joined
id: 42
data: {"name": "Alice"}data: first line
data: second line
data — the payload. Multiple data: lines are joined with \n. An event is dispatched when a blank line is reached.event — the event type, received by addEventListener(type). Defaults to message.id — stored by the browser and sent back as the Last-Event-ID header after a reconnect, letting the server resume where it left off.retry — reconnection interval in milliseconds, a hint from the server.: are comments — commonly used as keep-alive heartbeats (: ping).data:foo and data: foo are identical.\n, \r\n or \r — all are valid separators.\n\n.| SSE | WebSocket | Long polling | |
|---|---|---|---|
| Direction | Server → client | Bidirectional | Client pulls |
| Protocol | HTTP/1.1+, text | ws:// custom, text + binary | HTTP |
| Auto-reconnect | ✅ built-in | ❌ manual | manual |
| Infrastructure | plain HTTP — proxies/CDN friendly | needs Upgrade support | plain HTTP |
| Auth headers | easy (fetch), EventSource is limited | limited | easy |
| Typical use | feeds, notifications, LLM streaming | chat, games, collaboration | legacy fallback |
Rule of thumb: if data only flows from server to client, prefer SSE. Reach for WebSocket only when the client must push frequently and with low latency (typing indicators, game state).
// GET-only, no custom headers. Auto-reconnects for you.
const es = new EventSource("https://example.com/events");
es.onmessage = (e) => console.log("message:", e.data);
es.addEventListener("user-joined", (e) => console.log(e.data));
es.onerror = () => console.log("connection lost; browser will retry");
// es.readyState: 0 connecting, 1 open, 2 closed
When you need POST, custom headers (e.g. Authorization: Bearer …), or GET/POST SSE APIs like OpenAI's, consume the stream manually. This is exactly what the debugger on this page does:
// fetch-based SSE consumption with POST + headers
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
body: JSON.stringify({ prompt: "hello" }),
signal: controller.signal,
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// split on blank lines (\n\n) — see the format section
let idx;
while ((idx = buf.indexOf("\n\n")) !== -1) {
const block = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const data = block.split("\n")
.filter(l => l.startsWith("data:"))
.map(l => l.slice(5).trimStart())
.join("\n");
if (data) handleEvent(data);
}
}
Trade-offs: you give up the browser's automatic reconnection and Last-Event-ID handling — re-implement them if the server sends ids. In exchange you get full control over method, headers and error handling.
app.get("/events", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
res.flushHeaders();
let n = 0;
const timer = setInterval(() => {
res.write(`id: ${++n}\nevent: tick\ndata: ${JSON.stringify({ n, t: Date.now() })}\n\n`);
}, 1000);
req.on("close", () => clearInterval(timer));
});
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio, json
app = FastAPI()
@app.get("/events")
async def events():
async def gen():
n = 0
while True:
n += 1
yield f"id: {n}\nevent: tick\ndata: {json.dumps({'n': n})}\n\n"
await asyncio.sleep(1)
return StreamingResponse(gen(), media_type="text/event-stream")
package main
import (
"fmt"
"net/http"
"time"
)
func events(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher) // response writer must support streaming
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
for i := 1; ; i++ {
fmt.Fprintf(w, "id: %d\nevent: tick\ndata: {\"n\": %d}\n\n", i, i)
flusher.Flush() // flush after every event, or the client sees nothing
time.Sleep(time.Second)
}
}
func main() {
http.HandleFunc("/events", events)
http.ListenAndServe(":8080", nil)
}
Any server that can stream a response can speak SSE. The three essential headers are Content-Type: text/event-stream, Cache-Control: no-cache and (for HTTP/1.1) Connection: keep-alive. Then just write data: …\n\n and flush.
Web examples live in section 4 above; here is a complete minimal page plus native iOS and Android clients. All of them consume the same plain HTTP stream — and on every platform outside a browser, reconnection and Last-Event-ID handling are your responsibility.
<!DOCTYPE html>
<html>
<body>
<ul id="log"></ul>
<script>
const es = new EventSource("/events"); // same-origin GET endpoint
es.onmessage = (e) => {
const li = document.createElement("li");
li.textContent = e.data; // e.lastEventId also available
document.getElementById("log").appendChild(li);
};
es.addEventListener("tick", (e) => console.log("named event:", e.data));
es.onerror = () => console.log("lost connection — browser retries automatically");
</script>
</body>
</html>
// No third-party dependency needed.
func connect() {
Task {
var req = URLRequest(url: URL(string: "https://example.com/events")!)
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
req.setValue("Bearer <token>", forHTTPHeaderField: "Authorization") // custom headers OK
req.timeoutInterval = .infinity
do {
let (bytes, _) = try await URLSession.shared.bytes(for: req)
var block = ""
for try await line in bytes.lines {
if line.isEmpty { // blank line = event boundary
let data = block.split(separator: "\n")
.filter { $0.hasPrefix("data:") }
.map { String($0.dropFirst(5)).drop(while: { $0 == " " }) }
.joined(separator: "\n")
if !data.isEmpty { print("event:", data) }
block = ""
} else {
block += line + "\n" // keep event:/id:/retry: lines too
}
}
} catch {
// reconnect manually; send the last id as "Last-Event-ID" if the server uses ids
}
}
}
// build.gradle: implementation("com.squareup.okhttp3:okhttp:4.12.0")
val request = Request.Builder()
.url("https://example.com/events")
.header("Accept", "text/event-stream")
.header("Authorization", "Bearer <token>") // custom headers OK
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { scheduleReconnect() }
override fun onResponse(call: Call, response: Response) {
response.use { resp ->
val source = resp.body?.source() ?: return
var dataLines = mutableListOf<String>()
while (!call.isCanceled()) {
val line = source.readUtf8Line() ?: break // null = stream closed
when {
line.startsWith("data:") -> dataLines += line.removePrefix("data:").trimStart()
line.isEmpty() -> { // blank line = event boundary
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n")
runOnUiThread { onEvent(data) } // marshal to the main thread
}
dataLines = mutableListOf()
}
}
}
}
scheduleReconnect() // OkHttp won't reconnect for you
}
})
X-Accel-Buffering: no on the response, or proxy_buffering off; for the location.text/event-stream — buffering + compression will delay every event. SSE bodies are tiny anyway.res.flushHeaders() and ensure compression middleware is bypassed; in Python ensure your WSGI/ASGI server actually streams.\n\n. No trailing blank line = client sees nothing.: ping\n\n) every ~15s.Access-Control-Allow-Origin on the response, just like any fetch.