Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 031 / 100

Tower middleware: what every Rust developer should know

Reusable layers for Rust services.

Expona Admin8 min read

Tower middleware: what every Rust developer should know

When you build a production HTTP service in Rust, you rarely want every route handler to reinvent logging, authentication, rate limiting, and error handling from scratch. Tower middleware gives you a composable, reusable layer system that lets you attach cross-cutting concerns to your entire service—or just specific routers—without polluting your business logic. This is the foundation of maintainable Rust services, and understanding how to stack, order, and write middleware is essential for any developer moving beyond toy projects.

What Tower middleware actually is

Tower is a library for building and composing asynchronous services. A middleware in Tower terms is anything that wraps a service, intercepts requests before they reach your handlers, and can transform responses on the way out. Think of it as a decorator pattern: each layer sits between your client and the actual handler, giving you a clean extension point.

The core trait is Service, which is straightforward: it takes a request and returns a future yielding a response. Middleware implements Layer, which is a factory that creates a Service. When you compose layers, you build a stack—requests flow down, responses flow back up.

We use middleware for concerns that apply across multiple routes: authentication, CORS headers, request tracing, timeouts, rate limiting, compression. Anything that should happen for most (or all) requests is a candidate.

tower-http for CORS, tracing and compression

The tower-http crate provides off-the-shelf middleware for the most common HTTP concerns. You don't need to write these yourself—they're battle-tested and well-maintained.

CORS handling

CORS is one of the most frequent middleware needs in web services. tower-http provides CorsLayer, which lets you declare allowed origins, methods, and headers declaratively:

rust
use tower_http::cors::{CorsLayer, AllowOrigin};
use axum::Router;
use std::str::FromStr;

let cors = CorsLayer::permissive();
// or more restrictively:
let cors = CorsLayer::very_restrictive()
    .allow_origin(AllowOrigin::list(vec![
        "https://example.com".parse().unwrap(),
        "https://app.example.com".parse().unwrap(),
    ]))
    .allow_methods([axum::http::Method::GET, axum::http::Method::POST])
    .allow_headers([axum::http::header::CONTENT_TYPE]);

let app = Router::new()
    .route("/api/users", axum::routing::get(list_users))
    .layer(cors);

In production services we've built, permissive CORS during development and then restrictive CORS in staging and production saved us from subtle cross-origin bugs. The layer approach means you configure CORS once and it applies to your entire router.

Request tracing

tower-http provides TraceLayer, which automatically logs HTTP requests and responses with structured data. This integrates seamlessly with tracing, letting your logs flow into observability tools:

rust
use tower_http::trace::{TraceLayer, DefaultMakeSpan, DefaultOnResponse};
use tracing::Level;

let trace = TraceLayer::new_for_http()
    .make_span_with(DefaultMakeSpan::new().level(Level::INFO))
    .on_response(DefaultOnResponse::new().level(Level::INFO));

let app = Router::new()
    .route("/api/orders", axum::routing::post(create_order))
    .layer(trace);

Every request now gets a structured log with method, path, status, and latency. When debugging production issues, this is invaluable—you can correlate request IDs across services and see exactly what hit your endpoint and when.

Response compression

Large JSON responses can benefit from gzip or brotli compression, saving bandwidth without adding code to each handler:

rust
use tower_http::compression::CompressionLayer;

let compression = CompressionLayer::new();

let app = Router::new()
    .route("/api/data", axum::routing::get(fetch_large_dataset))
    .layer(compression);

The layer respects the Accept-Encoding header from clients and transparently compresses responses. No handler changes needed.

Timeouts and rate limits

Beyond tower-http, you'll want middleware for resilience. Timeouts prevent hanging requests from consuming resources indefinitely. Rate limits protect your service from abuse—both accidental and malicious.

Adding timeouts

Tower provides a TimeoutLayer that cancels any request taking longer than a threshold:

rust
use tower::timeout::TimeoutLayer;
use std::time::Duration;

let timeout = TimeoutLayer::new(Duration::from_secs(30));

let app = Router::new()
    .route("/api/process", axum::routing::post(long_running_task))
    .layer(timeout);

When we've deployed services without timeouts, a single slow database query or external API call can cascade into resource exhaustion. Timeouts force failure-fast behavior, which is healthier than hanging indefinitely.

Rate limiting

For rate limiting, the ecosystem offers libraries like tower-governor or tower-limit. Here's a simple approach with tower-limit:

rust
use tower_limit::rate::RateLimitLayer;
use std::num::NonZeroU32;

let rate_limit = RateLimitLayer::new(
    NonZeroU32::new(100).unwrap(),  // 100 requests
    std::time::Duration::from_secs(60),  // per minute
);

let app = Router::new()
    .route("/api/search", axum::routing::get(search))
    .layer(rate_limit);

This rejects requests once you hit 100 per minute, returning 429 Too Many Requests. In production services handling public APIs, this middleware prevents single clients from starving others or triggering cascading failures.

Compose layers on routers

A key insight: you don't have to apply the same middleware to your entire app. You can layer middleware at different scopes—global, per-router, or per-route. This gives you fine-grained control.

Global layers

Layers applied to the app root affect every request:

rust
use axum::Router;
use tower_http::trace::TraceLayer;
use tower_http::cors::CorsLayer;

let app = Router::new()
    .route("/api/users", axum::routing::get(list_users))
    .route("/api/orders", axum::routing::post(create_order))
    .layer(TraceLayer::new_for_http())
    .layer(CorsLayer::permissive());

Every request, to any route, gets traced and CORS headers.

Route-specific layers

Sometimes you want middleware on just a subset of routes. Nest routers and apply layers selectively:

rust
use axum::{Router, middleware as axum_middleware};
use tower_http::trace::TraceLayer;
use tower::timeout::TimeoutLayer;
use std::time::Duration;

// Public routes: traced, no timeout
let public_routes = Router::new()
    .route("/health", axum::routing::get(health_check))
    .layer(TraceLayer::new_for_http());

// Internal routes: timeout, no tracing (to avoid noise)
let internal_routes = Router::new()
    .route("/internal/metrics", axum::routing::get(metrics))
    .layer(TimeoutLayer::new(Duration::from_secs(5)));

let app = Router::new()
    .nest("/api", public_routes)
    .nest("/internal", internal_routes);

In services we've built, this pattern lets you apply strict timeouts to external-facing endpoints while giving internal diagnostics endpoints more breathing room.

Ordering matters

The order you stack layers matters: requests flow through them in the order you apply them, and responses flow back through in reverse. If you want to trace everything including timeouts, put the trace layer outermost:

rust
let app = Router::new()
    .route("/api/work", axum::routing::post(do_work))
    .layer(TraceLayer::new_for_http())  // Outermost: sees full flow
    .layer(TimeoutLayer::new(Duration::from_secs(10)));

If you reversed this, the timeout fires before the trace layer sees the cancellation, leading to incomplete logs.

Write custom layers when needed

Off-the-shelf middleware covers most cases, but sometimes you need domain-specific logic. Writing a custom layer is straightforward if you understand the pattern.

Example: Authentication middleware

Imagine you need to extract a bearer token, validate it, and inject the user ID into request extensions:

rust
use tower::{Layer, Service};
use axum::{
    http::{Request, StatusCode},
    body::Body,
};
use futures::future::BoxFuture;
use std::task::{Context, Poll};

#[derive(Clone)]
pub struct AuthLayer;

impl<s> Layer<s> for AuthLayer {
    type Service = AuthMiddleware<s>;

    fn layer(&self, inner: S) -> Self::Service {
        AuthMiddleware { inner }
    }
}

#[derive(Clone)]
pub struct AuthMiddleware<s> {
    inner: S,
}

impl<s> Service> for AuthMiddleware<s>
where
    S: Service, Response = axum::response::Response> + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = BoxFuture<'static, Result>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request) -> Self::Future {
        let token = req
            .headers()
            .get("authorization")
            .and_then(|h| h.to_str().ok())
            .and_then(|s| s.strip_prefix("Bearer "))
            .map(|s| s.to_string());

        match token {
            Some(t) => {
                // Validate token, extract user_id (simplified)
                if let Ok(user_id) = validate_token(&t) {
                    req.extensions_mut().insert(user_id);
                    let future = self.inner.call(req);
                    Box::pin(async move { future.await })
                } else {
                    Box::pin(async {
                        Ok(axum::response::Response::builder()
                            .status(StatusCode::UNAUTHORIZED)
                            .body(Body::from("Invalid token"))
                            .unwrap())
                    })
                }
            }
            None => Box::pin(async {
                Ok(axum::response::Response::builder()
                    .status(StatusCode::UNAUTHORIZED)
                    .body(Body::from("Missing token"))
                    .unwrap())
            }),
        }
    }
}

fn validate_token(token: &str) -> Result {
    // Your validation logic here
    Ok(1)
}</s></s></s></s></s></s>

Now handlers can extract the user ID from extensions without reimplementing token validation:

rust
use axum::Extension;

async fn protected_route(Extension(user_id): Extension) -> String {
    format!("Hello user {}", user_id)
}

Custom middleware keeps your handlers clean and centralizes cross-cutting logic.

Common mistakes

Applying layers in the wrong order: Trace layer should usually be outermost so it captures the full request lifecycle including timeouts and rate limit rejections. Putting it inside a timeout layer means timeout errors don't get traced.

Creating middleware that panics: A panic in middleware crashes your entire request handler. Always return errors gracefully. In custom middleware, wrap fallible operations and convert errors to appropriate HTTP responses.

Forgetting to clone middleware for concurrency: Middleware must implement Clone to work with concurrent requests. If your middleware holds state, use Arc and interior mutability patterns (like Arc<Mutex> or Arc<DashMap>).

Applying expensive middleware globally when it should be selective: If you're doing heavy work in middleware (like cryptographic validation), apply it only to routes that need it. Global middleware runs on every request including health checks and metrics endpoints.

Key takeaways

  • Tower middleware is a composable decorator system for cross-cutting concerns like logging, auth, rate limiting, and compression.
  • tower-http provides battle-tested implementations for CORS, tracing, and compression—use them instead of writing custom versions.
  • Timeouts and rate limits are essential for resilience; Tower provides layers for both.
  • You can layer middleware at different scopes—globally, per-router, or per-route—giving you fine-grained control.
  • Layer order matters: requests flow down, responses flow back up. Put observability middleware outermost.
  • Custom middleware is straightforward to write when you need domain logic like authentication.
  • Always handle errors gracefully in middleware; never panic or unwrap.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles