Skip to content
rusthacks.dev
Web

Axum Server Sent Events for Live Updates Without WebSockets

Server sent events push updates from Axum to the browser over plain HTTP. See Sse, Event, KeepAlive, streams from channels, reconnect with Last-Event-ID and proxy gotchas.

8 min read
Summarize in AI
Flat illustration for the post “Axum Server Sent Events for Live Updates Without WebSockets”, composed around web.

Most "real-time" features do not need a bidirectional protocol. A build progress bar, a notification count, a live dashboard, an AI response streaming token by token: the server has news and the browser listens. Server-sent events fit that shape with far less machinery than WebSockets, because an SSE stream is a normal HTTP response that does not end. The browser API handles reconnection for you, intermediaries understand it as HTTP and the server side in Axum is one return type. This post builds a ticker and a publish-subscribe endpoint, shows the exact bytes on the wire, covers the three infrastructure traps (buffering, idle timeouts and auth) and says when to pick WebSockets instead. Tested on axum 0.8.9, tokio-stream 0.1 and Rust 1.99.

The wire format is five lines of text

An event stream is a response with the content type text/event-stream whose body is a series of events separated by blank lines. Each event is made of fields, one per line: id, event (a name for the listener to subscribe to), data (the payload, repeated for multiple lines) and retry. A line starting with a colon is a comment, which servers use as a heartbeat. That is the entire format, which means curl -N is a complete client for debugging. Here is a handler that emits an event every 300 ms:

src/main.rsrust
use std::{convert::Infallible, time::Duration};

use axum::{
    Router,
    response::sse::{Event, KeepAlive, Sse},
    routing::get,
};
use futures_util::{Stream, StreamExt};
use tokio_stream::wrappers::IntervalStream;

async fn ticker() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let mut n = 0u32;
    let stream = IntervalStream::new(tokio::time::interval(Duration::from_millis(300))).map(move |_| {
        n += 1;
        Ok(Event::default().id(n.to_string()).event("tick").data(format!("count {n}")))
    });
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)).text("keep-alive"))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/ticker", get(ticker));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
Shell
curl -N -i -m 1.1 localhost:3000/ticker
Text
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
transfer-encoding: chunked

id: 1
event: tick
data: count 1

id: 2
event: tick
data: count 2

id: 3
event: tick
data: count 3

Axum sets the content type and cache-control: no-cache for you and uses chunked transfer encoding, which is how a response of unknown length is sent in HTTP/1.1. The stream's item type is Result<Event, E>; the error type must implement Into<BoxError>, and Infallible says "this stream never fails", which is the honest signature for a ticker. Dependencies are axum, tokio, tokio-stream (with the time and sync features) and futures-util. The browser end is similarly small:

app.jsJavaScript
const source = new EventSource("/ticker");
source.addEventListener("tick", (e) => {
  console.log("tick", e.data, "last id", e.lastEventId);
});
source.onerror = () => console.log("connection lost, the browser will retry");

EventSource reconnects on its own after a drop, waiting a few seconds, and sends the last id it saw in a Last-Event-ID request header. That is the feature that makes SSE pleasant: resumption is in the protocol. Honoring it is your job on the server. Use the id as a cursor, the database row id or a sequence number, and when a client reconnects with Last-Event-ID: 41, replay events after 41 before switching to live ones. A stream that ignores the header loses everything that happened while the client was disconnected, a mobile client on a train will drop constantly, and so replay is not optional for anything that matters.

Fan out to many clients with a broadcast channel

A ticker is the same for everyone. Real use is events produced by something else (a handler, a background job) and delivered to every connected listener. tokio::sync::broadcast is built for that: one sender, any number of receivers, each of which sees every message sent after it subscribed.

src/main.rsrust
use axum::{extract::State, http::HeaderMap, routing::post};
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;

#[derive(Clone)]
struct AppState {
    tx: broadcast::Sender<String>,
}

async fn events(
    State(s): State<AppState>,
    headers: HeaderMap,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    if let Some(last) = headers.get("last-event-id") {
        println!("client resumed after {last:?}"); // replay from your store here
    }
    let rx = s.tx.subscribe();
    let stream = BroadcastStream::new(rx).filter_map(|msg| async move {
        match msg {
            Ok(text) => Some(Ok(Event::default().event("message").data(text))),
            Err(_lagged) => {
                Some(Ok(Event::default().event("lagged").data("some messages were dropped")))
            }
        }
    });
    Sse::new(stream).keep_alive(KeepAlive::default())
}

async fn publish(State(s): State<AppState>, body: String) -> &'static str {
    let _ = s.tx.send(body);
    "sent"
}

// in main: let (tx, _rx) = broadcast::channel(16);
// Router::new().route("/events", get(events)).route("/publish", post(publish)).with_state(AppState { tx })
Shell
curl -N localhost:3000/events -H 'last-event-id: 41' &
curl -X POST localhost:3000/publish -d hello
curl -X POST localhost:3000/publish -d world
Text
event: message
data: hello

event: message
data: world

The server's own log also printed client resumed after "41", which shows the header arriving. Three details deserve attention. The channel capacity (16 here) is how many messages a slow receiver may fall behind before it loses some: when a receiver lags, the next read returns Lagged(n) and the stream above turns it into a lagged event so the client can refetch state, since silently skipping messages is worse than telling it. A subscriber that arrives later does not see earlier messages, so a new connection must load the current state some other way, then listen. And when a client disconnects, Axum drops the response body, which drops the stream and the BroadcastStream and its receiver, so there is nothing to clean up by hand; this is the benefit of tying the lifetime of the subscription to the response. Choose the channel type by the shape of the traffic, as laid out in Tokio channels compared: broadcast for fan-out of every message, watch for "only the latest value matters" such as a progress percentage, and one mpsc per client when each needs different data.

The three infrastructure traps

Buffering. The demo works against localhost and fails in production in the same way: the page receives nothing, then ten events at once. A reverse proxy is buffering the response. Nginx buffers by default and needs proxy_buffering off for the location, or the response header X-Accel-Buffering: no, which you can set from Axum with a header layer on the route. Compression layers also hold output until a buffer fills, so exclude text/event-stream from CompressionLayer (the tower-http predicate does this by default for the event-stream type in recent versions, but verify). A CDN may need streaming enabled for the path.

Idle timeouts. Proxies and load balancers close connections that carry no bytes for 30 to 120 seconds. KeepAlive sends a comment line on an interval (: keep-alive), which the browser ignores and the network treats as traffic. Set the interval to well under the shortest idle timeout on the path, 15 seconds is a safe default. Also check any request timeout layer: a TimeoutLayer over the router (timeouts and body size caps) will kill every stream at its deadline, so put the SSE route in a router without it.

Connection limits. Over HTTP/1.1, browsers allow about six connections per host, and every open EventSource uses one. Several tabs, each with a stream, exhaust the budget and the page stops loading other resources. HTTP/2 multiplexes streams on one connection and removes the problem, so terminate TLS with HTTP/2 enabled in front of the service. Server side, each connected client holds a task and a socket, which is cheap in Tokio, tens of thousands per instance with a raised file descriptor limit, but it also means a restart disconnects everyone simultaneously and the reconnect storm lands on the new process. Add jitter on the client by setting the retry field to a randomized value.

Authentication and authorization

EventSource cannot set request headers, so a bearer token in Authorization is not available. Same-origin pages send cookies automatically, so a session cookie works and is the simplest option; if you use cookies, the CSRF considerations in session cookies and CSRF protection apply to the subscription endpoint, although a GET that only reads has little to forge. For cross-origin or non-cookie clients, issue a short-lived, single-use token through an authenticated call and pass it as a query parameter, then expire it after the connection is made, since URLs end up in logs. Or use fetch with a streaming reader instead of EventSource and give up the automatic reconnect. Whatever the method, check authorization per event, not only at connect time: a user removed from a group should stop receiving that group's events even on a connection opened an hour ago.

SSE or WebSockets

NeedChoose
Server pushes updates, client rarely talksSSE
Auto-reconnect and resume with minimal codeSSE
Works through any HTTP proxy, plain logging and authSSE
Client sends frequent messages (chat, games, collaborative editing)WebSocket
Binary payloadsWebSocket (SSE is text only)
Many streams per page on HTTP/1.1WebSocket, or move to HTTP/2

A client can send data alongside an SSE stream with ordinary POST requests, as the publish endpoint does, which covers many apps that first look like they need WebSockets. For true two-way traffic, see WebSockets with broadcast. Payloads in events are plain text, so JSON-encode structured data into data (Event::json_data does it), and avoid newlines in the raw string, which the format splits across multiple data lines. Testing the stream means reading from the response body incrementally in a test, the technique in testing with Tower oneshot, and the structure of the surrounding service is in an Axum production service.

When SSE is not worth it

If updates arrive every few minutes and a slightly stale page is fine, polling an endpoint with If-None-Match is simpler, cache-friendly and needs no long-lived connections. If you must support clients behind an enterprise proxy that buffers or breaks streaming responses, polling is the fallback that always works. And a stream that carries a huge number of messages per second should be batched on the server, one event per 100 ms with the whole delta, instead of one event per change, or the cost moves from your server to every client's event loop.

Taggedaxumsse
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.