Building Rust APIs with Actix Web: Extractors, Middleware Order, WebSocket and sqlx

Key takeaways

Actix Web handlers stay small when request parsing lives in extractors and errors converge on one response path. This post walks through the routing tree, custom FromRequest types, the direction in which wrap middleware runs, SSE behind proxies and load balancers, and request-scoped sqlx transactions.

Actix Web is one of the most widely used async web frameworks in Rust. The name suggests the actor model, and early versions did route everything through actors, but in actix-web 4 a typical JSON API is written as plain async fn handlers with typed extractors, running on Tokio. This article covers how to split routes with App and Scope, how extractors and responders define the boundaries of a handler, the order in which middleware actually runs, WebSocket and SSE endpoints, sharing a sqlx pool through web::Data, and the blocking-code mistake that stalls Actix workers. It assumes you are comfortable with ownership and async Rust.


Where Actix Web fits

Axum, Rocket, Warp, and Poem are the main alternatives. Actix Web’s strengths are a mature ecosystem of companion crates (CORS, sessions, identity, rate limiting), a long track record in production, and routing, middleware, and WebSocket support in one framework. Its main structural difference from Axum is the runtime model: Actix starts several worker threads, each running its own single-threaded Tokio runtime, and assigns each connection to one worker. That model is fast, but it has consequences for shared state and blocking code that come up repeatedly below.

Actors are no longer part of everyday Actix Web code. They used to be the standard way to model WebSocket sessions, through actix-web-actors, but that crate is now deprecated in favor of actix-ws, which exposes a WebSocket as an async stream and sink without actors.


Minimal run: HttpServer and App

HttpServer::new takes a factory closure and calls it once per worker thread to build that worker’s App.

use actix_web::{web, App, HttpResponse, HttpServer, Responder};

async fn health() -> impl Responder {
    HttpResponse::Ok().body("ok")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new().route("/health", web::get().to(health))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Because the closure runs once per worker, anything you construct inside it exists once per worker. A stateless health check does not care. But if you write .app_data(web::Data::new(Mutex::new(0))) inside the closure as a request counter, each worker gets its own counter, and the numbers you see depend on which worker served the request. Resources that must exist once per process, such as database pools, config, and metric registries, are created before HttpServer::new and moved into the closure, where each worker clones a handle to the same object. The sqlx section below shows this pattern.


App and Scope: routing tree design

A resource groups handlers for one path by HTTP method. A scope groups routes under a common path prefix and can carry its own middleware and app data.

use actix_web::{web, HttpResponse, Responder};

async fn list_users() -> impl Responder {
    HttpResponse::Ok().body("[]")
}

async fn get_user(path: web::Path<u64>) -> impl Responder {
    HttpResponse::Ok().body(format!("user {}", path.into_inner()))
}

fn user_routes() -> actix_web::Scope {
    web::scope("/users")
        .route("", web::get().to(list_users))
        .route("/{id}", web::get().to(get_user))
}

// App::new().service(user_routes())

Under web::scope("/users"), the route "" matches /users and /{id} matches /users/42. Path<u64> parses a single segment and returns 404 if it does not parse as a number; for several segments, extract a tuple such as Path<(u64, String)> or a struct deriving Deserialize. /users/ with a trailing slash does not match "", which is a common source of surprise 404s; the NormalizePath middleware can normalize slashes, but agreeing on one URL convention is the more durable fix.

Versioned APIs fit naturally into scopes: web::scope("/api/v1").service(user_routes()) keeps everything for one version inside one subtree, so a v2 can be added beside it.

App data lookup

app_data can be attached to the App, a scope, or a resource. When a handler asks for web::Data<T>, Actix looks for a T starting at the innermost resource and walking outward to the App, so data registered on the App is visible everywhere, and a scope can override it for its own routes. If no matching type is found, the request fails with a 500 and a log message about missing app data, which most often happens when the type parameter does not match exactly, for example registering Data<Arc<Pool>> but extracting Data<Pool>.

Guards

Guards add conditions beyond the path, such as method, host, or header. They are useful for splitting internal and public APIs on the same path by Host, or for serving JSON and HTML from one URL based on a header.

use actix_web::{guard, web, HttpResponse};

// App::new().service(
//     web::resource("/api/data")
//         .guard(guard::Host("internal.example.com"))
//         .to(|| async { HttpResponse::Ok().body("internal") })
// )

Requests that match no guard fall through to the next matching service or to the default 404, which makes heavy guard use hard to follow. Separate scopes are easier to read when the routes really are different.

default_service and 404 policy

App::default_service handles every request that matched nothing, which is where you return a consistent JSON 404 for an API. When one server hosts both the API and a single-page frontend, register the API scopes first and the SPA fallback last, so unknown /api/... paths still get a JSON 404 instead of index.html.


Extractors and responders

Common extractors

The extractors you will use most are web::Path<T> for path segments, web::Query<T> for the query string, web::Json<T> and web::Form<T> for request bodies, web::Header<T> for typed headers, web::Data<T> for shared state, and HttpRequest when you need raw access.

use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;

#[derive(Deserialize)]
struct PageQuery {
    page: Option<u32>,
    size: Option<u32>,
}

async fn search(q: web::Query<PageQuery>) -> impl Responder {
    let page = q.page.unwrap_or(1);
    let size = q.size.unwrap_or(20).min(100);
    HttpResponse::Ok().body(format!("page={page} size={size}"))
}

Extractors put a handler’s requirements into its signature. A handler that takes web::Json<CreateOrder> cannot run without a valid body, so the handler body never has to check for one. If extraction fails, Actix returns an error response (400 for malformed input, 413 for oversized bodies) without calling the handler.

FromRequest: custom extractors

Cross-cutting request data such as the authenticated user, a tenant ID, or a request ID is cleaner as a custom extractor than as middleware that stuffs values into request extensions. A handler that takes AuthUser is guaranteed to have one, and failing extraction returns an error response directly.

use actix_web::{dev::Payload, error::ErrorUnauthorized, FromRequest, HttpRequest};
use std::future::{ready, Ready};

struct AuthUser {
    sub: String,
}

impl FromRequest for AuthUser {
    type Error = actix_web::Error;
    type Future = Ready<Result<AuthUser, Self::Error>>;

    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
        let token = req
            .headers()
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "));

        ready(match token.and_then(verify_token) {
            Some(sub) => Ok(AuthUser { sub }),
            None => Err(ErrorUnauthorized("invalid token")),
        })
    }
}

fn verify_token(token: &str) -> Option<String> {
    // Verify signature and expiry with a key loaded from app_data in real code.
    let _ = token;
    None
}

The Future associated type lets an extractor do async work, such as looking up a session in Redis. Keep that work short: extractors run on the worker’s async thread just like handlers, so a slow lookup adds latency to every request that uses the extractor, and anything blocking stalls the whole worker (see the last section).

Consistent error responses

Handlers can return HttpResponse directly or any type implementing Responder. For errors, the idiomatic approach is a custom error enum that implements ResponseError, so handlers return Result<T, AppError> and use ?, and the mapping from error to status code and JSON body lives in one place.

use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use serde::Serialize;

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("not found")]
    NotFound,
    #[error("bad request: {0}")]
    BadRequest(String),
    #[error(transparent)]
    Db(#[from] sqlx::Error),
}

#[derive(Serialize)]
struct ErrorBody {
    code: &'static str,
    message: String,
}

impl ResponseError for AppError {
    fn status_code(&self) -> StatusCode {
        match self {
            AppError::NotFound => StatusCode::NOT_FOUND,
            AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
            AppError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    fn error_response(&self) -> HttpResponse {
        let (code, message) = match self {
            AppError::NotFound => ("NOT_FOUND", self.to_string()),
            AppError::BadRequest(m) => ("BAD_REQUEST", m.clone()),
            // Do not leak database error details to clients.
            AppError::Db(_) => ("INTERNAL", "internal error".to_string()),
        };
        HttpResponse::build(self.status_code()).json(ErrorBody { code, message })
    }
}

Whatever shape you choose, clients need to be able to tell retryable errors (429, 503) from errors where retrying will not help (400, 404).

Payloads and uploads

web::Json and web::Form buffer the whole body in memory up to their configured limit. For large uploads, take web::Payload and process the stream chunk by chunk, or use actix-multipart. With multipart, set per-field and total size limits; without them, a client can send an arbitrarily large field and exhaust memory or disk.


Middleware: order is meaning

Which wrap runs first

Middleware layers form an onion: a request passes through the layers from the outside in, reaches the handler, and the response passes back out in reverse. In Actix, the middleware registered last is the outermost layer.

use actix_web::middleware::{Compress, Logger};
use actix_web::{web, App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/ping", web::get().to(|| async { "pong" }))
            .wrap(Compress::default()) // inner
            .wrap(Logger::default())   // outer: registered last, runs first
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Here a request goes through Logger, then Compress, then the handler; the response comes back through Compress and then Logger, so the logger measures the full duration including compression.

The ordering mistake that causes real confusion is placing an authentication middleware outside the logger. If auth is registered after Logger, it is the outer layer, and when it rejects a request with 401 it returns without calling the inner layers, so the logger never sees those requests. Failed logins then disappear from your access logs. Register logging and tracing last so they are outermost, and keep business rules in handlers and extractors.

wrap_fn and custom middleware

wrap_fn takes a closure and is convenient for small things such as adding a response header or a request ID. When the logic grows, a dedicated middleware type (implementing Transform and Service, or using middleware::from_fn in recent 4.x versions) is easier to test and reuse.

CORS

Browser-facing public APIs need actix_cors::Cors with explicit allowed origins, methods, and headers. Cors::permissive() is meant for development only. Server-to-server traffic between internal services involves no browser and therefore no CORS at all; CORS is a browser enforcement mechanism, not an access control for your API.


WebSocket

HTTP is request-response; a WebSocket is a long-lived bidirectional connection. The current recommended crate is actix-ws, which upgrades the request and gives you a session for sending and a stream of incoming messages that you process in a spawned task.

use actix_web::{rt, web, Error, HttpRequest, HttpResponse};
use actix_ws::Message;
use futures_util::StreamExt;

async fn ws_handler(req: HttpRequest, body: web::Payload) -> Result<HttpResponse, Error> {
    let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;

    rt::spawn(async move {
        while let Some(Ok(msg)) = msg_stream.next().await {
            match msg {
                Message::Text(text) => {
                    if session.text(text).await.is_err() {
                        break; // client went away
                    }
                }
                Message::Ping(bytes) => {
                    if session.pong(&bytes).await.is_err() {
                        break;
                    }
                }
                Message::Close(reason) => {
                    let _ = session.close(reason).await;
                    return;
                }
                _ => {}
            }
        }
        let _ = session.close(None).await;
    });

    Ok(response)
}

// App::new().route("/ws", web::get().to(ws_handler))

The older actix-web-actors approach models each connection as an actor implementing StreamHandler<Result<ws::Message, ws::ProtocolError>>. Existing code using it still works, but new code should not start there.

In production, idle timeouts, message size limits, and connection limits have to be set consistently at the reverse proxy (Nginx, a cloud load balancer, Cloudflare) and in the application, or connections drop at whichever layer has the shortest timeout. Send periodic pings from the server so idle connections stay alive and dead ones are detected. Clients should reconnect with exponential backoff and jitter, so a server restart does not produce a synchronized reconnect storm.

Broadcasting across sessions and instances

For chat or notifications, sessions in one process can share a hub, for example a tokio::sync::broadcast channel stored in web::Data that every session task subscribes to. Once you run several instances behind a load balancer, users connected to different instances must still receive each other’s messages, so cross-instance fan-out has to go through an external bus such as Redis Pub/Sub, NATS, or Kafka. Each instance then subscribes to the bus and forwards messages to its local sessions.


SSE (Server-Sent Events)

SSE is a one-way stream from server to client over a normal HTTP response with Content-Type: text/event-stream. Each event is a few field: value lines ending with a blank line, and the browser’s EventSource reconnects automatically, sending the last received id in a Last-Event-ID header so the server can resume.

use actix_web::{web, HttpResponse, Responder};
use futures_util::stream;
use std::time::Duration;

async fn events() -> impl Responder {
    let ticks = stream::unfold(0u64, |n| async move {
        tokio::time::sleep(Duration::from_secs(15)).await;
        let chunk = format!("id: {n}\nevent: tick\ndata: {{\"n\":{n}}}\n\n");
        Some((Ok::<_, actix_web::Error>(web::Bytes::from(chunk)), n + 1))
    });

    HttpResponse::Ok()
        .content_type("text/event-stream")
        .insert_header(("Cache-Control", "no-cache"))
        .insert_header(("X-Accel-Buffering", "no"))
        .streaming(ticks)
}

The actix-web-lab crate also provides an sse module with typed events and built-in keep-alive if you prefer not to format events by hand.

Proxies, load balancers, and compression

SSE that works locally and drops in staging is almost always an intermediary problem. Nginx buffers proxied responses by default, so events arrive in bursts or not at all until you set proxy_buffering off (or send X-Accel-Buffering: no as above) and raise proxy_read_timeout. Load balancers and ingress controllers close idle connections after their own timeout, so send a heartbeat (an event or a : comment line) more often than the shortest idle timeout in the path. The Compress middleware can also buffer a streaming response while it fills its compression window; exclude SSE routes from it.

Over HTTP/1.1, browsers allow only about six connections per origin, and each open EventSource holds one, so a user with several tabs open can exhaust them. Over HTTP/2, streams are multiplexed on one connection and the limit is much higher.

SSE or WebSocket

If the client needs to send frequent messages back, as in collaborative editing or games, use WebSocket. If the server only pushes updates and client actions can be ordinary HTTP requests, SSE is simpler: it is plain HTTP, works with existing auth and proxies once buffering is disabled, and reconnection is built into the browser.


Database integration: sqlx and web::Data

Sharing the pool

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .connect(&database_url)
        .await
        .expect("failed to connect to database");

    let pool = web::Data::new(pool); // created once, before the factory

    HttpServer::new(move || {
        App::new()
            .app_data(pool.clone()) // each worker clones the Arc, same pool
            .route("/db-health", web::get().to(db_health))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

async fn db_health(pool: web::Data<PgPool>) -> impl Responder {
    match sqlx::query_scalar::<_, i32>("SELECT 1")
        .fetch_one(pool.get_ref())
        .await
    {
        Ok(_) => HttpResponse::Ok().body("db ok"),
        Err(_) => HttpResponse::ServiceUnavailable().finish(),
    }
}

web::Data<T> is already an Arc<T> internally, so wrapping the pool in another Arc is unnecessary; PgPool itself is also a cheap handle around shared state. What matters is that the pool is created once outside the factory closure. Creating it inside would give every worker its own pool, multiplying the number of database connections by the worker count.

max_connections caps connections per pool, so the total load on the database is that number times the number of application instances. With several replicas, size it against the database’s max_connections, not against the CPU count of one instance.

Note that sqlx::query_scalar with a string, as above, is not checked at compile time. The compile-time checking sqlx is known for comes from the query!, query_as!, and query_scalar! macros, which connect to the database named by DATABASE_URL at build time, or read cached query metadata created by cargo sqlx prepare when building offline in CI.

Transactions

When one request must run several statements atomically, open the transaction in one service function, pass &mut *tx to each query, and commit at the end. An sqlx Transaction that is dropped without commit() rolls back, so an early return via ? is safe. Spreading begin() calls across handlers and helpers makes it hard to see which statements are in which transaction, and a transaction held open across a slow external call keeps a pooled connection and its locks for the whole duration.

Diesel and SeaORM

Diesel builds queries with a type-safe Rust DSL generated from your schema and catches many mistakes at compile time without a database connection. Its main crate is synchronous, so in Actix you must call it through web::block or use the separate diesel-async crate. SeaORM is an async ORM built on sqlx, with entity and ActiveModel types and a query builder. Any of them is shared through web::Data the same way; the choice is mostly about whether the team prefers writing SQL or writing Rust that generates SQL.

Migrations and deploy order

With rolling deploys, old and new versions of the service run against the same database for a while. Schema changes therefore have to be compatible with both: add a nullable column or a column with a default first, deploy code that writes it, backfill, and only then make it required or remove the old column in a later release. Dropping or renaming a column in the same release that stops using it breaks the instances still running the old code, and makes rollback impossible.


Running Actix services in production

In a Cargo workspace, putting domain logic in library crates and keeping the Actix binary thin makes the routing tree a thin layer over testable code. A layout such as orders-api and billing-api binaries over shared auth, telemetry, and error crates maps directly onto Actix scopes.

For Kubernetes, expose a liveness endpoint that only shows the process is responsive and a readiness endpoint that checks dependencies such as the database pool. A liveness probe that checks the database restarts every Pod when the database has a brief outage, which turns a partial failure into a full one.

Load configuration once at startup into a validated struct and share it through web::Data instead of calling std::env::var in handlers. Handlers then become testable with a config you construct in the test, and a missing variable fails at startup rather than on the first request that needs it.

Set explicit timeouts on every outbound HTTP client and keep pool sizes bounded. Without a timeout, a slow upstream ties up requests until the client gives up, and those requests accumulate across workers; with one, the failure stays local and fast.

The tracing crate together with tracing-actix-web creates a span per request at the outermost layer, and exporting through OpenTelemetry links those spans across services. Include the trace ID in logs so a log line can be followed to its trace. At minimum, track request rate, latency histograms per route, error rate, and pool wait time; pool wait time is usually the first metric to move when the database becomes the bottleneck.

For tests, actix_web::test::init_service builds the real App with its routes, extractors, and middleware, and TestRequest sends requests to it in-process without opening a port. These tests are slower than pure unit tests but exercise the same wiring you deploy.

HttpServer::workers defaults to the number of CPU cores. Raising it rarely helps an async workload and is usually a sign of blocking code (see below). On shutdown, Actix handles SIGTERM and SIGINT by default, stops accepting new connections, and waits up to shutdown_timeout (30 seconds by default) for in-flight requests; make sure your orchestrator’s grace period is longer than that.

TLS is usually terminated at the reverse proxy or load balancer. In that setup, use the forwarded headers (X-Forwarded-For, Forwarded) only from trusted proxies, through ConnectionInfo::realip_remote_addr, and never trust them for security decisions when the app might be reachable directly. Mask Authorization headers, cookies, and personal data in request logs; the default Logger format does not log headers, but custom formats and tracing fields often do.


The mistake that stalls an Actix worker: blocking inside a handler

Each Actix worker runs its own single-threaded async runtime, and every connection assigned to that worker shares it. A handler that calls something blocking (a synchronous database driver such as Diesel, std::fs on a large file, a password hash with a high cost factor, std::thread::sleep) does not just slow down its own request; it freezes every other request on that worker until it returns. Under light load this is invisible because other workers pick up the slack. Under real load, latency climbs in steps as workers get pinned one by one, and CPU usage can look low while requests queue.

The fix is to move that work off the async thread with web::block(move || ...), which runs the closure on a separate blocking thread pool and returns a future, or to use an async driver such as sqlx in the first place. When a latency problem appears only under concurrency and profiling shows threads parked in synchronous calls, check the handlers for blocking code before tuning the workers count.