Deep dive
How it works
In: a GET request, optionally with Last-Event-ID; then bytes of a UTF-8 text stream · Out: events {type, data, id} and reconnection-time updates · State: unfinished line, data/type buffers, last-id buffer (persists), skip-LF flag · Cost: O(bytes) time, O(longest line + event) memory
- Client requests the URL (
Accept: text/event-streammay be set). Anything but status 200 with that content type fails the connection permanently; HTTP 204 tells the client to stop reconnecting. - Decode as UTF-8, dropping one leading BOM. A line ends at CRLF, LF or lone CR; a CR at the end of a chunk may be followed by an LF in the next one.
- Empty line: dispatch. Line starting with
:: comment, ignored. - Otherwise split at the first
:; a single space after it is removed. No colon: the whole line is the field name, value empty. Names are case-sensitive. dataappends value + LF to the data buffer;eventsets the type;idsets the last-id buffer unless the value contains NUL;retrysets the reconnection time only if the value is ASCII digits (ms); other fields are ignored.- Dispatch: if the data buffer is empty, clear buffers and stop. Else strip the final LF, type defaults to
message, emit with the last-id buffer. Data and type reset; the id does not, so later events inherit it. - At end of stream a half-built event is discarded: the blank line is the commit.
- On drop the client waits the reconnection time (browsers may add backoff), then repeats with
Last-Event-ID: <last id>if non-empty. The server looks up that id in its log and replays what follows, then goes live.
Code
#[derive(Debug, PartialEq, Clone)]
pub struct Event { pub kind: String, pub data: String, pub id: String }
#[derive(Debug, PartialEq)]
pub enum Item { Event(Event), Retry(u64) }
#[derive(Default)]
pub struct Parser {
line: Vec<u8>, // bytes of the unfinished line
started: bool, // first line seen (BOM only allowed at stream start)
skip_lf: bool, // previous chunk ended in CR: swallow a following LF
data: String, kind: String,
id: String, // last event ID buffer: never reset by dispatch
}
impl Parser {
pub fn feed(&mut self, chunk: &[u8], out: &mut Vec<Item>) {
let mut i = 0;
while i < chunk.len() {
let b = chunk[i];
if b == b'\n' && self.skip_lf { self.skip_lf = false; i += 1; continue; }
self.skip_lf = false;
if b == b'\r' || b == b'\n' {
self.skip_lf = b == b'\r';
let line = std::mem::take(&mut self.line);
self.on_line(&line, out);
self.line = line; self.line.clear(); // reuse the allocation
i += 1;
} else {
let run = chunk[i..].iter().position(|&c| c == b'\r' || c == b'\n').unwrap_or(chunk.len() - i);
self.line.extend_from_slice(&chunk[i..i + run]);
i += run;
}
}
}
fn on_line(&mut self, raw: &[u8], out: &mut Vec<Item>) {
let mut raw = raw;
if !self.started { self.started = true; raw = raw.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(raw); }
let line = String::from_utf8_lossy(raw);
if line.is_empty() { return self.dispatch(out); }
if line.starts_with(':') { return; }
let (field, value) = match line.split_once(':') {
Some((f, v)) => (f, v.strip_prefix(' ').unwrap_or(v)), // strip ONE space
None => (&line[..], ""),
};
match field {
"event" => self.kind = value.into(),
"data" => { self.data.push_str(value); self.data.push('\n'); }
"id" if !value.contains('\0') => self.id = value.into(),
"retry" if !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit()) => {
if let Ok(ms) = value.parse() { out.push(Item::Retry(ms)); }
}
_ => {} // unknown fields (and id with NUL, bad retry) are ignored
}
}
fn dispatch(&mut self, out: &mut Vec<Item>) {
let (mut data, kind) = (std::mem::take(&mut self.data), std::mem::take(&mut self.kind));
if data.is_empty() { return; } // nothing buffered: no event
data.pop(); // the LF appended after the last data line
let kind = if kind.is_empty() { "message".into() } else { kind };
out.push(Item::Event(Event { kind, data, id: self.id.clone() }));
}
}
pub fn encode(e: &Event) -> String {
let mut s = String::new();
if e.kind != "message" { s += &format!("event: {}\n", e.kind); }
if !e.id.is_empty() { s += &format!("id: {}\n", e.id); }
for l in e.data.replace("\r\n", "\n").replace('\r', "\n").split('\n') { s += &format!("data: {l}\n"); }
s + "\n"
}
fn parse_all(input: &[u8], step: usize) -> Vec<Item> {
let (mut p, mut out) = (Parser::default(), vec![]);
for c in input.chunks(step) { p.feed(c, &mut out); }
out
}
fn ev(kind: &str, data: &str, id: &str) -> Item { Item::Event(Event { kind: kind.into(), data: data.into(), id: id.into() }) }
fn main() {
// WHATWG examples: multi-line data; comment, id persistence, id reset, leading space kept
let s = b"data: YHOO\ndata: +2\ndata: 10\n\n";
assert_eq!(parse_all(s, 1000), vec![ev("message", "YHOO\n+2\n10", "")]);
let s = b": test stream\n\ndata: first event\nid: 1\n\ndata:second event\nid\n\ndata: third event\n\n";
assert_eq!(parse_all(s, 1000), vec![ev("message", "first event", "1"), ev("message", "second event", ""), ev("message", " third event", "")]);
// empty data events; the last block has no blank line and is discarded
assert_eq!(parse_all(b"data\n\ndata\ndata\n\ndata:", 1000), vec![ev("message", "", ""), ev("message", "\n", "")]);
assert_eq!(parse_all(b"data:test\n\ndata: test\n\n", 1000), vec![ev("message", "test", ""), ev("message", "test", "")]);
// id persists to later events; event type resets; NUL id ignored; retry digits only; mixed endings; BOM
let s = b"\xEF\xBB\xBFid: 7\revent: tick\r\ndata: a\n\ndata: b\r\rretry: 1500\nretry: 15s\nid: x\0y\n\n";
let want = vec![ev("tick", "a", "7"), ev("message", "b", "7"), Item::Retry(1500)];
assert_eq!(parse_all(s, 1000), want);
for step in 1..s.len() { assert_eq!(parse_all(s, step), want, "step {step}"); } // CRLF, BOM, any split
let e = Event { kind: "tick".into(), data: "l1\nl2".into(), id: "9".into() };
assert_eq!(parse_all(encode(&e).as_bytes(), 3), vec![Item::Event(e)]);
}
Compiled with rustc 1.99.1 and run: every assertion passes, including the split-at-every-byte check.
Complexity and constants
- Measured by me on an Apple M4 Pro (release build, one core): 1,000,000 events of about 100 B data each (120 MB with framing), fed in 16 KiB chunks, parse at 220-280 MB/s, 1.9-2.4 M events/s over three runs. Untuned: it allocates per event.
- Framing costs
data:(6 B) plus a blank line per event. Server cost is per subscriber: one socket, one send buffer, one cursor into the log; the work scales with subscribers times event rate.
Efficiency
- Cost: parsing about 250 MB/s per core (measured above, M4 Pro, bound by allocation and line scanning); a client needs one socket and a few KiB of buffers; a server is bound by fan-out writes and send buffers, not the protocol. A heartbeat is 3 B (
:and two LF) before chunk and TLS overhead. - Levers:
- serialize an event once, write the same bytes to every subscriber — removes per-subscriber formatting — fan-out to many clients
- batch several events into one write — fewer syscalls and TLS records — bursts
- HTTP/2 — many streams on one connection, avoids the 6-connection cap (MDN) — many tabs per origin
- heartbeat every ~15 s rather than every second — idle cost stays near zero (WHATWG advice) — idle streams
- Naive → standard → state of the art:
- poll every second · one request per client per second, mostly "nothing"
- one SSE response per client, replay from a log by id · one open socket per client, events as they happen
- shared encoded bytes per event, fan-out from a ring buffer on epoll or io_uring, HTTP/2 · one encode per event, one write per subscriber
Implementation notes
- Headers:
Content-Type: text/event-stream,Cache-Control: no-cache; flush after every event. For nginx sendX-Accel-Buffering: noor setproxy_buffering off(nginx docs: the header enables or disables buffering per response, unlessproxy_ignore_headersblocks it). Raise the proxy read timeout above the heartbeat period. - Resume: give every event a monotonic id (log offset or sequence number). On
Last-Event-IDreplay from id+1 if still retained; if not, send a "reset" event so the client refetches state. Wikimedia uses timestamps in ids because offsets differ across its Kafka clusters. - Ids must not contain NUL, CR or LF. Escape newlines in data by writing one
data:line per line. - Use
retry:to tell clients to wait longer when you shed load; add server-side jitter, see backoff. - A slow reader must not block the producer: bound the per-subscriber queue and drop or disconnect, see backpressure.
Pitfalls
- Proxy or framework buffering holds events until a buffer fills; the stream looks dead and then bursts.
- Compression middleware (gzip) can also hold bytes until its block fills; flush the encoder per event or exclude
text/event-stream(from memory, not verified here). - Idle timeouts: Wikimedia's termination layer cuts connections at 15 minutes, so clients must resume by
Last-Event-ID. - Browser limit of about 6 connections per origin on HTTP/1.1 (MDN) starves later tabs.
EventSourcecannot send headers (e.g.Authorization) or a body; use cookies, a token in the URL (it leaks into logs) or read the stream withfetchand parse it yourself.- Assuming exactly-once: a resume can repeat the last event; clients dedupe by id.
- Events without
id: a drop resumes from the older id and replays the id-less ones.
Variants and alternatives
fetchPOST and headers allowed, but no standard resume.In production
Anthropic's Messages API streams responses as named SSE events with periodic ping events; Wikimedia EventStreams serves its recent-changes feeds as SSE with ids that encode Kafka positions or timestamps; EventSource is in all current browser engines (spec page). LaunchDarkly's SDKs use SSE for flag streams (from memory).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
axum Sse | Rust | tokio-rs/axum | cargo add axum | — | — | — | — | axum::response::sse: Sse response, Event, KeepAlive |
| eventsource-client | Rust | launchdarkly/rust-eventsource-client | cargo add eventsource-client | — | — | — | — | client with reconnect and Last-Event-ID |
| reqwest-eventsource | Rust | jpopesculian/reqwest-eventsource | cargo add reqwest-eventsource | — | — | — | — | EventSource over reqwest; last release 2024-03 |
| sse-starlette | Python | sysid/sse-starlette | pip install sse-starlette | — | — | — | — | EventSourceResponse for Starlette/FastAPI |
| go-sse | Go | tmaxmax/go-sse | go get github.com/tmaxmax/go-sse | — | — | — | — | client and server; sse.Read parses POST-based model streams |
Sources
- WHATWG, HTML Living Standard, section 9.2 Server-sent events. spec
- MDN, Using server-sent events. docs
- Wikimedia, Event Platform/EventStreams HTTP Service. docs
- Anthropic, Streaming Messages. docs
- nginx, Module ngx_http_proxy_module (
proxy_buffering,X-Accel-Buffering). docs - Loreto et al., Known Issues and Best Practices for the Use of Long Polling and Streaming in Bidirectional HTTP, RFC 6202, 2011. RFC