Shared state in Axum: a practical guide for developers
By day 30 of building with Rust, you've probably hit a wall: how do you pass a database connection pool, configuration, or other shared resources through your request handlers? Axum's state system lets you attach application-level data to your router, making those resources available throughout your handlers without resorting to globals. In this guide, we'll walk through the patterns that work in production and the pitfalls to avoid.
Understanding the State extractor
Axum provides a State extractor that pulls application state into any handler. Think of it as dependency injection: you define what your app needs, attach it to the router, and Axum injects it where you ask for it.
Let's start with a concrete example. Say you have a database pool and a configuration object:
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppConfig {
pub database_url: String,
pub max_connections: u32,
}
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<AppConfig>,
}
Now you attach this state to your router and extract it in handlers:
use axum::{
extract::State,
routing::get,
Router,
};
async fn health_check(State(state): State<AppState>) -> String {
format!(
"Database connected. Max connections: {}",
state.config.max_connections
)
}
#[tokio::main]
async fn main() {
let pool = PgPool::connect("postgres://...").await.unwrap();
let config = Arc::new(AppConfig {
database_url: "postgres://...".to_string(),
max_connections: 20,
});
let state = AppState { pool, config };
let app = Router::new()
.route("/health", get(health_check))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
The key insight: State is just a wrapper around whatever you pass to with_state(). Axum clones it for each request, so you need to ensure that cloning is cheap.
Arc for shared data
Here's where Arc comes in. Arc (Atomic Reference Counted) lets multiple threads own the same data without copying. When you clone an Arc, you're only incrementing a counter, not copying the underlying data.
In a web server handling thousands of concurrent requests, you can't afford to clone your entire configuration or expensive objects for each request. Wrap them in Arc:
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<AppConfig>,
pub cache: Arc<RwLock<HashMap<String, String>>>,
}
async fn fetch_user(
State(state): State<AppState>,
Path(user_id): Path<u32>,
) -> Result<Json<User>, AppError> {
// Access the config without cloning it
let max_conn = state.config.max_connections;
// Access the cache
let cache = state.cache.read().await;
if let Some(cached) = cache.get(&format!("user:{}", user_id)) {
return Ok(Json(serde_json::from_str(cached)?));
}
drop(cache);
// Query the database
let user = sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE id = $1"
)
.bind(user_id)
.fetch_one(&state.pool)
.await?;
Ok(Json(user))
}
Notice how state.config and state.cache are accessed directly. With Arc, each request handler gets a cheap clone of the pointer, not the data itself. Threads can safely share the data because RwLock coordinates access.
Clone cheap handles like pools
Database pools are already designed to be cloned cheaply. sqlx::PgPool, redis::Client, and similar utilities return lightweight handles that manage connections internally. Cloning a pool just increments an internal counter; it doesn't spawn new connections.
This is why your AppState can include the pool directly without wrapping it in Arc:
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool, // Cheap to clone
pub config: Arc<AppConfig>, // Wrapped for safety
}
When Axum clones state for each request, cloning the pool is nearly free. The same goes for Redis clients, HTTP clients, and other connection managers:
use redis::aio::Connection;
#[derive(Clone)]
pub struct AppState {
pub db_pool: PgPool,
pub redis: redis::Client,
pub http_client: reqwest::Client,
pub config: Arc<AppConfig>,
}
Each of these can be cloned cheaply. Axum's design assumes you'll follow this pattern: put your expensive, singleton-like objects in Arc, and let pools pass through directly.
Avoid global statics
A common antipattern in Rust web applications is reaching for lazy_static or once_cell to create global state:
use lazy_static::lazy_static;
// Don't do this
lazy_static! {
static ref GLOBAL_POOL: PgPool = create_pool();
static ref GLOBAL_CONFIG: AppConfig = load_config();
}
async fn handler() {
let pool = &GLOBAL_POOL;
// ...
}
Why avoid this pattern? First, it's hard to test. You can't easily swap out a global pool for a test double. Second, it makes dependencies invisible—future developers reading your handler won't know it needs a pool without looking at the globals. Third, it couples your handler to a specific initialization strategy, making the code less reusable.
Axum's state system is the better way. It makes dependencies explicit and testable:
async fn handler(State(state): State<AppState>) -> Result<Json<User>, AppError> {
// Dependency is explicit right in the signature
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(1)
.fetch_one(&state.pool)
.await
.map(Json)
.map_err(AppError::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_handler() {
let test_state = create_test_state().await;
let result = handler(State(test_state)).await;
assert!(result.is_ok());
}
}
For testing, you create a separate state with test doubles. For production, you use real pools. The handler doesn't care—it just uses what it's given.
Common mistakes
Wrapping everything in Arc: New developers often wrap pools in Arc unnecessarily. Pools are already cheap to clone. Unnecessary Arc adds synchronization overhead and makes code harder to read.
Forgetting to derive Clone: Your state struct must derive or implement Clone. If it doesn't, Axum can't pass it to handlers. This will show up as a compile error, but the error message can be cryptic.
// This won't compile
pub struct AppState {
pub pool: PgPool,
}
// Fix it
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
}
Holding locks too long: If you use RwLock or Mutex in your state, don't hold them across .await points unless you have a good reason. You'll block other tasks:
// Bad: lock held across await
let mut cache = state.cache.write().await;
let user = fetch_from_db(&state.pool, id).await; // Blocks other tasks trying to write cache
cache.insert(key, user);
// Good: acquire, release, then do async work
{
let cached = state.cache.read().await.get(&key).cloned();
if let Some(user) = cached {
return Ok(Json(user));
}
}
let user = fetch_from_db(&state.pool, id).await;
state.cache.write().await.insert(key, user.clone());
Mixing state types: If you need multiple independent pieces of state, nest them cleanly rather than flattening everything:
// Clear structure
#[derive(Clone)]
pub struct DatabaseState {
pub pool: PgPool,
}
#[derive(Clone)]
pub struct CacheState {
pub redis: redis::Client,
}
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseState,
pub cache: CacheState,
pub config: Arc<AppConfig>,
}
Key takeaways
- Use Axum's
Stateextractor to inject dependencies into handlers; it's explicit, testable, and follows Rust's ownership model. - Wrap expensive, immutable data (like config) in
Arcso cloning is cheap across requests. - Pass pools and other connection managers directly; they're designed to be cloned efficiently.
- Avoid global statics; they hide dependencies, complicate testing, and reduce code reusability.
- Ensure your state struct derives
Clone; Axum needs to clone it for each request. - When using locks in state, keep critical sections small and never hold locks across
.awaitpoints unless absolutely necessary.






Comments
Loading comments…