How to build REST API with Actix-web

Initialize a Rust project with actix-web, define a handler, and start the server to serve HTTP requests.

The concierge who never sleeps

You have a backend written in Python or Node. It works. The routes are easy to define. JSON comes back when you ask for it. Then the load spikes. The event loop blocks on a slow database query. Memory usage creeps up as garbage collection struggles. You want the speed of C++ without the segfaults, and you want the type safety that catches bugs before they hit production.

Actix-web is one of the fastest web frameworks in Rust. It sits on top of the Actix actor system, though you rarely touch actors directly when writing standard REST endpoints. You get a high-performance async runtime, a flexible routing system, and compile-time guarantees that your handlers won't crash the server.

How Actix-web routes requests

Think of Actix-web as a high-speed concierge at a hotel. Guests arrive at the front desk with requests. The concierge checks the destination on their card, looks up the directory, and hands the request to the right staff member. The staff member does the work, maybe checks the database, maybe calculates a number, and hands the result back. The concierge packages it up and sends it to the guest.

The magic is concurrency. The concierge can handle thousands of guests at once because every staff member works asynchronously. If a staff member has to wait for the database, they step aside immediately. The concierge grabs the next guest. No one stands around waiting. The runtime manages the threads and the futures, so you can write handlers that look like synchronous code while the system handles the parallelism.

Minimal server

Start with a project that returns a simple string. This example shows the core structure: a handler function, an app builder, and a server runner.

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

/// Returns a simple greeting.
async fn hello() -> impl Responder {
    // HttpResponse::Ok sets the 200 status code.
    // .body attaches the payload to the response.
    HttpResponse::Ok().body("Hello world!")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // HttpServer::new takes a closure that builds the App.
    // This closure runs once per worker thread.
    HttpServer::new(|| {
        // App::new starts the builder pattern.
        // .route maps a path and method to a handler.
        App::new().route("/hello", web::get().to(hello))
    })
    // .bind attaches the server to an address.
    // The ? operator propagates errors like "address in use".
    .bind("127.0.0.1:8080")?
    // .run starts the async event loop.
    // This future resolves only when the server stops.
    .run()
    .await
}

Convention note: Use #[actix_web::main] instead of manually spawning a runtime. The macro sets up the Actix system and runs your async main function. It's the standard pattern across the ecosystem.

Hit the endpoint. The server runs forever until you kill it.

What happens under the hood

When you run the code, main is an async function. The #[actix_web::main] macro wraps your main and boots the Actix runtime. HttpServer::new receives a closure. This closure is critical. It runs once for every worker thread the server spawns. If you call .workers(4), that closure executes four times, creating four independent App instances.

This design means state inside the closure is isolated per worker. You can initialize resources in the closure, and each worker gets its own copy. App::new starts building the application state. .route registers the handler. web::get() restricts the route to GET requests. .to(hello) points to the handler function.

bind attaches the server to the socket. If the address is already in use, bind returns an error, and the ? operator propagates it, causing the program to exit. run starts the event loop. The await at the end keeps the program alive. The future returned by run resolves only when the server shuts down, which happens when you send a termination signal.

The closure runs once per worker. Design your initialization accordingly.

Realistic endpoint with JSON

Real APIs return structured data. Actix-web provides extractors that parse request data automatically. This example shows a handler that extracts a path parameter and returns JSON.

use actix_web::{web, App, HttpResponse, HttpServer, Result};
use serde::{Deserialize, Serialize};

/// Represents a user in our API.
#[derive(Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
}

/// Extracts the user ID from the URL path.
async fn get_user(path: web::Path<u32>) -> Result<HttpResponse> {
    // path is a wrapper around the extracted parameter.
    // .into_inner() consumes the wrapper and returns the value.
    let user_id = path.into_inner();

    // Simulate fetching from a database.
    // In real code, this would be an async DB call.
    let user = User {
        id: user_id,
        name: format!("User {}", user_id),
    };

    // .json() serializes the struct using serde.
    // It also sets Content-Type: application/json.
    Ok(HttpResponse::Ok().json(user))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            // Routes can be chained.
            // web::get().to() is the standard pattern.
            .route("/users/{id}", web::get().to(get_user))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

Convention note: Use extractors like web::Path, web::Json, and web::Form instead of parsing manually. Extractors keep handlers clean and handle validation errors automatically. Also, prefer Result<HttpResponse> over impl Responder when errors can occur. It makes error handling explicit.

Let extractors do the parsing. Your handler should focus on business logic, not string manipulation.

Sharing state across handlers

Handlers often need access to shared resources like database pools or configuration. You can't just put a value in the closure and expect it to work. Rust's ownership rules prevent accidental sharing. Use web::Data to share state safely.

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

struct AppState {
    // In real code, this would be a database pool.
    // Arc makes it thread-safe and reference-counted.
    db_pool: Arc<String>,
}

async fn handler(data: web::Data<AppState>) -> impl Responder {
    // data is a reference-counted pointer to AppState.
    // Accessing fields is cheap and safe.
    let pool = &data.db_pool;
    HttpResponse::Ok().body("Connected")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Create the shared state.
    let state = AppState {
        db_pool: Arc::new("postgres://localhost/db".to_string()),
    };

    HttpServer::new(move || {
        // .app_data shares state with all handlers.
        // web::Data::new wraps the state in an Arc.
        // Clone the state for each worker.
        App::new()
            .app_data(web::Data::new(state.clone()))
            .route("/", web::get().to(handler))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

The move keyword in HttpServer::new(move || ...) captures state by value. Each worker gets its own Arc clone. web::Data::new wraps the state so handlers can extract it. When a handler requests web::Data<AppState>, Actix provides a reference to the shared state. Cloning web::Data is cheap because it just increments a reference count.

Wrap shared state in web::Data. Cloning the data is cheap; cloning the underlying resource is not.

Pitfalls and compiler errors

Actix-web catches mistakes at compile time. Here are common issues and how to fix them.

If you try to share a non-thread-safe type across workers, the compiler rejects you with E0277 (trait bound not satisfied). The error message will complain that the type doesn't implement Send. This is a feature. Rust is stopping you from creating a data race before it happens. Wrap shared state in Arc or use types that implement Send and Sync.

If you try to move a value into the closure that doesn't implement Clone, you get E0382 (use of moved value). The closure captures variables by move. If you need the value outside the closure, clone it first.

Never put CPU-heavy work or blocking I/O directly in a handler. The runtime expects async tasks to yield control. If you block a thread, you starve other requests. Use web::block to run blocking code in a separate thread pool.

use actix_web::web;

async fn handler() -> Result<HttpResponse> {
    // web::block moves the closure to a thread pool.
    // It returns a future that resolves when the block finishes.
    let result = web::block(|| {
        // Blocking code here.
        // This runs on a separate thread.
        heavy_computation()
    })
    .await?;

    Ok(HttpResponse::Ok().body(result))
}

Routes are matched in order. If you register /users/{id} before /users/me, the {id} parameter might capture "me". Register static routes before parameterized ones.

Never block the runtime. If you must block, isolate it behind web::block.

Choosing your framework

Rust has several web frameworks. Pick the one that matches your needs.

Use Actix-web when you need raw throughput and battle-tested stability. It has the highest benchmark scores and a mature ecosystem with extensive middleware.

Use Axum when you prefer an ergonomic API built on Tokio and Hyper. Axum feels more like standard Rust and integrates seamlessly with the Tokio ecosystem.

Use Rocket when you want macro-heavy convenience and rapid prototyping. Rocket's procedural macros reduce boilerplate, though they can make debugging harder.

Use Poem when you need a lightweight framework with a focus on simplicity and flexibility. Poem is smaller and easier to embed in constrained environments.

Benchmark your specific workload. Benchmarks lie if they don't match your traffic pattern.

Where to go next