X

Debug Server-Sent Events right in your browser

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.

⚡ 100% client-side 🔧 Custom headers & POST 📖 Built-in SSE guide 🆓 Free & open source

Request

Presets: · ·
Equivalent cURL›
Idle Events 0 Bytes 0 Elapsed 0.0s TTFB — Rate 0/s

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.

Server-Sent Events: A Practical Guide

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.

1. What is SSE?

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.

2. The wire format

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:

Minimal event
data: hello world
Named event with id
event: user-joined
id: 42
data: {"name": "Alice"}
Multi-line data
data: first line
data: second line

Rules that surprise people

3. SSE vs WebSocket vs polling

SSEWebSocketLong polling
DirectionServer → clientBidirectionalClient pulls
ProtocolHTTP/1.1+, textws:// custom, text + binaryHTTP
Auto-reconnect✅ built-in❌ manualmanual
Infrastructureplain HTTP — proxies/CDN friendlyneeds Upgrade supportplain HTTP
Auth headerseasy (fetch), EventSource is limitedlimitedeasy
Typical usefeeds, notifications, LLM streamingchat, games, collaborationlegacy 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).

4. Client-side: EventSource & fetch

EventSource — the simple path

// 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

fetch + ReadableStream — full control

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.

5. Server-side examples

Node.js (Express)

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));
});

Python (FastAPI)

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")

Go (net/http)

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)
}

A minimal raw response

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.

6. Multi-platform examples

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.

Web — a complete minimal page

<!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>

Swift — iOS (URLSession, iOS 15+)

// 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
        }
    }
}

Kotlin — Android (OkHttp)

// 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
    }
})

7. Common pitfalls

8. FAQ

Does SSE work over HTTP/2?
Yes — and it works better. HTTP/2 multiplexes many streams over one TCP connection, removing the browser's 6-connections-per-origin limit and the head-of-line blocking concerns of HTTP/1.1. SSE still requires HTTP/1.1-compatible text framing, which HTTP/2 handles fine.
Can I send POST requests with SSE?
Not with the native EventSource API — it only does GET without custom headers. But SSE is just a streaming HTTP response, so you can consume it with fetch + ReadableStream and use any method, headers, or body. Try it in the Debugger tab: choose POST and add your headers.
How do I debug an SSE endpoint that returns nothing?
Check four things: (1) the response Content-Type is exactly text/event-stream; (2) each event ends with a blank line (\n\n); (3) no proxy or compression middleware is buffering the stream — add X-Accel-Buffering: no; (4) CORS: if the stream is cross-origin, the server must send Access-Control-Allow-Origin. This tool's "Response headers" panel shows all of them at a glance.
Is my data safe with this tool?
Yes. SSE Inspector is a single static HTML file — there is no backend. Your requests go directly from your browser to the endpoint you enter, and history/settings stay in your browser's localStorage. Nothing is logged or proxied anywhere.
Why did my event stream stop after a while?
Most likely an intermediary (load balancer, CDN, or proxy) timed out an idle connection. Keep the stream warm with comment heartbeats like ": ping\n\n" every 10–20 seconds, and rely on reconnection with Last-Event-ID for anything lost in between.