Axum / Chapter 3 of 4

WebSockets, streaming, and uploads

Handle Axum WebSockets, server-sent events, multipart uploads, and streaming bodies with Rust examples for each response pattern.

6 topicsUpdated
23 / 31

WebSockets#

A WebSocket starts as HTTP, upgrades the connection, then exchanges frames in both directions. Enable Axum’s ws feature.

Rustecho WebSocket
use axum::{
    extract::ws::{Message, WebSocket, WebSocketUpgrade},
    response::Response,
    routing::any,
    Router,
};

async fn upgrade(ws: WebSocketUpgrade) -> Response {
    ws.protocols(["notes.v1"])
        .on_failed_upgrade(|error| tracing::warn!(%error, "upgrade failed"))
        .on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    while let Some(result) = socket.recv().await {
        match result {
            Ok(Message::Text(text)) => {
                if socket.send(Message::Text(text)).await.is_err() {
                    break;
                }
            }
            Ok(Message::Ping(bytes)) => {
                let _ = socket.send(Message::Pong(bytes)).await;
            }
            Ok(Message::Close(_)) | Err(_) => break,
            _ => {}
        }
    }
}

let app = Router::new().route("/ws", any(upgrade));

Message variants are Text, Binary, Ping, Pong, and Close. Configure protocols and buffer/message/frame limits on WebSocketUpgrade. For concurrent reading and writing, call socket.split() from futures_util::StreamExt and own each half in a task.

24 / 31

Server-sent events#

SSE is a one-way HTTP stream from server to browser. It is simpler than WebSocket when the client only needs live updates from the server.

Rustlive clock stream
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream::{self, Stream};
use std::{convert::Infallible, time::Duration};
use tokio_stream::StreamExt;

async fn events() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let stream = stream::repeat_with(|| {
        Ok(Event::default()
            .event("heartbeat")
            .data("still connected"))
    })
    .throttle(Duration::from_secs(5));

    Sse::new(stream).keep_alive(
        KeepAlive::new()
            .interval(Duration::from_secs(15))
            .text("keep-alive"),
    )
}

Event can set data, JSON data, event, id, retry, and comments. EventDataWriter safely builds multiline data. KeepAlive prevents quiet intermediaries from closing an idle stream.

25 / 31

Multipart uploads#

Use Multipart for browser forms that mix fields and files. Read fields in order; a field borrows the multipart stream, so finish it before asking for the next one.

Rustsafe-size upload example
use axum::{
    extract::{DefaultBodyLimit, Multipart},
    http::StatusCode,
    routing::post,
    Router,
};

async fn upload(mut multipart: Multipart) -> Result<StatusCode, AppError> {
    while let Some(field) = multipart
        .next_field()
        .await
        .map_err(|_| AppError::Validation("invalid multipart body".into()))?
    {
        let name = field.name().unwrap_or("").to_owned();
        let file_name = field.file_name().map(str::to_owned);
        let content_type = field.content_type().map(str::to_owned);
        let bytes = field
            .bytes()
            .await
            .map_err(|_| AppError::Validation("cannot read field".into()))?;

        tracing::info!(%name, ?file_name, ?content_type, size = bytes.len());
    }

    Ok(StatusCode::NO_CONTENT)
}

let app = Router::new()
    .route("/upload", post(upload))
    .layer(DefaultBodyLimit::max(10 * 1024 * 1024));

A Field exposes name, file_name, content_type, headers, bytes, text, and chunked reading. Validate file type from content, not only the client-provided name or MIME header.

26 / 31

Bodies & streaming#

Buffer small known-size bodies; stream large or unbounded bodies. Axum’s default buffering limit is 2 MiB for extractors such as Bytes, String, Json, and Form.

Rustbuffer with an explicit limit
use axum::{
    body::{to_bytes, Body},
    extract::Request,
    http::StatusCode,
};

async fn inspect(request: Request) -> Result<String, StatusCode> {
    let bytes = to_bytes(request.into_body(), 64 * 1024)
        .await
        .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;

    Ok(format!("received {} bytes", bytes.len()))
}
Ruststream a response
use axum::body::{Body, Bytes};
use futures_util::stream;
use std::convert::Infallible;

async fn download() -> Body {
    let chunks = stream::iter([
        Ok::<_, Infallible>(Bytes::from_static(b"first\n")),
        Ok(Bytes::from_static(b"second\n")),
    ]);

    Body::from_stream(chunks)
}

Body::new wraps another HTTP body, Body::empty creates an empty body, Body::from_stream adapts a stream, and into_data_stream exposes data frames. DefaultBodyLimit::max(n) changes the default buffer limit; DefaultBodyLimit::disable() removes it. Add another explicit limit before accepting untrusted data.

27 / 31

Services & static files#

Handlers are convenient Tower services. Use service routes when you already have a Tower service, need static files, or are integrating another HTTP component.

Ruststatic assets + single page fallback
use axum::Router;
use tower_http::services::{ServeDir, ServeFile};

let app = Router::new()
    .nest_service(
        "/assets",
        ServeDir::new("public/assets"),
    )
    .fallback_service(
        ServeFile::new("public/index.html"),
    );

Peer connection information

Rustclient socket address
use axum::extract::ConnectInfo;
use std::net::SocketAddr;

async fn who_am_i(
    ConnectInfo(peer): ConnectInfo<SocketAddr>,
) -> String {
    format!("connected from {peer}")
}

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;

axum::serve(
    listener,
    app.into_make_service_with_connect_info::<SocketAddr>(),
).await?;
Proxy awareness

Behind a reverse proxy, the socket peer is usually the proxy. Only trust forwarded-IP headers from a proxy you control and configure; never accept them blindly from the public internet.

route_service mounts one service at one path. nest_service mounts a service under a prefix. The *_service routing helpers match methods and adapt service response bodies. If a service can error, wrap it in HandleError or HandleErrorLayer.

28 / 31

Custom extensions#

Build a custom extractor when validation belongs at the HTTP boundary and will be reused. Implement FromRequestParts if no body is needed; implement FromRequest when consuming the body.

Rustcustom authentication extractor
use axum::{
    extract::FromRequestParts,
    http::{header, request::Parts, StatusCode},
};

struct ApiKey(String);

impl<S> FromRequestParts<S> for ApiKey
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        let value = parts
            .headers
            .get(header::AUTHORIZATION)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.strip_prefix("Bearer "))
            .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token"))?;

        Ok(Self(value.to_owned()))
    }
}

async fn private(ApiKey(key): ApiKey) -> String {
    format!("accepted key with {} characters", key.len())
}

Run extractors manually

RustRequestExt
use axum::{extract::Request, Json, RequestExt};

async fn custom_pipeline(
    request: Request,
) -> Result<Json<CreateNote>, AppError> {
    request
        .extract::<Json<CreateNote>, _>()
        .await
        .map_err(|_| AppError::Validation("bad JSON".into()))
}

RequestExt and RequestPartsExt provide extract and extract_with_state. With the macros feature, #[derive(FromRequest)] and #[derive(FromRequestParts)] reduce wrapper boilerplate.

Lower-level middleware

Use from_fn for application middleware. Implement Tower’s Layer and Service when publishing reusable middleware, controlling readiness/backpressure, or avoiding boxed futures. Axum expects routed services to be effectively always ready; apply load shedding or wrap backpressure-sensitive services around the whole router.