Home › Networking › HTTP & RPC
algorithm low

Server-sent events

Problem A service must push a steady flow of small updates to many clients that only listen, over ordinary HTTP, and resume without gaps or duplicates after the connection drops, at each reconnect.

One long-lived HTTP response carries text events one way; each event may carry an id, so a reconnecting client names its last one and the server resumes.

parse O(bytes), O(1) state per stream; per event one write and one fan-out copy per subscriber
httppushstreamingtext-event-streamlast-event-ideventsourceheartbeat
Overview

The client sends one GET; the server answers 200 with Content-Type: text/event-stream and never finishes the body. Each event is a few field: value text lines ended by a blank line. An optional id is the resume cursor: after a drop the browser reconnects with Last-Event-ID and the server continues from there. No framing layer, no upgrade, plain HTTP.

Key facts

Use when

  • Token-by-token model output (Anthropic Messages API stream: true), live dashboards and tickers, notification feeds.
  • Subscribing to a public change feed (Wikimedia EventStreams), job or CI log tailing, device telemetry to a console.
  • You want push through existing HTTP infrastructure, auth and caching rules.

Avoid when

  • The client must send a stream back or the payload is binary: use WebSockets.
  • Updates are rare and proxies are hostile: long polling is simpler; the receiver is a server: webhooks.

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

  1. Client requests the URL (Accept: text/event-stream may be set). Anything but status 200 with that content type fails the connection permanently; HTTP 204 tells the client to stop reconnecting.
  2. 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.
  3. Empty line: dispatch. Line starting with :: comment, ignored.
  4. 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.
  5. data appends value + LF to the data buffer; event sets the type; id sets the last-id buffer unless the value contains NUL; retry sets the reconnection time only if the value is ASCII digits (ms); other fields are ignored.
  6. 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.
  7. At end of stream a half-built event is discarded: the blank line is the commit.
  8. 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 send X-Accel-Buffering: no or set proxy_buffering off (nginx docs: the header enables or disables buffering per response, unless proxy_ignore_headers blocks 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-ID replay 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.
  • EventSource cannot send headers (e.g. Authorization) or a body; use cookies, a token in the URL (it leaks into logs) or read the stream with fetch and 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

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

namelangrepoinstallverdictlicencehealthas_ofnotes
axum SseRusttokio-rs/axumcargo add axum————axum::response::sse: Sse response, Event, KeepAlive
eventsource-clientRustlaunchdarkly/rust-eventsource-clientcargo add eventsource-client————client with reconnect and Last-Event-ID
reqwest-eventsourceRustjpopesculian/reqwest-eventsourcecargo add reqwest-eventsource————EventSource over reqwest; last release 2024-03
sse-starlettePythonsysid/sse-starlettepip install sse-starlette————EventSourceResponse for Starlette/FastAPI
go-sseGotmaxmax/go-ssego 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

Used by

model context protocol · plannedJSON-RPC protocol exposing tools, resources and prompts from servers to LLM clients, with capability negotiation

Systems (19):

Alternatives and related

WebSocketsWeb apps need the server to push updates to a browser the moment they happen, and the browser to answer on the same channel, without a new request for every update.Long pollingA client behind ordinary HTTP infrastructure needs to learn about server-side changes within moments, without the server being able to call it and without wasting a request every second on "nothing yet".HTTP/2Clients and servers that exchange many small requests over one connection need them to proceed independently, without one slow response blocking the rest or repeated headers wasting bytes, while one peer cannot exhaust the other's memory.Reverse proxyA service runs on several servers but clients must see one address. Something has to accept their connections, pick a healthy server per request, forward it and relay the answer, without hiding failures or holding whole bodies in memory.HTTP/1.1 message parsingA program reads HTTP/1.1 from a TCP connection (server, proxy or crawler); bytes arrive in arbitrary pieces, and it must find where each message ends in exactly the way every other hop does.Webhook deliveryPlatforms must tell their customers' own servers that something happened, though those servers may be down, slow or hostile, and customers must be able to trust that a call really came from the platform.Pub/sub fan-outA publisher must deliver one message to many live subscribers, such as chat rooms, presence updates or price ticks, without a single slow subscriber exhausting the broker's memory.BackpressureA component receives work faster than a downstream consumer can handle it, in a network stack, stream job, log agent or realtime server, and must stop memory and latency growing without limit.Exponential backoff with jitterClients of a remote service that fails or throttles must retry in a way that does not hammer it in synchronized waves while it recovers: timeouts, 429/503 errors, reconnect loops.HTTP caching semanticsA client, proxy or edge server holds a copy of a fetched resource and must decide, for each new request, whether it may reuse the copy, must ask the origin whether it changed, or must refetch it.