Lesson 25 of 30
WebSockets and Server-Sent Events in Axum
WebSockets and Server-Sent Events (SSE) keep a connection open so the server can push updates. This lesson builds a live order-status feed for the Storefront with both.
- WebSocket: two-way messages over an upgraded HTTP connection. Reconnect is written by the application.
- SSE: one-way events over one long HTTP response. The browser
EventSourcereconnects on its own.
Project setup
Cargo.toml
[package]
name = "storefront-live"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["sync"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"No database or broker is needed. Events live in memory. For a WebSocket client from the terminal, install websocat.
Example 1: The smallest WebSocket handler
A WebSocket starts as an HTTP GET with Upgrade: websocket. The server answers 101 Switching Protocols, and the same TCP connection then carries WebSocket frames (text, binary, ping, pong, close).
src/main.rs
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
response::Response,
routing::get,
Router,
};
async fn ws_handler(upgrade: WebSocketUpgrade) -> Response {
upgrade.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(Ok(message)) = socket.recv().await {
match message {
Message::Text(text) => {
let reply = format!("echo: {text}");
if socket.send(Message::Text(reply.into())).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/ws", get(ws_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}WebSocketUpgradeis an extractor.on_upgradereturns the101response at once and runshandle_socketas a separate task afterwards.recv()returnsOption<Result<Message, Error>>.Nonemeans the connection closed.Message::TextholdsUtf8Bytesin Axum 0.8..into()converts fromString.- A failed send means the client is gone, so the loop ends.
- Axum answers ping frames automatically. A peer that vanishes without a close frame is detected only by a failed read or write, so servers send their own pings on a timer.
Example 2: Broadcast to many WebSocket clients
A tokio::sync::broadcast channel has one sender and one receiver per connection. Each receiver sees every message sent after it subscribed. WebSocket implements Stream and Sink, so split() gives a sender half and a receiver half that run in separate tasks.
This example replaces src/main.rs. POST /publish sends the request body to every connected client.
src/main.rs
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::Response,
routing::{get, post},
Router,
};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::broadcast::{self, error::RecvError};
async fn ws_handler(
upgrade: WebSocketUpgrade,
State(tx): State<broadcast::Sender<String>>,
) -> Response {
upgrade.on_upgrade(move |socket| handle_socket(socket, tx))
}
async fn handle_socket(socket: WebSocket, tx: broadcast::Sender<String>) {
let (mut sender, mut receiver) = socket.split();
let mut events = tx.subscribe();
let mut send_task = tokio::spawn(async move {
loop {
match events.recv().await {
Ok(text) => {
if sender.send(Message::Text(text.into())).await.is_err() {
return;
}
}
Err(RecvError::Lagged(_)) => {
let _ = sender.send(Message::Text("resync".into())).await;
return;
}
Err(RecvError::Closed) => return,
}
}
});
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await {
if matches!(message, Message::Close(_)) {
return;
}
}
});
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
}
}
async fn publish(State(tx): State<broadcast::Sender<String>>, body: String) {
let _ = tx.send(body);
}
#[tokio::main]
async fn main() {
let (tx, _) = broadcast::channel::<String>(64);
let app = Router::new()
.route("/ws", get(ws_handler))
.route("/publish", post(publish))
.with_state(tx);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}- The channel keeps the last 64 messages. A receiver that falls further behind gets
RecvError::Lagged(n). This handler tells the client to resync and closes the connection. select!waits for the first task to finish and aborts the other. Without it, a closed client leaves the send task running until its next failed send.tx.sendreturns an error when no receiver exists.let _ =ignores it.- This design is per process. With several instances, each instance subscribes to a shared source (Redis pub/sub or a topic from messaging and Kafka) and forwards into its local channel.
Example 3: Server-Sent Events
SSE is a response with Content-Type: text/event-stream that never ends. Each event is text lines ended by a blank line.
id: 42
event: order_status
data: {"order_id":7,"status":"paid"}
data: the payload.event: a name thatEventSourcedispatches to a listener.id: the id the browser remembers.retry: reconnect delay in milliseconds. A line that starts with:is a comment used as keep-alive.EventSourcesends onlyGETand cannot set headers, so it authenticates with cookies. Clients that need anAuthorizationheader read the stream withfetch.
This example replaces src/main.rs with the same broadcast channel served as SSE.
src/main.rs
use axum::{
extract::State,
response::sse::{Event, KeepAlive, Sse},
routing::{get, post},
Router,
};
use std::convert::Infallible;
use tokio::sync::broadcast;
use tokio_stream::{wrappers::BroadcastStream, Stream, StreamExt};
async fn events(
State(tx): State<broadcast::Sender<String>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = BroadcastStream::new(tx.subscribe())
.map_while(|item| item.ok())
.map(|text| Ok(Event::default().event("order_status").data(text)));
Sse::new(stream).keep_alive(KeepAlive::default())
}
async fn publish(State(tx): State<broadcast::Sender<String>>, body: String) {
let _ = tx.send(body);
}
#[tokio::main]
async fn main() {
let (tx, _) = broadcast::channel::<String>(64);
let app = Router::new()
.route("/events", get(events))
.route("/publish", post(publish))
.with_state(tx);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}map_whileends the stream on a lag error. The browser then reconnects, which Example 5 turns into a resume.KeepAlivewrites comment lines at intervals so proxies do not close an idle response. The proxy idle timeout must exceed that interval.- Choose SSE when the client only listens and HTTP-only proxies are in the path. Choose WebSocket for frequent client messages or binary payloads.
Example 4: Origin check and ticket authentication
Checks run during the handshake, before on_upgrade, because a WebSocket outlives the request.
- Browsers attach
Originto WebSocket handshakes and apply no same-origin policy. Without an allow list, a page on another site can open a socket with the user's cookies (cross-site WebSocket hijacking). - Browser JavaScript cannot set
Authorizationon a WebSocket. A short-lived, single-use ticket in the query string solves this: an authenticated REST call issues it, and the handshake redeems it. Token checks follow authentication and authorization and OAuth, JWT and mTLS.
src/auth.rs
use axum::http::{header, HeaderMap, StatusCode};
use std::{collections::HashMap, sync::Mutex};
const ALLOWED_ORIGINS: [&str; 1] = ["http://localhost:3000"];
pub fn check_origin(headers: &HeaderMap) -> Result<(), StatusCode> {
let origin = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
.ok_or(StatusCode::FORBIDDEN)?;
if ALLOWED_ORIGINS.contains(&origin) {
Ok(())
} else {
Err(StatusCode::FORBIDDEN)
}
}
#[derive(Default)]
pub struct Tickets {
inner: Mutex<(u64, HashMap<String, u64>)>,
}
impl Tickets {
pub fn issue(&self, user_id: u64) -> String {
let mut guard = self.inner.lock().unwrap();
guard.0 += 1;
let ticket = format!("t{}", guard.0);
guard.1.insert(ticket.clone(), user_id);
ticket
}
pub fn redeem(&self, ticket: &str) -> Option<u64> {
self.inner.lock().unwrap().1.remove(ticket)
}
}redeemremoves the ticket, so it works once. Production tickets are random, expire in seconds, and live in Redis (Redis caching). The counter here keeps the demo output fixed.- The user id returned by
redeemis also the identity for per-message authorization: a subscribe request for an order must check that the user owns it, as a REST handler does.
Example 5: Resume with ids and a backlog
After a drop, EventSource reconnects with the header Last-Event-ID. A WebSocket client sends its last id itself, here as ?after=N. The server replays stored events after that id, then continues live.
Two orderings prevent gaps and duplicates. Subscribing and copying the backlog happen under one lock, and publishing sends on the channel under the same lock.
src/state.rs
use serde::Serialize;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
#[derive(Clone, Serialize)]
pub struct OrderEvent {
pub id: u64,
pub order_id: u64,
pub status: String,
}
#[derive(Clone)]
pub struct Feed {
log: Arc<Mutex<Vec<OrderEvent>>>,
tx: broadcast::Sender<OrderEvent>,
}
impl Feed {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(64);
Feed { log: Arc::new(Mutex::new(Vec::new())), tx }
}
pub fn publish(&self, order_id: u64, status: String) -> OrderEvent {
let mut log = self.log.lock().unwrap();
let event = OrderEvent { id: log.len() as u64 + 1, order_id, status };
log.push(event.clone());
let _ = self.tx.send(event.clone());
event
}
pub fn subscribe_from(&self, after: u64) -> (Vec<OrderEvent>, broadcast::Receiver<OrderEvent>) {
let log = self.log.lock().unwrap();
let receiver = self.tx.subscribe();
let backlog = log.iter().filter(|event| event.id > after).cloned().collect();
(backlog, receiver)
}
}Ids come from a counter here. In the Storefront they come from the outbox sequence in SQL transactions.
Backpressure and slow clients
Backpressure means a full buffer slows or stops the producer instead of growing without limit. Options for a client that cannot keep up:
- Drop old messages and keep the latest. Fits state updates.
- Send a resync message so the client refetches state. Fits after
Lagged. - Close the connection and let the client reconnect. Fits stalled clients.
- Wrap every send in a timeout. One client must never block a loop that serves others.
- Cap message size (
max_message_size), connections per user, and incoming message rate (resilience patterns).
Long-lived connections never end on their own. Graceful shutdown (health, shutdown and configuration) must signal connection tasks to close, and a rolling deploy (Docker, Kubernetes and CI) drops every connection, so clients need reconnect logic.
Complete example
storefront-live/
Cargo.toml
src/
main.rs
auth.rs
state.rsCargo.toml
[package]
name = "storefront-live"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["sync"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"src/state.rs
use serde::Serialize;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
#[derive(Clone, Serialize)]
pub struct OrderEvent {
pub id: u64,
pub order_id: u64,
pub status: String,
}
#[derive(Clone)]
pub struct Feed {
log: Arc<Mutex<Vec<OrderEvent>>>,
tx: broadcast::Sender<OrderEvent>,
}
impl Feed {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(64);
Feed { log: Arc::new(Mutex::new(Vec::new())), tx }
}
pub fn publish(&self, order_id: u64, status: String) -> OrderEvent {
let mut log = self.log.lock().unwrap();
let event = OrderEvent { id: log.len() as u64 + 1, order_id, status };
log.push(event.clone());
let _ = self.tx.send(event.clone());
event
}
pub fn subscribe_from(&self, after: u64) -> (Vec<OrderEvent>, broadcast::Receiver<OrderEvent>) {
let log = self.log.lock().unwrap();
let receiver = self.tx.subscribe();
let backlog = log.iter().filter(|event| event.id > after).cloned().collect();
(backlog, receiver)
}
}src/auth.rs
use axum::http::{header, HeaderMap, StatusCode};
use std::{collections::HashMap, sync::Mutex};
const ALLOWED_ORIGINS: [&str; 1] = ["http://localhost:3000"];
pub fn check_origin(headers: &HeaderMap) -> Result<(), StatusCode> {
let origin = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
.ok_or(StatusCode::FORBIDDEN)?;
if ALLOWED_ORIGINS.contains(&origin) {
Ok(())
} else {
Err(StatusCode::FORBIDDEN)
}
}
#[derive(Default)]
pub struct Tickets {
inner: Mutex<(u64, HashMap<String, u64>)>,
}
impl Tickets {
pub fn issue(&self, user_id: u64) -> String {
let mut guard = self.inner.lock().unwrap();
guard.0 += 1;
let ticket = format!("t{}", guard.0);
guard.1.insert(ticket.clone(), user_id);
ticket
}
pub fn redeem(&self, ticket: &str) -> Option<u64> {
self.inner.lock().unwrap().1.remove(ticket)
}
}src/main.rs
mod auth;
mod state;
use auth::{check_origin, Tickets};
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, Query, State,
},
http::{HeaderMap, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
Response,
},
routing::{get, post},
Json, Router,
};
use futures_util::{stream::SplitSink, SinkExt, StreamExt};
use serde::Deserialize;
use state::{Feed, OrderEvent};
use std::{convert::Infallible, future::ready, sync::Arc, time::Duration};
use tokio::sync::broadcast::error::RecvError;
use tokio_stream::{wrappers::BroadcastStream, Stream};
#[derive(Clone)]
struct AppState {
feed: Feed,
tickets: Arc<Tickets>,
}
#[derive(Deserialize)]
struct StatusBody {
status: String,
}
#[derive(Deserialize)]
struct TicketParams {
user: u64,
}
#[derive(Deserialize)]
struct WsParams {
ticket: String,
after: Option<u64>,
}
async fn set_status(
State(state): State<AppState>,
Path(order_id): Path<u64>,
Json(body): Json<StatusBody>,
) -> Json<OrderEvent> {
Json(state.feed.publish(order_id, body.status))
}
async fn issue_ticket(
State(state): State<AppState>,
Query(params): Query<TicketParams>,
) -> Json<serde_json::Value> {
Json(serde_json::json!({ "ticket": state.tickets.issue(params.user) }))
}
fn sse_event(event: &OrderEvent) -> Event {
Event::default()
.id(event.id.to_string())
.event("order_status")
.data(serde_json::to_string(event).unwrap())
}
async fn events(
State(state): State<AppState>,
headers: HeaderMap,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let last_id: u64 = headers
.get("last-event-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.unwrap_or(0);
let (backlog, receiver) = state.feed.subscribe_from(last_id);
let backlog = tokio_stream::iter(backlog.into_iter().map(|e| Ok(sse_event(&e))));
let live = BroadcastStream::new(receiver)
.take_while(|item| ready(item.is_ok()))
.filter_map(|item| ready(item.ok()))
.map(|e| Ok(sse_event(&e)));
Sse::new(backlog.chain(live)).keep_alive(KeepAlive::default())
}
async fn ws_handler(
upgrade: WebSocketUpgrade,
headers: HeaderMap,
Query(params): Query<WsParams>,
State(state): State<AppState>,
) -> Result<Response, StatusCode> {
check_origin(&headers)?;
let _user_id = state
.tickets
.redeem(¶ms.ticket)
.ok_or(StatusCode::UNAUTHORIZED)?;
let after = params.after.unwrap_or(0);
Ok(upgrade
.max_message_size(64 * 1024)
.on_upgrade(move |socket| handle_socket(socket, state.feed, after)))
}
async fn send_event(sender: &mut SplitSink<WebSocket, Message>, event: &OrderEvent) -> bool {
let text = serde_json::to_string(event).unwrap();
let sent = tokio::time::timeout(
Duration::from_secs(5),
sender.send(Message::Text(text.into())),
)
.await;
matches!(sent, Ok(Ok(())))
}
async fn handle_socket(socket: WebSocket, feed: Feed, after: u64) {
let (mut sender, mut receiver) = socket.split();
let (backlog, mut live) = feed.subscribe_from(after);
let mut send_task = tokio::spawn(async move {
for event in backlog {
if !send_event(&mut sender, &event).await {
return;
}
}
loop {
match live.recv().await {
Ok(event) => {
if !send_event(&mut sender, &event).await {
return;
}
}
Err(RecvError::Lagged(_)) => {
let _ = sender.send(Message::Text(r#"{"type":"resync"}"#.into())).await;
return;
}
Err(RecvError::Closed) => return,
}
}
});
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await {
if matches!(message, Message::Close(_)) {
return;
}
}
});
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
}
}
#[tokio::main]
async fn main() {
let state = AppState {
feed: Feed::new(),
tickets: Arc::new(Tickets::default()),
};
let app = Router::new()
.route("/orders/{id}/status", post(set_status))
.route("/tickets", post(issue_ticket))
.route("/events", get(events))
.route("/ws", get(ws_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Notes on the code:
- Only
futures_util::StreamExtis imported. Importingtokio_stream::StreamExtas well would makenext,map, andchainambiguous. take_whileends the SSE stream on a lag error, andfilter_mapdrops the wrappedOk. The browser reconnects withLast-Event-ID, which replays the missed events from the log.
Run:
cargo runRequests (each in its own terminal, in this order):
# terminal 1: SSE stream, stays open
curl -N http://127.0.0.1:3000/events
# terminal 2: publish two events
curl -s -X POST http://127.0.0.1:3000/orders/7/status \
-H 'content-type: application/json' -d '{"status":"paid"}'
curl -s -X POST http://127.0.0.1:3000/orders/7/status \
-H 'content-type: application/json' -d '{"status":"shipped"}'Responses of the two POST calls:
{"id":1,"order_id":7,"status":"paid"}
{"id":2,"order_id":7,"status":"shipped"}Terminal 1 prints each event as it arrives (keep-alive comments appear every 15 seconds):
id: 1
event: order_status
data: {"id":1,"order_id":7,"status":"paid"}
id: 2
event: order_status
data: {"id":2,"order_id":7,"status":"shipped"}
Resume after a drop: curl -N -H 'Last-Event-ID: 1' http://127.0.0.1:3000/events prints only event 2, then stays open.
WebSocket with a ticket and origin check:
curl -s -X POST 'http://127.0.0.1:3000/tickets?user=5'
websocat -H 'Origin: http://localhost:3000' 'ws://127.0.0.1:3000/ws?ticket=t1&after=0'{"ticket":"t1"}
{"id":1,"order_id":7,"status":"paid"}
{"id":2,"order_id":7,"status":"shipped"}Rejected handshakes return plain HTTP errors and never upgrade:
# wrong origin: 403 Forbidden
websocat -H 'Origin: http://evil.example' 'ws://127.0.0.1:3000/ws?ticket=t1'
# reused or unknown ticket with a valid origin: 401 Unauthorized
websocat -H 'Origin: http://localhost:3000' 'ws://127.0.0.1:3000/ws?ticket=t1'websocat reports the failed handshake with the status code. Ticket t1 is spent after the first successful connection, so the second command fails with 401. Request a new ticket for each connection.
Summary
- A WebSocket starts as an HTTP request with
Upgrade: websocketand the server answers101 Switching Protocols. WebSocketUpgradewithon_upgraderuns the message loop after the handshake.split()gives independent send and receive halves.- Browsers apply no same-origin policy to WebSockets, so the server compares
Originwith an allow list. - Browser JavaScript cannot set headers on a WebSocket. Cookies with an origin check, or a single-use ticket, carry identity.
tokio::sync::broadcastfans events out within one process. Several instances need a shared pub/sub source.- SSE
idfields and theLast-Event-IDheader resume a stream. Subscribing and reading the backlog under one lock prevents gaps. - Bounded channels, send timeouts, message size limits, and connection limits handle slow clients.
- Shutdown and deploys close long-lived connections, so clients must reconnect and resume.
