Updated Jun 23, 2026

Shared State with web::Data

So far our articles API has been stateless — each handler builds its response from the request and nothing else. Real services need shared things: a database connection pool, a cache, a config struct, a counter. The question that trips everyone up isn't "how do I store it" — it's "how do I make sure every request, on every worker thread, sees the same thing." That's the whole story of this phase.

The mental model

In actix-web, a shared dependency lives in web::Data<T>. You register it on the App with .app_data(...), and any handler that wants it lists web::Data<T> as an argument — extraction is by type. The framework looks up the registered value whose type matches and hands it over.

💡 web::Data<T> is an Arc<T> under the hood. An Arc is a thread-safe, reference-counted pointer: cloning it doesn't copy the T, it just bumps a counter and hands back another pointer to the same T. That's the property we're about to lean on hard.

Here's the subtlety to hold in your head from the very start: HttpServer doesn't run one copy of your App — it runs one per worker thread. Which means the closure you pass to HttpServer::new runs once per worker. Where you create your state relative to that closure decides whether your workers share one brain or each get a private one.

⚠️ The per-worker closure trap

This is the single most common actix-web state bug, so let's name it precisely. You saw the warning back in Phase 1: the closure passed to HttpServer::new(...) is the app factory, and actix-web calls it once on each worker thread to build that worker's own App. So this looks fine and is quietly broken:

// ⚠️ BROKEN: state is built INSIDE the closure
HttpServer::new(|| {
    let state = web::Data::new(AppState {
        articles: Mutex::new(HashMap::<u32, Article>::new()),
    });
    App::new()
        .app_data(state)
        .route("/articles", web::get().to(list))
})

What just happened: because the web::Data::new(...) line sits inside the factory closure, every worker runs it and gets a separate, fresh HashMap. Write an article on the thread serving worker A, then read on worker B, and it's gone. With four workers you effectively have four independent databases, and which one you hit depends on which thread the OS scheduled your request onto. It'll even look like it works in testing when there's one worker.

The fix is to build the web::Data once, outside the closure, then move it in and .clone() it into each App. Cloning a web::Data clones the inner Arc — so all workers point at the same state:

use actix_web::{web, App, HttpServer, Responder, HttpResponse};
use std::collections::HashMap;
use std::sync::Mutex;

struct AppState {
    articles: Mutex<HashMap<u32, Article>>,
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // built ONCE, before any worker exists
    let state = web::Data::new(AppState {
        articles: Mutex::new(HashMap::<u32, Article>::new()),
    });

    HttpServer::new(move || {
        App::new()
            .app_data(state.clone())            // share the SAME state across workers
            .route("/articles", web::get().to(list))
            .route("/articles", web::post().to(create))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

What just happened: state is created before HttpServer::new, so there is exactly one Arc<AppState>. The closure is now move, which captures state by value; on each worker it calls state.clone(), and an Arc clone is just another handle to that one AppState. Four workers, one HashMap. The move keyword is load-bearing here — without it the closure can't capture an owned value to clone from.

Reading and writing the state

Now the handlers. To get at the state, list web::Data<AppState> as a handler argument and actix-web extracts it by type:

async fn list(state: web::Data<AppState>) -> impl Responder {
    let map = state.articles.lock().unwrap();
    let articles: Vec<&Article> = map.values().collect();
    HttpResponse::Ok().json(articles)
}

async fn create(
    state: web::Data<AppState>,
    body: web::Json<Article>,
) -> impl Responder {
    let mut map = state.articles.lock().unwrap();
    let article = body.into_inner();
    map.insert(article.id, article);
    HttpResponse::Created().finish()
}

What just happened: list locks the Mutex, borrows the map read-only, and serializes the values to JSON. create locks it mut and inserts. Note the web::Json<Article> extractor pulling the request body — that's extraction-by-type again, sitting right next to the state extractor. Both handlers reach through the same Arc to the same map, which is exactly what the previous section bought us.

⚠️ Why the Mutex? web::Data<T> is an Arc<T>, and an Arc only ever gives you a shared (&T) reference — never &mut T. So T itself has to allow mutation through a shared reference, which is what interior mutability types like Mutex<...> (or RwLock<...> when reads vastly outnumber writes) provide. Forget the Mutex and the compiler will not let you write to the map.

For a real database, you don't need the Mutex

The in-memory HashMap is a teaching prop. In a real service your shared state is usually a connection pool — and a pool like sqlx::Pool is already internally synchronized and cheaply cloneable (it's Arc-backed itself). So you wrap it in web::Data and use it directly, no Mutex in sight:

struct AppState {
    db: sqlx::PgPool,   // already cloneable + thread-safe; no Mutex needed
}

async fn list(state: web::Data<AppState>) -> impl Responder {
    let rows = sqlx::query_as::<_, Article>("SELECT id, title FROM articles")
        .fetch_all(&state.db)
        .await
        .unwrap();
    HttpResponse::Ok().json(rows)
}

What just happened: the pool manages its own concurrency, so handlers borrow &state.db and run queries concurrently without any explicit locking. The pool hands out connections, waits when they're all busy, and returns them after — all thread-safe by design. You still build the pool once, outside the closure and clone the web::Data in, exactly as before; the per-worker trap is identical whether your state is a HashMap or a PgPool.

⚠️ "App data is not configured"

One more sharp edge. If a handler extracts web::Data<T> for a type you never registered with .app_data(...), there's nothing to look up — and actix-web panics at runtime with a message like:

App data is not configured, to configure use App::app_data()

What just happened: extraction-by-type means the framework matches your handler's web::Data<AppState> against the registered data by type. Register a web::Data<AppState> but ask for web::Data<PgPool> (or forget .app_data entirely) and the lookup fails. This isn't a compile error — the types are individually valid — so it surfaces as a 500 and a panic in the logs on the first request that hits that handler. When you see this message, the fix is almost always "register the exact type the handler asks for."

Recap

  • Shared dependencies live in web::Data<T>, registered with .app_data(...) and extracted by type as a handler argument.
  • web::Data<T> is an Arc<T>; cloning it shares one underlying value rather than copying it.
  • ⚠️ The closure passed to HttpServer::new runs once per worker. Build state outside it, then move + .clone() inside, or each worker gets a private copy.
  • Because Data is an Arc (shared &T only), mutable in-memory state needs interior mutability — a Mutex<...> or RwLock<...> field. A real sqlx::Pool is already shareable, so no Mutex needed.
  • ⚠️ Extracting a web::Data<T> you never registered panics at runtime with "App data is not configured" — register the exact type the handler asks for.

Quick check

[
  {
    "q": "Why must web::Data be created outside the HttpServer::new closure?",
    "choices": [
      "The closure won't compile if Data is created inside it",
      "The closure runs once per worker thread, so state built inside gives each worker a separate copy",
      "web::Data can only be created in an async context",
      "It's a style preference with no functional effect"
    ],
    "answer": 1,
    "explain": "HttpServer::new calls its closure once per worker. Building state inside means every worker gets its own copy; build it once outside and clone the Arc in so all workers share one value."
  },
  {
    "q": "Your AppState holds an in-memory HashMap you need to write to. What does the field need?",
    "choices": [
      "Nothing — web::Data already allows mutation",
      "An interior-mutability wrapper like Mutex<...> or RwLock<...>",
      "The #[mut] attribute on the field",
      "A second web::Data registration"
    ],
    "answer": 1,
    "explain": "web::Data<T> is an Arc<T>, which only hands out shared &T references. To mutate through a shared reference you need interior mutability — a Mutex or RwLock field."
  },
  {
    "q": "A handler extracts web::Data<PgPool> but you only registered web::Data<AppState>. What happens?",
    "choices": [
      "A compile error pointing at the mismatch",
      "The handler receives a default-constructed PgPool",
      "A runtime panic: 'App data is not configured'",
      "The request silently returns 404"
    ],
    "answer": 2,
    "explain": "Extraction is by type. With no matching registered type, actix-web panics at runtime ('App data is not configured') on the first request — it's not caught at compile time."
  }
]

← Phase 3: Responders · Guide overview · Phase 5: Middleware →

Check your understanding

1. Why must web::Data be created outside the HttpServer::new closure?

2. Your AppState holds an in-memory HashMap you need to write to. What does the field need?

3. A handler extracts web::Data<PgPool> but you only registered web::Data<AppState>. What happens?

Was this page helpful?