Actix Web 4.x is the part of the Rust web stack that you stop thinking about a week into using it, which is exactly when it starts to cost you. The default App::new(), the default extractor wiring, the default error type, the default release profile, the default test setup: all of them work, none of them are optimal for a service that runs in production. This guide enumerates 30+ Actix Web best practices across eight production categories (project structure and app state, routing and extractors, middleware chains, error handling, database integration, authentication and authorization, testing, and deployment with observability) on actix-web 4.x stable, Rust 1.85+ with the 2024 edition, and the modern companion-crate stack (actix-cors 0.7, actix-identity 0.8, actix-session 0.10, actix-web-httpauth 0.8, tracing-actix-web 0.7, sqlx 0.8). Actix Web 5.0 is on the roadmap; everything here is stable on 4.x today.
The one rule that subsumes the rest: the actor model is optionality, not the default. Reach for actix::Actor and actix-web-actors only when WebSocket fan-out, stateful supervision, or per-connection mailbox semantics are the load-bearing requirement. For 90% of REST/JSON workloads, default to handler-functions, share state through web::Data<Arc<AppState>>, and let Tokio do its job.
The 10 Actix Web Anti-Patterns to Avoid (Bookmark This)
Print this. Pin it next to your monitor. Send it to the team channel the next time someone opens a PR that triggers row 3 or row 6. Every row maps to a section below.
| # | Anti-pattern | Why it hurts | Fix |
|---|---|---|---|
| 1 | Cloning state into every handler closure (let state = state.clone(); move || ...) | Each clone is a fresh allocation under load; Actix builds a fresh App per worker thread, so closure clones bloat startup and lifetimes | Use web::Data::new(Arc::new(state)) once, register with .app_data(...), extract in handlers as state: web::Data<AppState> |
| 2 | Returning Result<HttpResponse, std::io::Error> (or any non-ResponseError) from handlers | Actix can't map your error to an HTTP response, so you log it manually and lose structured error responses | Define AppError with #[derive(thiserror::Error)] plus impl ResponseError; return Result<HttpResponse, AppError> everywhere |
| 3 | Calling blocking code (std::fs, sync DB drivers, reqwest::blocking) directly inside an async handler | A single 100ms blocking call stalls the worker; under load you see p99 spikes that no profiler explains | Move blocking work to web::block(|| { ... }).await?; or pick an async-native crate (tokio::fs, sqlx, reqwest) |
| 4 | Wiring HttpServer::new(|| App::new()...) directly in main with no factory function | Tests stand up a different App from production; the two drift until prod-only bugs surface | Extract fn build_app() -> App<...> (or a closure) and pass it to both HttpServer::new and actix_web::test::init_service |
| 5 | Using web::Json<T> without setting JsonConfig payload limits | Default 32 KiB JSON limit produces opaque 400s; raise it in one place and you fix a class of bugs at once | Register web::JsonConfig::default().limit(4 * 1024 * 1024) with .app_data(...) once; document the cap on the API |
| 6 | Loading database config (DSN, pool size) inside the HttpServer::new factory closure | The closure runs once per worker, so you parse env vars and connect to Postgres N times | Build the PgPool once in main, then .app_data(web::Data::new(pool.clone())); Pool is internally Arc, the clone is cheap |
| 7 | Skipping actix-cors and writing Access-Control-Allow-* headers by hand in a middleware | The hand-rolled middleware ships in production without preflight handling; browsers refuse the request and the team blames Actix | Use Cors::default().allow_any_method().allowed_origin("https://app.example.com") and adjust per environment |
| 8 | Default release profile (opt-level = 3, lto = false, codegen-units = 16) for the shipped binary | Default release ships a binary 2–3x larger than the tuned profile and misses LTO entirely; cold-start and image-pull get worse | Set lto = "fat", codegen-units = 1, strip = "symbols", panic = "abort" in [profile.release] |
| 9 | Returning panics from middleware (or letting unwrap() ride in extractors) | A panic in an extractor closes the connection with an opaque error; one bad input becomes one 500 that's hard to symbolicate | Replace unwrap() with ? in FromRequest impls; catch in middleware where appropriate; lean on ResponseError to map domain errors to HTTP |
| 10 | No graceful shutdown — Ctrl-C aborts in-flight requests | Kubernetes terminates the pod, in-flight requests 5xx, on-call gets paged | Bind signal handlers via tokio::signal; call server_handle.stop(true).await for a graceful drain; set terminationGracePeriodSeconds to drain + 5s in the pod spec |
Project Structure & App State: The Patterns That Compound
Most Actix Web projects do not fail because of a slow handler. They fail because the first three structure decisions (lib vs bin layout, app-state shape, factory pattern) get made implicitly and then nothing scales. The practices below are the ones every production codebase converges on; doing them on day one saves the refactor on day 200.
1. Use the lib + thin-bin shape from day one
The default cargo new my-api gives you a binary-only crate. The production shape is a library crate that owns every type and function, plus a thin src/main.rs that wires environment and starts the server. The win is that tests, integration suites, and downstream consumers all use my_api::build_app; the binary becomes a six-line entry point.
cargo new my-api
# then add src/lib.rs and adjust Cargo.toml:[package]
name = "my-api"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[lib]
name = "my_api"
path = "src/lib.rs"
[[bin]]
name = "my-api"
path = "src/main.rs"
[dependencies]
actix-web = "4"
actix-cors = "0.7"
actix-identity = "0.8"
actix-session = { version = "0.10", features = ["cookie-session"] }
actix-web-httpauth = "0.8"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-actix-web = "0.7"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "macros", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
anyhow = "1"
uuid = { version = "1", features = ["v4", "serde"] }
jsonwebtoken = "9"
[dev-dependencies]
actix-rt = "2"
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "macros"] }
[profile.release]
lto = "fat"
codegen-units = 1
strip = "symbols"
panic = "abort"src/main.rs then becomes a thin entry that delegates to the library:
use my_api::{config::Config, run};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let cfg = Config::from_env().expect("config from env");
run(cfg).await
}// src/lib.rs
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod handlers;
pub mod middleware;
pub mod routes;
pub mod state;
pub mod telemetry;
pub use config::Config;
pub use error::AppError;
pub use state::AppState;
use actix_web::{web, App, HttpServer};
use std::sync::Arc;
pub async fn run(cfg: Config) -> std::io::Result<()> {
telemetry::init(&cfg);
let pool = db::pool(&cfg).await.expect("postgres connect");
let state = Arc::new(AppState::new(cfg.clone(), pool.clone()));
let server = HttpServer::new(move || build_app(state.clone()))
.workers(cfg.workers)
.backlog(cfg.backlog)
.max_connections(cfg.max_connections)
.keep_alive(std::time::Duration::from_secs(cfg.keep_alive_secs))
.client_request_timeout(std::time::Duration::from_secs(30))
.shutdown_timeout(30)
.bind(&cfg.bind_addr)?
.run();
let handle = server.handle();
tokio::spawn(telemetry::graceful_shutdown(handle));
server.await
}
pub fn build_app(state: Arc<AppState>) -> App<impl actix_web::dev::ServiceFactory<
actix_web::dev::ServiceRequest,
Config = (), Response = actix_web::dev::ServiceResponse,
Error = actix_web::Error, InitError = (),
>> {
App::new()
.app_data(web::Data::from(state))
.app_data(web::JsonConfig::default().limit(4 * 1024 * 1024))
.wrap(tracing_actix_web::TracingLogger::default())
.wrap(actix_web::middleware::Compress::default())
.wrap(actix_web::middleware::NormalizePath::trim())
.configure(routes::init)
}The factory pattern (item #4 in the anti-pattern table) is what makes integration tests trivial. Every test calls actix_web::test::init_service(build_app(state.clone())).await and gets the exact same App configuration the binary uses in production.
2. Centralize shared resources in AppState
AppState is the canonical name for the struct that holds every shared resource the handlers need: the Postgres pool, an HTTP client, configuration, a feature-flag client, an in-memory cache, a JWT signing key. Wrap it in Arc and register it once with .app_data(...). Handlers receive it as state: web::Data<AppState>.
// src/state.rs
use sqlx::PgPool;
use std::sync::Arc;
use crate::config::Config;
pub struct AppState {
pub cfg: Config,
pub db: PgPool,
pub http: reqwest::Client,
pub jwt: jsonwebtoken::EncodingKey,
}
impl AppState {
pub fn new(cfg: Config, db: PgPool) -> Self {
Self {
jwt: jsonwebtoken::EncodingKey::from_secret(cfg.jwt_secret.as_bytes()),
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("reqwest client"),
cfg,
db,
}
}
}The web::Data<T> wrapper is internally Arc<T>, so the state.clone() in the HttpServer::new closure is a refcount bump, not a deep copy. The closure runs once per worker (Actix spawns one per CPU core by default), and each worker holds a cheap Arc reference to the same underlying state. This is the structural fix for anti-pattern row 1.
3. Configuration: env-first, struct-backed, fail-fast
Production services parse environment variables once at startup and crash if any required variable is missing. Doing this with a typed Config struct catches mistakes at boot instead of inside the first request that touches the misconfigured field.
// src/config.rs
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)]
pub struct Config {
pub bind_addr: String,
pub database_url: String,
pub jwt_secret: String,
#[serde(default = "default_workers")]
pub workers: usize,
#[serde(default = "default_backlog")]
pub backlog: u32,
#[serde(default = "default_max_connections")]
pub max_connections: usize,
#[serde(default = "default_keep_alive")]
pub keep_alive_secs: u64,
pub log_level: String,
}
fn default_workers() -> usize { num_cpus::get() }
fn default_backlog() -> u32 { 4096 }
fn default_max_connections() -> usize { 25_000 }
fn default_keep_alive() -> u64 { 75 }
impl Config {
pub fn from_env() -> Result<Self, config::ConfigError> {
config::Config::builder()
.add_source(config::Environment::default().separator("__"))
.build()?
.try_deserialize()
}
}The __ separator lets you pass BIND_ADDR=0.0.0.0:8080, DATABASE_URL=postgres://..., JWT_SECRET=..., WORKERS=8 cleanly. The #[serde(default)] attributes hold the sensible defaults; anything required (bind_addr, database_url, jwt_secret) is omitted from the defaults and will fail-fast if unset.
4. Module layout: by feature, not by type
Group code by what it does, not by what it is. A handlers/ directory of 40 files is harder to navigate than a users/ directory of 4 (mod.rs, handlers.rs, model.rs, db.rs). The rule of thumb that scales: keep a single module under ~400 lines of substantive code; at 400+ lines, split into a child module by feature.
src/
├── lib.rs
├── main.rs
├── config.rs
├── state.rs
├── error.rs
├── telemetry.rs
├── routes.rs
├── auth/
│ ├── mod.rs // pub mod jwt; pub mod session;
│ ├── jwt.rs
│ ├── session.rs
│ └── middleware.rs
├── users/
│ ├── mod.rs // pub mod handlers; pub mod model; pub mod db;
│ ├── handlers.rs
│ ├── model.rs
│ └── db.rs
└── orders/
├── mod.rs
├── handlers.rs
├── model.rs
└── db.rsFor the underlying Cargo workspace, profile, and Cargo.lock discipline, the Cargo best practices guide is the companion reference. For first-time Actix setup (cargo new, hello-world, first handler), start with Actix Web getting started. The framework's official feature surface lives on the Actix Web tool profile.
Routing & Extractors: The Type-Safe Request Contract
Routing in Actix Web is the place where the type system pays its biggest rent. Every parameter your handler declares is a typed extraction from the incoming request, and the compiler refuses to let you forget a field or accept the wrong shape. The practices below are the ones that scale a handler-count from 10 to 200 without the routing layer becoming a tangled match statement.
5. Nest routes with web::scope(), not by repeating path prefixes
Every API has a versioned prefix (/api/v1), authentication groups, and resource hierarchies. Build them with web::scope() so the prefix lives in exactly one place.
// src/routes.rs
use actix_web::web;
use crate::{auth, users, orders};
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api/v1")
.service(
web::scope("/auth")
.route("/login", web::post().to(auth::handlers::login))
.route("/logout", web::post().to(auth::handlers::logout))
.route("/me", web::get().to(auth::handlers::me)),
)
.service(
web::scope("/users")
.wrap(auth::middleware::require_auth())
.route("", web::get().to(users::handlers::list))
.route("", web::post().to(users::handlers::create))
.route("/{id}", web::get().to(users::handlers::get))
.route("/{id}", web::patch().to(users::handlers::update))
.route("/{id}", web::delete().to(users::handlers::delete)),
)
.service(
web::scope("/orders")
.wrap(auth::middleware::require_auth())
.route("", web::post().to(orders::handlers::create))
.route("/{id}", web::get().to(orders::handlers::get)),
),
)
.route("/healthz", web::get().to(|| async { actix_web::HttpResponse::Ok().body("ok") }))
.route("/readyz", web::get().to(handlers::readyz));
}The nested web::scope calls compose; the .wrap() on the inner scope applies only to that scope's routes, which is the right scoping for "everything under /users requires auth, but /auth/login does not." Reading the route file from top to bottom tells you the API surface in 30 lines.
6. Prefer attribute macros for handler-local routes, builders for composition
Actix Web supports two routing styles. Attribute macros (#[get("/users/{id}")]) put the route definition next to the handler; the builder API (web::resource("/users/{id}").route(web::get().to(get_user))) puts the route in a separate routing config. Both work. The convention that scales:
- Use attribute macros for simple, isolated handlers that have one canonical path (
/healthz,/metrics, single-shot webhooks). - Use the builder API for anything resource-shaped (REST CRUD), because resources have multiple methods on the same path and the builder lets you declare them together.
// Attribute macro: good for single-purpose endpoints
#[actix_web::get("/healthz")]
pub async fn healthz() -> impl actix_web::Responder {
actix_web::HttpResponse::Ok().body("ok")
}
// Builder: good for resource handlers (multiple methods on one path)
pub fn user_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/users/{id}")
.route(web::get().to(users::handlers::get))
.route(web::patch().to(users::handlers::update))
.route(web::delete().to(users::handlers::delete)),
);
}The attribute-macro style trips up newcomers when they need to add middleware: you cannot wrap a single attribute-macro'd handler in middleware directly. Wrap it via the parent scope, or convert to the builder API.
7. Use every built-in extractor; know the limits
Actix Web ships eight extractors out of the box. Each one has a config struct that controls limits, error mapping, and content-type handling. The full inventory, with the configuration you usually want:
// Path: positional or named segment captures from the URL
async fn get_user(path: web::Path<uuid::Uuid>) -> Result<HttpResponse, AppError> { ... }
// Query: serde-deserialized query string
#[derive(serde::Deserialize)]
struct ListParams { page: Option<u32>, per_page: Option<u32> }
async fn list_users(q: web::Query<ListParams>) -> Result<HttpResponse, AppError> { ... }
// Json: serde-deserialized body
#[derive(serde::Deserialize)]
struct CreateUser { email: String, name: String }
async fn create_user(body: web::Json<CreateUser>) -> Result<HttpResponse, AppError> { ... }
// Form: application/x-www-form-urlencoded body
async fn login(form: web::Form<LoginForm>) -> Result<HttpResponse, AppError> { ... }
// Bytes / String / Payload: raw body access
async fn webhook(body: web::Bytes) -> Result<HttpResponse, AppError> { ... }
// Data: shared application state
async fn list(state: web::Data<AppState>) -> Result<HttpResponse, AppError> { ... }
// Header: typed-header reads via actix-web-httpauth or hand-rolled FromRequestThe hard limits to remember:
- Maximum 12 extractors per handler. This is a real compile-time error; the framework can't statically generate the trait impl past 12. Handlers needing more than 12 are usually over-doing the extractors; extract a struct with
FromRequestinstead. - Default JSON payload limit is 32 KiB. Set
web::JsonConfig::default().limit(4 * 1024 * 1024)at theApplevel for API payloads, or per-scope/per-resource where smaller limits make sense. - Default form limit is 16 KiB. Same fix with
web::FormConfig::default().limit(...). web::Bytesreads the full body into memory. For streaming uploads, useactix-multipartor read thePayloaddirectly.
// In build_app:
.app_data(web::JsonConfig::default()
.limit(4 * 1024 * 1024)
.error_handler(|err, _req| {
actix_web::error::InternalError::from_response(
err,
actix_web::HttpResponse::BadRequest().json(serde_json::json!({
"error": "invalid_json_body"
})),
).into()
})
)8. Implement custom FromRequest for cross-cutting context
When the same three lines of "pull X from the request" repeat in five handlers, lift it into a FromRequest impl. Common candidates: authenticated-user identity, request-id, trace-id, tenant-id from a subdomain or header.
// src/auth/jwt.rs
use actix_web::{dev::Payload, FromRequest, HttpRequest, web};
use std::future::{ready, Ready};
use crate::error::AppError;
pub struct AuthUser {
pub id: uuid::Uuid,
pub role: String,
}
impl FromRequest for AuthUser {
type Error = AppError;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let token = req
.headers()
.get(actix_web::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let state = req.app_data::<web::Data<crate::AppState>>();
let result = match (token, state) {
(Some(token), Some(state)) => {
let key = jsonwebtoken::DecodingKey::from_secret(state.cfg.jwt_secret.as_bytes());
let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
jsonwebtoken::decode::<Claims>(token, &key, &validation)
.map(|td| AuthUser { id: td.claims.sub, role: td.claims.role })
.map_err(|_| AppError::Unauthorized)
}
_ => Err(AppError::Unauthorized),
};
ready(result)
}
}
#[derive(serde::Deserialize)]
struct Claims { sub: uuid::Uuid, role: String, exp: usize }Handlers now declare user: AuthUser and the extractor does the parsing, decoding, and validation. No unwrap(), no per-handler boilerplate, no chance of forgetting the check on a new endpoint.
For the Actix Web vs Axum extractor comparison (axum's FromRequest is similar in shape but Tower-flavored), see Actix Web vs Axum.
Middleware Chains: wrap() Order, Cors, Logger, Rate Limit, Tracing
Middleware in Actix Web composes in a specific order and the order matters. Middleware registered with .wrap() runs in the reverse of declaration order on the way in, and in the declaration order on the way out; last-wrap-wins on requests, first-wrap-wins on responses. Getting this wrong puts your tracing span outside the auth check, your CORS preflight inside the rate limiter, or your compression layer above the request logger. The patterns below are the ones every production Actix app converges on.
9. Standardize the wrap order: telemetry outermost, auth innermost
The canonical ordering, applied in the build_app factory:
App::new()
.app_data(web::Data::from(state))
.app_data(web::JsonConfig::default().limit(4 * 1024 * 1024))
// 1. Outermost: tracing — captures every request including failures upstream
.wrap(tracing_actix_web::TracingLogger::default())
// 2. Request-id propagation, in/out
.wrap(actix_web::middleware::DefaultHeaders::new()
.add(("x-content-type-options", "nosniff"))
.add(("x-frame-options", "DENY")))
// 3. Compression (after tracing so the trace sees the uncompressed body length)
.wrap(actix_web::middleware::Compress::default())
// 4. Path normalization
.wrap(actix_web::middleware::NormalizePath::trim())
// 5. CORS (before auth — preflight must not hit the auth layer)
.wrap(actix_cors::Cors::default()
.allowed_origin("https://app.example.com")
.allowed_methods(vec!["GET", "POST", "PATCH", "DELETE"])
.allowed_headers(vec![actix_web::http::header::AUTHORIZATION, actix_web::http::header::CONTENT_TYPE])
.max_age(3600))
.configure(routes::init)Per-scope middleware (auth, rate-limit) applies inside the scope where it belongs, not at the App level. That keeps /healthz and /readyz unauthenticated while everything under /api/v1/users requires a logged-in identity.
10. CORS: use actix-cors, never write the headers by hand
Anti-pattern row 7. The CORS preflight contract is more subtle than the Access-Control-Allow-* headers themselves; OPTIONS handling, vary headers, credentials, and max-age all interact. The actix-cors crate gets it right:
use actix_cors::Cors;
use actix_web::http::header;
let cors = Cors::default()
.allowed_origin("https://app.example.com")
.allowed_origin("https://admin.example.com")
.allowed_methods(vec!["GET", "POST", "PATCH", "DELETE", "OPTIONS"])
.allowed_headers(vec![header::AUTHORIZATION, header::CONTENT_TYPE, header::ACCEPT])
.expose_headers(vec![header::CONTENT_DISPOSITION])
.supports_credentials()
.max_age(3600);For development, Cors::permissive() is the right shortcut; for production, name the exact origins and methods and let CORS fail closed on anything else.
11. Logger: tracing-actix-web, not the built-in actix_web::middleware::Logger
The built-in Logger middleware writes Apache-style access logs to stdout, which were fine in 2018 and are not useful in a modern observability stack. The community-standard answer is tracing-actix-web (tracing-actix-web = "0.7"), which emits per-request tracing spans with a propagated request_id, structured JSON output when the subscriber is JSON-configured, and OpenTelemetry interop. Initialize the subscriber once at startup:
// src/telemetry.rs
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub fn init(cfg: &crate::Config) {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&cfg.log_level));
let json_logs = std::env::var("JSON_LOGS").map(|v| v == "true").unwrap_or(true);
if json_logs {
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().json().with_target(true))
.init();
} else {
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().pretty())
.init();
}
}Inside the App, .wrap(tracing_actix_web::TracingLogger::default()) is enough; every handler now runs inside a request span and every tracing::info!, tracing::warn!, tracing::error! call automatically carries the request-id field.
12. Rate limiting via actix-governor
For per-IP or per-tenant rate limits, actix-governor is the production-standard middleware on Actix 4.x. It uses the GCRA algorithm under the hood (the same as upstream proxies like Envoy), supports the governor crate's burst-and-steady parameters, and integrates with web::Data for shared state.
use actix_governor::{Governor, GovernorConfigBuilder};
let governor_conf = GovernorConfigBuilder::default()
.per_second(60)
.burst_size(100)
.finish()
.expect("governor config");
// Apply at the App level or per scope:
.wrap(Governor::new(&governor_conf))The default key is the client IP from the peer_addr() lookup. For tenant-based limiting, implement the KeyExtractor trait and read the tenant from the JWT-derived AuthUser. The 429 response is automatic, with a Retry-After header populated from the bucket state.
13. Custom middleware with wrap_fn() for one-off logic
For middleware that doesn't justify a full Transform impl, wrap_fn is the inline closure form. Use it for headers, redirects, observability hooks, or quick guards.
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::body::{BoxBody, MessageBody};
.wrap_fn(|req, srv| {
let start = std::time::Instant::now();
let fut = srv.call(req);
async move {
let res = fut.await?;
let elapsed_ms = start.elapsed().as_millis();
tracing::info!(elapsed_ms, "request completed");
Ok(res)
}
})For long-lived middleware (auth, request-id propagation, per-request DB transaction binding), graduate to a real Transform + Service pair so the code is testable in isolation. The wrap_fn style is fine until the closure exceeds ~30 lines.
Error Handling: ResponseError, thiserror, anyhow, and the 4xx/5xx Contract
Anti-pattern row 2. Actix Web has a precise contract for errors: implement ResponseError on your error type and return Result<HttpResponse, YourError> from handlers. The framework calls error_response() to produce the HTTP response and status_code() to set the status. Everything else is wiring around that single trait.
14. Define one AppError per service; lean on thiserror for typed variants
The right error model for an API service is one enum with all the domain-meaningful variants. thiserror generates the Display and Error impls; you write the ResponseError impl once.
// src/error.rs
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("validation failed: {0}")]
Validation(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("conflict: {0}")]
Conflict(String),
#[error("rate limited")]
RateLimited,
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("internal: {0}")]
Internal(#[from] anyhow::Error),
}
impl ResponseError for AppError {
fn status_code(&self) -> StatusCode {
match self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::BadRequest(_) | AppError::Validation(_) => StatusCode::BAD_REQUEST,
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::Forbidden => StatusCode::FORBIDDEN,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
let status = self.status_code();
let code = match self {
AppError::NotFound(_) => "not_found",
AppError::BadRequest(_) => "bad_request",
AppError::Validation(_) => "validation_failed",
AppError::Unauthorized => "unauthorized",
AppError::Forbidden => "forbidden",
AppError::Conflict(_) => "conflict",
AppError::RateLimited => "rate_limited",
AppError::Database(_) | AppError::Internal(_) => "internal_error",
};
// Log internal errors at warn/error level so they reach the alerting stack
if matches!(self, AppError::Database(_) | AppError::Internal(_)) {
tracing::error!(error = %self, "internal error");
}
HttpResponse::build(status).json(json!({
"error": {
"code": code,
"message": self.to_string(),
"status": status.as_u16(),
}
}))
}
}15. anyhow for application code, thiserror for the public API boundary
The choice between anyhow::Error and a typed thiserror enum is not either/or. Use anyhow::Result inside the service layer (where the call stack is internal and you want easy .context("...") annotations) and convert at the handler boundary into AppError (which has the ResponseError impl and the HTTP shape). The #[from] anyhow::Error variant in the enum above gives you free conversion: any function that returns anyhow::Result<T> is usable in a handler that returns Result<HttpResponse, AppError>.
// service layer: anyhow for ergonomic context
async fn create_user_in_db(pool: &PgPool, email: &str) -> anyhow::Result<User> {
sqlx::query_as::<_, User>("INSERT INTO users (email) VALUES ($1) RETURNING *")
.bind(email)
.fetch_one(pool)
.await
.with_context(|| format!("create_user_in_db: email={email}"))
}
// handler: typed error for HTTP mapping
pub async fn create_user(
state: web::Data<AppState>,
body: web::Json<CreateUserInput>,
) -> Result<HttpResponse, AppError> {
if body.email.is_empty() {
return Err(AppError::Validation("email required".into()));
}
let user = create_user_in_db(&state.db, &body.email).await?;
Ok(HttpResponse::Created().json(user))
}16. Map known infrastructure errors to your domain error explicitly
#[from] sqlx::Error is a starting point, not a finishing point. For the specific cases where your application has a known mapping (unique-constraint → Conflict, no-rows-found → NotFound), match the underlying error and produce the right AppError.
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
match &e {
sqlx::Error::RowNotFound => AppError::NotFound("resource not found".into()),
sqlx::Error::Database(dberr) if dberr.code().as_deref() == Some("23505") => {
AppError::Conflict("unique constraint".into())
}
_ => {
tracing::error!(error = %e, "unhandled sqlx error");
AppError::Database(e)
}
}
}
}The 23505 SQLSTATE is Postgres's unique-violation code. Map it explicitly so the API returns a clean 409 instead of a 500 on a duplicate-email signup.
Database Integration: sqlx First, web::Data<PgPool>, web::block for the Sync Cases
Actix Web is database-agnostic. The community converges on sqlx for async-native Postgres/MySQL/SQLite, on diesel for the synchronous-but-typed ORM camp, and on mongodb for document workloads. The patterns below assume sqlx 0.8 with Postgres; the diesel route is in section 19.
17. Build the PgPool once in main; inject via web::Data
Anti-pattern row 6. The HttpServer::new factory closure runs once per worker, so building the PgPool inside it produces N independent pools and N times the connection count to Postgres. Build the pool once before HttpServer::new, then clone the Arc-backed handle into each worker.
// src/db.rs
use sqlx::postgres::{PgPool, PgPoolOptions};
use std::time::Duration;
use crate::Config;
pub async fn pool(cfg: &Config) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Some(Duration::from_secs(600)))
.max_lifetime(Some(Duration::from_secs(1800)))
.connect(&cfg.database_url)
.await
}The connection-pool sizing rule of thumb: max_connections per service instance × instance count ≤ Postgres max_connections minus the budget you reserve for migrations and the admin user. A typical 8-instance deploy with 20 connections per pool consumes 160 of Postgres's default 100-connection limit, so plan accordingly (either raise Postgres's max_connections, or front it with PgBouncer in transaction-pool mode).
18. Run migrations at startup with sqlx::migrate!
The sqlx::migrate! macro compiles every file under ./migrations/ into the binary and runs them at startup against the live database. The migrations are tracked in the _sqlx_migrations table, so re-runs are idempotent.
// In run() before HttpServer::new:
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");Pair this with a CI step that runs sqlx migrate run against a disposable test database so PRs that introduce a broken migration fail before merge.
19. The web::block escape hatch for synchronous drivers (Diesel)
Diesel is synchronous; calling it directly in an async handler blocks the worker (anti-pattern row 3). The Actix Web answer is web::block, which off-loads the closure to a blocking-thread pool managed by Tokio.
pub async fn get_user_diesel(
state: web::Data<AppState>,
path: web::Path<uuid::Uuid>,
) -> Result<HttpResponse, AppError> {
let user_id = path.into_inner();
let pool = state.diesel_pool.clone();
let user = web::block(move || {
let mut conn = pool.get()?;
users::table.find(user_id).first::<User>(&mut conn)
})
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("blocking pool: {e}")))?
.map_err(AppError::from)?;
Ok(HttpResponse::Ok().json(user))
}For new services, the answer in 2026 is sqlx. The diesel/web::block pattern is for codebases already on Diesel where the migration cost outweighs the async-native gain.
20. Transactions across multi-step handlers
For handlers that do two or more writes that must succeed together, hold an sqlx::Transaction and commit at the end. The borrow-checker rules force you to thread the transaction through the call stack rather than smuggling it via app state, which is the right thing.
pub async fn transfer(
state: web::Data<AppState>,
body: web::Json<TransferInput>,
) -> Result<HttpResponse, AppError> {
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2")
.bind(body.amount)
.bind(body.from_account)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2")
.bind(body.amount)
.bind(body.to_account)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(HttpResponse::Ok().finish())
}The &mut *tx deref pattern is what lets you pass the transaction into multiple sqlx::query calls without moving it. If any query returns an error, the ? propagates and tx drops; sqlx rolls back automatically on drop without an explicit commit.
21. Use compile-time-checked queries where possible
The sqlx::query! macro family checks your SQL against the live database at compile time. The build fails if the SQL is malformed, references a non-existent column, or returns a row shape that doesn't match the Rust struct. Worth the setup pain (a DATABASE_URL set at compile time and cargo sqlx prepare for offline builds).
let user = sqlx::query_as!(
User,
r#"
SELECT id as "id: uuid::Uuid", email, name, created_at
FROM users
WHERE id = $1
"#,
user_id,
)
.fetch_one(&state.db)
.await?;For CI builds where the database is not available at compile time, run cargo sqlx prepare --workspace locally and commit the resulting .sqlx/ directory. CI builds then run with SQLX_OFFLINE=true and use the prepared metadata.
Authentication & Authorization: actix-identity, actix-session, and JWT Middleware
Two end-to-end patterns: session cookies for browser-first apps (web app, admin panel) and bearer JWT for API-first apps (mobile, third-party). Both are first-class in Actix Web 4.x and both have official companion crates.
22. Session cookies with actix-identity + actix-session
actix-session is the storage layer (cookie, Redis, memory); actix-identity is the abstraction over "is this request authenticated, and as whom". The cookie session is the right default for a server-rendered or first-party-frontend app.
use actix_identity::IdentityMiddleware;
use actix_session::{config::PersistentSession, storage::CookieSessionStore, SessionMiddleware};
use actix_web::cookie::{time::Duration, Key};
let secret_key = Key::from(cfg.session_secret.as_bytes());
App::new()
.wrap(IdentityMiddleware::default())
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
.cookie_name("sid".into())
.cookie_secure(true)
.cookie_http_only(true)
.cookie_same_site(actix_web::cookie::SameSite::Lax)
.session_lifecycle(PersistentSession::default().session_ttl(Duration::days(7)))
.build(),
)Handlers then accept identity: actix_identity::Identity and call Identity::login(&req.extensions(), user.id.to_string()) to set it, id.logout() to clear it.
use actix_identity::Identity;
use actix_web::HttpMessage;
pub async fn login(
state: web::Data<AppState>,
req: actix_web::HttpRequest,
body: web::Json<LoginInput>,
) -> Result<HttpResponse, AppError> {
let user = state.users.verify_password(&body.email, &body.password).await?;
Identity::login(&req.extensions(), user.id.to_string())
.map_err(|e| AppError::Internal(anyhow::anyhow!("identity login: {e}")))?;
Ok(HttpResponse::Ok().json(user))
}
pub async fn logout(id: Identity) -> impl actix_web::Responder {
id.logout();
HttpResponse::Ok().finish()
}For Redis-backed sessions (multiple app instances behind a load balancer where cookie-storage is too large), swap CookieSessionStore::default() for RedisSessionStore::new(redis_url).await?. The rest of the wiring stays identical.
23. JWT bearer auth with actix-web-httpauth
For machine-to-machine APIs and mobile clients, JWT bearer tokens are the right answer. actix-web-httpauth provides the bearer-extraction middleware; you supply the validator closure.
use actix_web_httpauth::extractors::bearer::BearerAuth;
use actix_web_httpauth::middleware::HttpAuthentication;
use jsonwebtoken::{decode, DecodingKey, Validation};
async fn jwt_validator(
req: actix_web::dev::ServiceRequest,
creds: BearerAuth,
) -> Result<actix_web::dev::ServiceRequest, (actix_web::Error, actix_web::dev::ServiceRequest)> {
let state = req.app_data::<web::Data<crate::AppState>>().cloned();
let token = creds.token().to_string();
let result = match state {
Some(state) => {
let key = DecodingKey::from_secret(state.cfg.jwt_secret.as_bytes());
let validation = Validation::new(jsonwebtoken::Algorithm::HS256);
decode::<Claims>(&token, &key, &validation)
}
None => return Err((actix_web::error::ErrorInternalServerError("missing state"), req)),
};
match result {
Ok(token_data) => {
req.extensions_mut().insert(AuthUser {
id: token_data.claims.sub,
role: token_data.claims.role,
});
Ok(req)
}
Err(_) => Err((actix_web::error::ErrorUnauthorized("invalid token"), req)),
}
}
// Apply at the App or scope level:
.wrap(HttpAuthentication::bearer(jwt_validator))Handlers downstream pull AuthUser out of request extensions (or via the custom FromRequest impl from section 8). The token issuance side is a standard jsonwebtoken::encode call in the login handler, with the signing key sourced from state.jwt.
24. Role-based authorization via custom guard middleware
actix-web ships web::guard for path-level matching; for role-based authorization, wrap the scope in custom middleware that reads AuthUser and rejects forbidden roles.
pub fn require_role(role: &'static str) -> impl actix_web::dev::Transform<...> {
actix_web_httpauth::middleware::HttpAuthentication::with_fn(move |req, _creds| async move {
let auth = req.extensions().get::<AuthUser>().cloned();
match auth {
Some(u) if u.role == role || u.role == "admin" => Ok(req),
_ => Err((actix_web::error::ErrorForbidden("forbidden"), req)),
}
})
}
// Apply at the scope level:
web::scope("/admin").wrap(require_role("admin"))The simpler shape (a wrap_fn closure) is fine for one-off role checks; lift to a real Transform impl when the role logic gets richer than two roles plus admin.
Testing: actix_web::test::TestRequest, init_service, and Integration Scaffolds
Anti-pattern row 4 again. Testing in Actix Web is only painful if your handlers are tightly coupled to the HttpServer builder. With the build_app factory pattern, every test reconstructs the exact same App graph and exercises it through the real request pipeline.
25. Unit-test handlers with test::TestRequest::default()
For pure handler logic (no DB, no external HTTP), the lightest test is a direct call.
#[actix_web::test]
async fn healthz_returns_ok() {
let app = actix_web::test::init_service(
actix_web::App::new().route("/healthz", actix_web::web::get().to(crate::handlers::healthz)),
).await;
let req = actix_web::test::TestRequest::get().uri("/healthz").to_request();
let resp = actix_web::test::call_service(&app, req).await;
assert!(resp.status().is_success());
}The #[actix_web::test] attribute is the async-aware test runner; it spawns the Actix runtime so .await works inside the test function. Without it, you'd get the Tokio runtime not running error.
26. Integration-test with the same build_app the binary uses
Integration tests construct a real AppState (with a test database), pass it through the production build_app factory, and exercise the API the way a real client would. The win: production and test stand up the same App; bug surfaces are real.
// tests/users_integration.rs
use my_api::{build_app, AppState, Config};
use sqlx::PgPool;
use std::sync::Arc;
async fn test_app() -> impl actix_web::dev::Service<
actix_web::dev::ServiceRequest,
Response = actix_web::dev::ServiceResponse,
Error = actix_web::Error,
> {
let cfg = Config::from_env().expect("test env");
let pool: PgPool = PgPool::connect(&cfg.database_url).await.expect("pg");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate");
let state = Arc::new(AppState::new(cfg, pool));
actix_web::test::init_service(build_app(state)).await
}
#[actix_web::test]
async fn create_then_get_user() {
let app = test_app().await;
let create_req = actix_web::test::TestRequest::post()
.uri("/api/v1/users")
.set_json(serde_json::json!({"email": "[email protected]", "name": "A"}))
.to_request();
let create_resp = actix_web::test::call_service(&app, create_req).await;
assert!(create_resp.status().is_success());
let body: serde_json::Value = actix_web::test::read_body_json(create_resp).await;
let id = body["id"].as_str().unwrap();
let get_req = actix_web::test::TestRequest::get()
.uri(&format!("/api/v1/users/{id}"))
.to_request();
let get_resp = actix_web::test::call_service(&app, get_req).await;
assert!(get_resp.status().is_success());
}27. Isolate the test database with sqlx::test
The sqlx::test attribute (sqlx 0.8) creates a per-test transactional database snapshot and rolls back at the end of the test. Combined with the integration pattern above, every test runs against a fresh database state without polluting the next test.
#[sqlx::test(migrations = "./migrations")]
async fn user_count_starts_at_zero(pool: PgPool) {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 0);
}For tests that need the full AppState (not just a pool), inject the test pool into the state factory and reuse the integration scaffold above.
28. Inject mocks via Box<dyn Trait> in AppState
For handlers that call third-party HTTP APIs, wrap the dependency in a trait and store Box<dyn UserRepo + Send + Sync> in AppState. Production wires the real implementation; tests wire a mock built with mockall.
#[cfg_attr(test, mockall::automock)]
pub trait UserRepo: Send + Sync {
fn find_by_email(&self, email: &str) -> Result<Option<User>, AppError>;
}The pattern keeps the handler logic testable without spinning up the whole HTTP/DB stack for every test, and it's the only way to deterministically test failure paths in third-party calls.
Deployment, Performance & Observability
Every other section is wasted if the binary you ship to production starts in 8 seconds, drops requests on deploy, or doesn't expose the metrics that tell you it's working. The patterns below are the operational floor for a production Actix Web service.
29. Tune the server configuration explicitly; don't trust the defaults
The configuration knobs that move latency and capacity most are workers, backlog, max_connections, and keep_alive. The defaults are workable; the explicit settings below are what production-tuned services converge on for a typical REST API behind a load balancer.
| Setting | Default | Recommendation | Why |
|---|---|---|---|
.workers(n) | num_cpus::get() | One per CPU core, leave one for the OS/sidecars | Saturates cores without thrashing |
.backlog(n) | 2048 | 4096–8192 | Smooths SYN bursts during deploys |
.max_connections(n) | 25,000 | Set explicitly; memory-bound | Each connection is a few KiB; cap to keep RSS predictable |
.max_connection_rate(n) | 256 | 256–512 per worker | Throttles new-connection storms |
.keep_alive(d) | 5s | 60–75s | Matches typical LB idle timeouts; reduces TCP-setup overhead |
.client_request_timeout(d) | 5s | 30s | Tolerates slow clients without dropping legitimate requests |
.shutdown_timeout(s) | 30 | 30 | Drain in-flight requests on SIGTERM |
HttpServer::new(move || build_app(state.clone()))
.workers(cfg.workers)
.backlog(cfg.backlog)
.max_connections(cfg.max_connections)
.keep_alive(std::time::Duration::from_secs(cfg.keep_alive_secs))
.client_request_timeout(std::time::Duration::from_secs(30))
.shutdown_timeout(30)
.bind(&cfg.bind_addr)?
.run()30. Terminate TLS at the load balancer; if you can't, use rustls
The right TLS posture for 90% of services is "terminate at the LB" (nginx, Envoy, the cloud-managed LB, or an Ingress controller). The application speaks plain HTTP inside the cluster. The reasons compound: certificate rotation is centralized, the LB handles HTTP/2 and HTTP/3 negotiation, and the application binary stays smaller.
When you do need TLS in-process (single-tenant edge deploy, bare-metal install, mutual TLS), use rustls (the pure-Rust TLS implementation, no OpenSSL dependency).
use rustls::{pki_types::CertificateDer, ServerConfig};
use rustls_pemfile::{certs, pkcs8_private_keys};
use std::fs::File;
use std::io::BufReader;
fn rustls_config(cert_path: &str, key_path: &str) -> ServerConfig {
let cert_file = &mut BufReader::new(File::open(cert_path).expect("cert"));
let key_file = &mut BufReader::new(File::open(key_path).expect("key"));
let cert_chain: Vec<CertificateDer> = certs(cert_file).filter_map(|c| c.ok()).collect();
let mut keys: Vec<_> = pkcs8_private_keys(key_file).filter_map(|k| k.ok()).collect();
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, keys.remove(0).into())
.expect("rustls config")
}
// In run():
.bind_rustls_0_23(&cfg.bind_addr, rustls_config(&cfg.cert, &cfg.key))?Pin the dependency tightly: rustls = "0.23", rustls-pemfile = "2", actix-tls = "3". The version axis on rustls moves fast and the bind_rustls_0_23 method name encodes the rustls major version explicitly.
31. Release-profile recipe: measured binary-size impact
Anti-pattern row 8. The default [profile.release] is workable for development but ships a binary 2–3x larger than the tuned profile. The four settings that matter and the measured impact on a typical Actix REST API (compiled on Rust 1.85, x86_64-unknown-linux-gnu, ~30 dependencies including sqlx and tokio):
| Configuration | Binary size | Clean compile |
|---|---|---|
Default [profile.release] | 19.4 MB | 92 s |
+ lto = "fat" | 14.2 MB | 168 s |
+ codegen-units = 1 | 13.6 MB | 245 s |
+ strip = "symbols" | 7.1 MB | 245 s |
+ panic = "abort" | 6.4 MB | 245 s |
The bottom-row configuration is the right answer for a binary shipped in a container image; the smaller binary means a smaller image, faster pulls, and faster cold start. For development on a release-mode build (uncommon), use a custom profile that drops LTO for faster iteration. For the Cargo profile mechanics in depth, the Cargo best practices guide is the companion reference.
32. Graceful shutdown: signal handlers + drain timeout
Anti-pattern row 10. Kubernetes sends SIGTERM when it wants the pod to stop. The application has up to terminationGracePeriodSeconds to finish in-flight work and exit before SIGKILL lands. Actix Web's ServerHandle::stop(true) does the graceful drain; the wiring is tokio::signal + a spawned task that calls into it.
pub async fn graceful_shutdown(handle: actix_web::dev::ServerHandle) {
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = signal(SignalKind::terminate()).expect("sigterm");
let mut sigint = signal(SignalKind::interrupt()).expect("sigint");tokio::select! {
_ = sigterm.recv() => tracing::info!("received SIGTERM"),
_ = sigint.recv() => tracing::info!("received SIGINT"),
}
tracing::info!("starting graceful shutdown");
handle.stop(true).await;
tracing::info!("server stopped");}
In the Kubernetes pod spec, set `terminationGracePeriodSeconds: 45` so the orchestrator gives the drain enough time to finish. Pair this with `/readyz` returning 503 once shutdown begins so the load balancer routes traffic away from the draining pod.
### 33. Health endpoints: separate liveness and readiness
`/healthz` (liveness) tells the orchestrator "this process is alive; do not restart." `/readyz` (readiness) tells it "this process is ready to take traffic." During shutdown, liveness stays green (the process IS alive, finishing work) and readiness flips to 503 (the process should NOT receive new requests).
```rust
pub async fn healthz() -> impl actix_web::Responder {
actix_web::HttpResponse::Ok().body("ok")
}
pub async fn readyz(state: web::Data<AppState>) -> impl actix_web::Responder {
if state.shutting_down.load(std::sync::atomic::Ordering::SeqCst) {
return actix_web::HttpResponse::ServiceUnavailable().body("draining");
}
// Probe a downstream dep — fast, low-cost
match sqlx::query("SELECT 1").fetch_one(&state.db).await {
Ok(_) => actix_web::HttpResponse::Ok().body("ready"),
Err(_) => actix_web::HttpResponse::ServiceUnavailable().body("db not ready"),
}
}The two endpoints map to the two Kubernetes probes (livenessProbe, readinessProbe). Misconfiguring them is one of the most common deploy-day bugs in Actix services; separating them explicitly avoids the trap.
34. Prometheus metrics middleware via actix-web-prom
The community-standard metrics middleware on Actix 4.x is actix-web-prom, which exposes the canonical RED metrics (Rate, Errors, Duration) at /metrics in Prometheus exposition format.
use actix_web_prom::PrometheusMetricsBuilder;
let prometheus = PrometheusMetricsBuilder::new("my_api")
.endpoint("/metrics")
.build()
.expect("prometheus");
App::new()
.wrap(prometheus.clone())
// ... rest of the AppThe my_api_http_requests_total, my_api_http_requests_duration_seconds, and the per-status-code counters are the floor for an Actix service in production; pair them with a Grafana dashboard and an alert on the errors_per_minute > N time series.
35. Tracing: tracing-actix-web with OpenTelemetry export
For end-to-end distributed tracing, layer tracing-opentelemetry on top of the tracing subscriber and export to a collector (Jaeger, Tempo, the Otel collector itself). The wiring is one extra layer in the subscriber init from section 11; every Actix request span then propagates as an Otel trace.
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(opentelemetry_otlp::new_exporter().tonic())
.install_batch(opentelemetry_sdk::runtime::Tokio)
.expect("otlp pipeline");
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer().json())
.with(telemetry)
.init();The combination of structured JSON logs, Prometheus metrics, and Otel traces is the three-legged stool of observability. Actix Web 4.x supports all three with first-party or community crates; the wiring above is what every production Actix service in 2026 should ship with.
When to Reconsider Actix Web
This page is for engineers committed to staying on actix-web. If your evaluation is really about something else — Tower middleware compatibility, compile-error noise, slower upstream release cadence, the actor model being overkill for stateless services — that conversation belongs on the Actix Web alternatives guide, which works through all the live Rust options (Axum, Rocket, Warp, Salvo, Poem, Loco) plus when the right call is to stay on Actix. For the head-to-head with Axum specifically, see Actix Web vs Axum. For Axum-specific patterns, the Axum best practices guide is the sibling reference.
The honest answer most teams find: the framework was never the bottleneck. The patterns in this guide are what they were missing.
Frequently Asked Questions
Is Actix Web 4.x stable and production-ready in 2026?
Yes. Actix Web 4.0 stabilized in February 2022 and the 4.x line has been the production-stable branch since. As of May 2026 the current minor is 4.x on actix-web (check crates.io for the current patch), the MSRV is Rust 1.75+, and the framework runs on stable Rust with no nightly features required. Actix Web 5.0 is on the upstream roadmap with breaking changes to extractor handling and middleware composition, but it is not in stable release as of May 2026. For a new service today, pin actix-web = "4" and the companion crates (actix-cors = "0.7", actix-identity = "0.8", actix-session = "0.10", actix-web-httpauth = "0.8", tracing-actix-web = "0.7") and you are on the modern current stack.
How do I share state across handlers in Actix Web?
Build an AppState struct holding every shared resource (Postgres pool, HTTP client, JWT key, feature flags), wrap it in Arc, register it once with .app_data(web::Data::from(state)), and extract it in handlers as state: web::Data<AppState>. The HttpServer::new closure runs once per worker, and web::Data is internally an Arc, so the per-worker clone is a refcount bump rather than a deep copy. Anti-pattern row 1 in the table at the top of this page is what to avoid — do not call state.clone() inside the closure on every request.
What's the difference between Actix Web and Tokio's native HTTP (Hyper)?
Actix Web is a framework: it sits on top of Tokio (and Hyper, since Actix Web 4) and gives you routing, extractors, middleware, error mapping, and a handler-function API. Hyper is the underlying HTTP implementation: low-level Request/Response types, no routing, no extractors. For 99% of production services you want a framework on top of Hyper rather than Hyper directly, because the framework is where the developer-time-saving lives. The choice is between Actix Web, Axum, Rocket, Warp, and the rest of the Rust framework field; the comparison page for the head-to-head with Axum is at Actix Web vs Axum.
How do I test Actix Web handlers?
Use the #[actix_web::test] attribute for async test runners, actix_web::test::init_service(build_app(state)).await to construct the App graph the binary uses, and actix_web::test::TestRequest to build the request. The full integration scaffold is in section 26 of this guide. The key structural rule is to extract a build_app factory function that both the binary and the test use, so production and test stand up the same App. Per-test database isolation uses the sqlx::test attribute, which provisions a transactional snapshot and rolls back at the end of the test.
What's the best database crate for Actix Web in 2026?
sqlx 0.8 for async-native Postgres/MySQL/SQLite with compile-time query checking. diesel 2.x for the synchronous-but-typed ORM camp; pair it with web::block to off-load the sync calls to a blocking-thread pool. For document workloads, the official mongodb async crate. For a new service today, sqlx is the right default because it composes cleanly with Actix's async story, has a battle-tested connection pool (PgPool), and gives you compile-time query validation via the query!/query_as! macros.
How do I handle authentication in Actix Web?
Two end-to-end patterns, picked by the client type. For browser-first apps, actix-identity 0.8 on top of actix-session 0.10 with cookie storage (or Redis for multi-instance deploys). For machine-to-machine and mobile clients, actix-web-httpauth 0.8 for bearer-token extraction plus jsonwebtoken for token issuance and validation. Both patterns are wired in sections 22 and 23 of this guide with complete code, including a custom FromRequest impl for AuthUser that gives handlers a typed authenticated-user parameter.
Should I use the actor model (actix-web-actors) for my service?
Only when WebSocket fan-out, stateful supervision, or per-connection mailbox semantics are the load-bearing requirement. The actor model is what actix (the crate) provides; actix-web-actors is the bridge that lets actor handlers process WebSocket frames. For a stateless REST/JSON API, handler-functions plus web::Data<AppState> is simpler, faster to compile, and easier to reason about. The "Actix is an actor framework" framing is a 2018-era inheritance; Actix Web 4.x is fundamentally a handler-function framework that happens to have first-class actor support when you need it.
How do I deploy an Actix Web service to production?
Container-first: multi-stage Dockerfile with a rust:1.85-bookworm builder stage and a debian:bookworm-slim runtime stage; non-root user; the tuned [profile.release] from section 31 to keep the binary at ~6 MB; explicit .workers(), .backlog(), .keep_alive(), .shutdown_timeout() settings; /healthz plus /readyz for Kubernetes probes; terminationGracePeriodSeconds: 45 in the pod spec to give the graceful-shutdown drain time to finish. TLS terminates at the load balancer for 90% of deploys; in-process rustls for the edge cases. Observability is the three-legged stool of JSON logs (tracing-actix-web with the JSON subscriber layer), Prometheus metrics (actix-web-prom at /metrics), and Otel traces (tracing-opentelemetry to an OTLP collector).
More Reading
- New to Actix Web? Start with Actix Web getting started for the cargo new, first handler, and first-build walkthrough.
- Considering alternatives? See the Actix Web alternatives guide for the framework-by-framework comparison and when the right call is to stay on Actix.
- For the head-to-head with the Tokio-team's framework, see Actix Web vs Axum.
- For Axum-specific production patterns, see Axum best practices, the sibling guide in this tutorials cluster.
- For the underlying Cargo workspace, profile, and supply-chain layer, see Cargo best practices, which is the companion reference for everything below the framework boundary.
External authority references: the Actix Web official docs and guide (the canonical reference maintained by the Actix Web team) and the actix/actix-web GitHub repository (the source-of-truth issue tracker, current examples, and changelog).
What you'll learn
- Structure an Actix Web service with the lib + thin-bin shape and the build_app factory pattern that lets tests and production stand up the same App
- Wire routing with web::scope() nesting, the 8 built-in extractors, JsonConfig limits, and a custom FromRequest impl for cross-cutting context like AuthUser
- Compose middleware in the canonical wrap order (TracingLogger outermost, Cors before auth, per-scope rate-limit) using actix-cors, tracing-actix-web, and actix-governor
- Build an AppError type with thiserror plus a ResponseError impl that maps domain errors to the right HTTP status codes, including explicit mapping of sqlx errors
- Integrate Postgres with sqlx 0.8 via web::Data<PgPool>, run migrations at startup, use compile-time-checked queries, and hold transactions across multi-step handlers
- Ship the production operational floor: tuned worker/backlog/keep-alive, graceful shutdown via tokio::signal, separate liveness/readiness endpoints, Prometheus metrics, JSON tracing, and OTLP export
Prerequisites
- Rust 1.85+ with the 2024 edition installed via rustup
- Working knowledge of async Rust (Tokio runtime, async/await, Future)
- A Postgres 14+ instance for the database integration patterns (Docker container is fine)
- Familiarity with the basic Actix Web request-handler shape (covered in actix-web-getting-started)
- Comfort with Cargo workspaces, Cargo.toml dependency syntax, and the release-profile mechanics
Step by step
- 1
Adopt the lib + thin-bin shape and pin the modern stack
Restructure as a library crate plus a six-line src/main.rs. Pin actix-web 4, the companion crates (actix-cors 0.7, actix-identity 0.8, actix-session 0.10, actix-web-httpauth 0.8, tracing-actix-web 0.7), and the release profile (lto = fat, codegen-units = 1, strip = symbols, panic = abort). Edition 2024, Rust 1.85+.
- 2
Centralize shared resources in AppState and extract a build_app factory
Wrap the Postgres pool, HTTP client, configuration, and JWT signing key in an Arc<AppState>. Register once with .app_data(web::Data::from(state)). Extract build_app(state) so the binary and tests stand up the same App. This is the structural fix for anti-pattern rows 1 and 4.
- 3
Wire routing with web::scope() and the 8 built-in extractors
Nest routes by API version, auth boundary, and resource. Use attribute macros for single-purpose endpoints and the builder API for resource handlers. Set JsonConfig::default().limit(4 * 1024 * 1024) at the App level. Implement a custom FromRequest for cross-cutting context like AuthUser.
- 4
Compose middleware with the canonical wrap order
Telemetry outermost (TracingLogger), then DefaultHeaders, then Compress, then NormalizePath, then Cors, then per-scope auth and rate-limit. Use actix-cors for CORS, tracing-actix-web for request spans, and actix-governor for rate limiting. Lift one-off logic into a Transform impl when wrap_fn closures pass 30 lines.
- 5
Define one AppError per service with ResponseError, thiserror, and anyhow
Build an AppError enum with #[derive(thiserror::Error)] for typed variants (NotFound, BadRequest, Validation, Unauthorized, Forbidden, Conflict, RateLimited, Database, Internal). Implement ResponseError with status_code() and error_response() methods. Use anyhow::Result inside the service layer; convert to AppError at the handler boundary. Map known infrastructure errors (sqlx::Error::RowNotFound, 23505 SQLSTATE) to domain errors explicitly.
- 6
Inject the database via web::Data<PgPool>, run migrations at startup
Build the PgPool once in main with PgPoolOptions (max_connections, min_connections, acquire_timeout, idle_timeout, max_lifetime). Run sqlx::migrate! at startup. Use sqlx::query_as! for compile-time-checked queries; commit the .sqlx/ directory for offline CI builds. Hold sqlx::Transaction across multi-step handlers with &mut *tx.
- 7
Wire auth: actix-identity for sessions, actix-web-httpauth for JWT
Session cookies for browser-first apps via actix-identity 0.8 on actix-session 0.10 (cookie or Redis backend). Bearer JWT for machine-to-machine via actix-web-httpauth 0.8 plus jsonwebtoken. Custom FromRequest for AuthUser gives handlers a typed authenticated-user parameter. Role-based authorization via scope-level Transform middleware.
- 8
Ship the operational floor: tuned config, graceful shutdown, /healthz + /readyz, Prometheus, JSON tracing
Explicit .workers(), .backlog(), .max_connections(), .keep_alive(75s), .client_request_timeout(30s), .shutdown_timeout(30). Graceful shutdown via tokio::signal + ServerHandle::stop(true). Separate /healthz (liveness) and /readyz (readiness, flips to 503 on drain). Prometheus metrics via actix-web-prom at /metrics. JSON tracing via tracing-actix-web 0.7 plus the tracing-subscriber JSON layer; layer in tracing-opentelemetry for OTLP export. Multi-stage Dockerfile, non-root user, terminationGracePeriodSeconds: 45.