Skip to Content
Course content

173: Server-Sent Events and Streaming Responses in Axum

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Server-Sent Events (SSE) are a fantastic way to push updates from your server to a browser without the overhead of WebSockets. I often reach for SSE when I need a one-way stream—like a live log viewer, a stock ticker, or a notification feed—because it's just HTTP. It's simpler to implement and plays nicer with load balancers.

The "Trait Bound" Wall

When I first started using Sse in Axum, I tried to be "efficient" by streaming raw strings. I figured the browser just wanted text, so why bother with extra wrappers? Here is the kind of code that usually leads to a wall of compiler errors:

use axum::{response::Sse, routing::get, Router};
use tokio_stream::Stream;
use std::time::Duration;

async fn stream_updates() -> Sse> {
    let stream = tokio_stream::iter(vec!["Update 1".to_string(), "Update 2".to_string()]);
    Sse::new(stream)
}

// This will fail to compile!

If you try to run this, the compiler will scream at you. It'll tell you that String doesn't implement the necessary traits to be an SSE event. The mistake here is assuming that Sse is just a wrapper for any stream of text. In reality, the SSE protocol requires a specific format (e.g., data: my message\n\n), and Axum enforces this by requiring the stream to yield SseEvent objects, wrapped in a Result.

Converting Raw Data into SseEvents

To fix this, we need to change the stream's item type. Axum's Sse expects a stream of Result<SseEvent, E>. Since we usually aren't expecting our stream to fail in a way that requires a complex error type, we use Infallible.

Here is the corrected version. Notice how I've used SseEvent::default() to wrap the string. This tells Axum to format the output as a standard "message" event:

use axum::{response::Sse, routing::get, Router};
use tokio_stream::Stream;
use std::convert::Infallible;
use axum::response::sse::SseEvent;
use tokio_stream::StreamExt;

async fn stream_updates() -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
    let stream = tokio_stream::iter(vec!["Update 1", "Update 2", "Update 3"])
        .map(|msg| Ok(SseEvent::default().data(msg)));
    
    Sse::new(stream)
}

I prefer using .map() on the stream itself. It keeps the logic clean and ensures that every piece of data hitting the wire is correctly formatted for the browser's EventSource API. If you need to send specific event names (like "heartbeat" or "alert"), you can use SseEvent::event("alert").data("System Overload!") instead of the default.

Handling Long-Lived Streams with Channels

In a real app, you aren't usually streaming a static Vec. You're likely streaming events from a database, a message queue, or another part of your system. This is where tokio::sync::mpsc channels come in.

The trick here is that the Receiver half of a channel isn't a Stream by default. You'll need the ReceiverStream wrapper from the tokio-stream crate. I've found that managing the "broadcast" aspect is the hardest part—if you want multiple users to see the same stream, a simple MPSC channel won't work because it's "multi-producer, single-consumer." For that, you'd want tokio::sync::broadcast.

Here is a pattern for a basic event broadcaster. I'm using a broadcast channel so that every connected client gets the same updates:

use axum::{response::Sse, response::sse::SseEvent, routing::get, Router};
use tokio::sync::broadcast;
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
use std::{convert::Infallible, time::Duration};

struct AppState {
    tx: broadcast::Sender<String>,
}

async fn sse_handler(
    axum::extract::State(state): axum::extract::State<std::sync::Arc<AppState>>,
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
    // Subscribe to the broadcast channel
    let rx = state.tx.subscribe();
    
    // Wrap the receiver in a BroadcastStream to make it a Stream
    let stream = BroadcastStream::new(rx)
        .filter_map(|res| res.ok()) // Ignore lag errors
        .map(|msg| Ok(SseEvent::default().data(msg)));

    Sse::new(stream)
}

#[tokio::main]
async fn main() {
    let (tx, _) = broadcast::channel(100);
    let state = std::sync::Arc::new(AppState { tx: tx.clone() });

    // Simulate a background task pushing updates
    let tx_clone = tx.clone();
    tokio::spawn(async move {
        let mut count = 0;
        loop {
            let _ = tx_clone.send(format!("Server time: {}", count));
            count += 1;
            tokio::time::sleep(Duration::from_secs(2)).await;
        }
    });

    let app = Router::new().route("/events", get(sse_handler)).with_state(state);
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

One final tip: always keep an eye on your channel capacity. If your clients are slow and your buffer fills up, broadcast will start lagging. That's why I used .filter_map(|res| res.ok())—it silently drops messages for clients that can't keep up, preventing one slow browser tab from crashing your entire event pipeline.




📋 Practical Task

Build a Real-time System Resource Monitor

Your goal is to create an Axum server that streams simulated system metrics (CPU and Memory usage) to a client using SSE.

  • Create a ResourceMetric struct that holds a cpu_usage (f64) and mem_usage (f64).
  • Implement a background task that generates random values for these metrics every 1 second.
  • Use a tokio::sync::broadcast channel to send these metrics to all connected clients.
  • The SSE handler must serialize the ResourceMetric struct into a JSON string before wrapping it in an SseEvent.
  • Ensure the stream handles broadcast::error::RecvError gracefully so that the connection doesn't drop just because a client lagged.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.