Actix Interview Questions and Answers

Last updated:

Check out 30 of the most common Actix interview questions, then take an AI-powered practice interview

RustActor ModelWebSocketPerformanceasync/await
30+
Questions
12
Basic
13
Intermediate
5
Advanced
Q1

What is Actix Web and how does it differ from other Rust web frameworks?

BasicFundamentals

Answer

Actix Web is a Rust web framework currently at version 4.x, built on Tokio (the de-facto async runtime for Rust) and Hyper (the HTTP implementation). It's the longest-running and most battle-tested Rust framework, released in 2017 by Nikolay Kim, it predates Axum, Rocket's async rewrite, and Warp. The 'Actix' name comes from the actor system (actix crate) that historically powered it, but Actix Web 4.x no longer requires you to write actors for normal HTTP handling.

The actor system is a separate library you can still use for stateful background services like WebSocket connection management or scheduled jobs. Compared to Axum (Tokio team's official framework), Actix Web has a richer ecosystem of middleware, slightly higher raw throughput in TechEmpower benchmarks, and a less elegant extractor signature pattern. Compared to Rocket, Actix Web is async-first from the ground up and doesn't rely on procedural macros for routing.

The framework's claim to fame remains its TechEmpower ranking, for plaintext and JSON-serialization benchmarks, Actix has been in the top 3 since 2018, sustaining over 7 million requests per second on commodity hardware. The 4.0 release in March 2022 was a major rewrite that decoupled actix-web from actix-rt's local-set model, allowing it to integrate cleanly with the broader Tokio 1.x ecosystem (sqlx, reqwest, tonic, redis-rs all just work). In 2026, the actively-maintained release line is 4.9, with maintenance backports for 4.4. New crates and learning resources (Zero to Production in Rust by Luca Palmieri is the canonical book) overwhelmingly target Actix 4.x; assume any tutorial older than 2022 needs careful translation.

Key Points

  • Built on Tokio + Hyper, currently at 4.x (4.9 as of 2026)
  • Async-first, no procedural-macro routing requirement
  • Actor system is optional, not required for HTTP handling
  • Consistently top 3 in TechEmpower benchmarks since 2018
Q2

How do you set up a minimal Actix Web HTTP server in 2026?

BasicFundamentals

Answer

You need three things: a Cargo dependency on actix-web 4.x, a `#[tokio::main]` or `#[actix_web::main]` attribute on `main()`, and an `HttpServer::new` call that builds an `App` per worker. The `App` factory closure is called once per worker thread, this is important because it means any non-`Send + Sync` data must be cloned for each worker. Routes are registered with `.route()` or via attribute macros like `#[get("/path")]`.

The handler can be an async function returning anything that implements `Responder`, `String`, `web::Json<T>`, `impl Responder`, or `HttpResponse`. The choice of `#[actix_web::main]` vs `#[tokio::main]` matters: the former sets up a current-thread runtime with a local task set (required for the actor system if you use it), while the latter creates a multi-threaded scheduler shared across the entire process. For pure async-handler workloads with no actors, either works.

The `workers()` call controls how many OS threads Actix spawns to accept connections, defaulting to `num_cpus::get()` is right for most deployments, but oversubscribed Kubernetes pods (e.g. 4 vCPUs requested, 2 actual) often need an explicit value. The bind call accepts a tuple `(addr, port)` or a string `"0.0.0.0:8080"`; both work, but the tuple form is type-checked. Don't forget to handle the `Result` from `bind()`, port-already-in-use is the most common startup failure in CI environments.

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

#[get("/hello/{name}")]
async fn greet(name: web::Path<String>) -> impl Responder {
    format!("Hello {}!", name)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env_logger::init();
    HttpServer::new(|| App::new().service(greet))
        .bind(("127.0.0.1", 8080))?
        .workers(4)
        .run()
        .await
}

Key Points

  • `#[actix_web::main]` wraps Tokio runtime setup
  • `App` factory runs once per worker thread
  • `workers()` defaults to num_cpus if omitted
  • Use explicit worker count on oversubscribed Kubernetes pods
Q3

What are extractors in Actix Web?

BasicExtractors

Answer

Extractors are types that implement the `FromRequest` trait, letting Actix pull typed data out of a request and inject it as a handler argument. The built-in extractors cover most needs: `web::Path<T>` for path parameters, `web::Query<T>` for query strings, `web::Json<T>` for JSON bodies, `web::Form<T>` for form-encoded data, `web::Data<T>` for shared application state, `web::Bytes` and `web::Payload` for raw bodies, and `HttpRequest` for the full request object. If extraction fails (e.g. invalid JSON, missing path param), Actix returns a 400 by default, you can customize this with `JsonConfig::error_handler` for `web::Json`.

You can also build your own extractor by implementing `FromRequest`, which is how libraries like `actix-web-validator` and `actix-identity` plug into the framework. The trait is async, `from_request` returns a future, so an extractor can do I/O (read the body, hit Redis for session lookup) without blocking the worker. Extractor order matters subtly: extractors run before the handler body, in declaration order.

If one extractor reads the body (e.g. `web::Json`), no later extractor can read it again, the payload is consumed. The framework handles `web::Path` and `web::Query` parsing without consuming the body, so they can co-exist with `web::Json` in any handler. A tuple of extractors `(web::Path<u32>, web::Json<Body>, AuthUser)` lets you compose multiple custom extractors per handler, clean and explicit.

use actix_web::{web, get};
use serde::Deserialize;

#[derive(Deserialize)]
struct Filter { q: String, page: Option<u32> }

#[get("/users/{id}")]
async fn user(
    path: web::Path<u32>,
    query: web::Query<Filter>,
) -> String {
    format!("user={} q={} page={:?}", path.into_inner(), query.q, query.page)
}
Q4

How do you handle JSON request bodies in Actix?

BasicRequests

Answer

Declare a struct with `#[derive(Deserialize)]` (from serde) and accept `web::Json<YourStruct>` as a handler argument. Actix Web reads the body, runs serde_json deserialization, and either gives you the typed struct or returns 400 Bad Request automatically. The default body-size limit is 32 KB, for larger payloads, override with `JsonConfig`.

For response bodies, return `web::Json(value)` or `HttpResponse::Ok().json(&value)`. The latter is preferred when you also need to set headers or a non-200 status. The serialization layer is serde_json, which is fast but not the fastest, for ultra-high-throughput APIs, swap to `simd-json` or `sonic-rs`.

Validation beyond shape (e.g. 'age must be 18+', 'email must match regex') doesn't come from serde, use the `validator` crate with `#[derive(Validate)]` annotations and call `.validate()` inside the handler, or use `actix-web-validator` to wrap `web::Json` and validate automatically. Customize the 400 response with `JsonConfig::default().error_handler(|err, req| { ... })` so your API returns a consistent error envelope. A subtle gotcha: `web::Json<T>` consumes the entire body before invoking the handler, for endpoints that should accept large bodies but reject most based on a header check, do the header check in middleware first to avoid wasting bandwidth.

For optional or strict field handling, serde provides `#[serde(default)]` for missing fields and `#[serde(deny_unknown_fields)]` to reject payloads with extra keys (useful for catching client-side typos that would otherwise silently no-op). For dates and currency, use the `chrono` and `rust_decimal` types respectively, both serialize cleanly with serde and avoid the floating-point precision issues that plague JavaScript-based stacks.

use actix_web::{post, web, HttpResponse};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser { email: String, age: u32 }

#[derive(Serialize)]
struct UserResponse { id: u64, email: String }

#[post("/users")]
async fn create(body: web::Json<CreateUser>) -> HttpResponse {
    HttpResponse::Created().json(UserResponse {
        id: 42,
        email: body.email.clone(),
    })
}
💡 Pro Tip: Override the default 32 KB JSON limit with `App::app_data(JsonConfig::default().limit(2 * 1024 * 1024))` for 2 MB payloads.
Q5

What is `web::Data<T>` and how does it differ from `web::Path<T>` or `web::Query<T>`?

BasicExtractors

Answer

`web::Data<T>` is the extractor for application-wide shared state, database pools, configuration, HTTP clients. You register it once with `App::app_data()`, and Actix gives every handler that asks for it a clone of the underlying `Arc<T>`. The wrapped type must be `Send + Sync + 'static`.

Path and Query extractors, by contrast, are per-request, they pull data out of the URL, not out of shared state. A common newcomer mistake: registering `App::app_data(my_pool)` (which moves the value) and expecting it to work, `web::Data<T>` requires `web::Data::new(my_pool)` so Actix knows to share an `Arc` across workers. Without that wrapper, each worker gets its own copy, which is wasteful for connection pools and breaks any in-memory state that should be global.

Also note: `web::Data<T>` and the `Data` registered via `app_data()` are different types, extractors only see the former. Another nuance: `app_data` can register multiple values keyed by type, so you can register a `PgPool`, a `redis::Client`, and a `Config` separately and extract each in different handlers. If you accidentally register two values of the same type, the second silently replaces the first, wrap them in newtypes (`pub struct CacheRedis(redis::Client)`) to distinguish multiple instances of the same underlying type. The Arc inside `web::Data` means clones are cheap; passing `web::Data<T>` between async tasks is just an atomic increment.

use actix_web::{web, App, HttpServer, get};
use sqlx::PgPool;

struct AppState { pool: PgPool, config: AppConfig }

#[get("/health")]
async fn health(state: web::Data<AppState>) -> String {
    format!("db_max={}", state.pool.options().get_max_connections())
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let pool = PgPool::connect("postgres://...").await.unwrap();
    let state = web::Data::new(AppState { pool, config: load_config() });
    HttpServer::new(move || {
        App::new().app_data(state.clone()).service(health)
    }).bind(("0.0.0.0", 8080))?.run().await
}

Key Points

  • Always wrap shared state with `web::Data::new()`
  • Inner type must be Send + Sync + 'static
  • Clone outside the factory closure so all workers share the Arc
Q6

How do you define routes in Actix Web?

BasicRouting

Answer

Actix supports two equivalent styles: builder-based with `.route()` / `.service()`, or attribute macros like `#[get("/path")]`, `#[post("/path")]`, etc. The attribute style is shorter but harder to test (the function and its routing are coupled). The builder style separates the handler from the URL, which is friendlier for large apps where routes live in a dedicated module. Both styles compose via `App::service()` and `web::scope()` for prefixed groups.

A common 2026 pattern is to define handlers as free async functions and wire them up explicitly in a `configure()` closure passed to `App::configure()`, this makes the routing table readable in one place and easier to share between the production binary and integration tests. Path patterns support typed parameters: `/users/{id}` matches any segment, `/files/{filename:.*}` is a greedy tail match (the `:.*` regex captures slashes), and `/posts/{year:\d{4}}/{slug}` constrains a segment with a regex. Guards add extra matching conditions beyond the URL, `web::route().guard(guard::Host("api.example.com"))` matches only requests to that virtual host, useful for serving multiple domains from one binary. Internally, Actix uses a tree-based router (not a sorted vector or linear scan), so registration order generally doesn't matter for correctness, but more specific routes should still be registered first as a defensive habit.

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

async fn list_users() -> &'static str { "users" }
async fn get_user(path: web::Path<u32>) -> String { format!("user {}", path) }

fn user_routes(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api/v1")
            .route("/users", web::get().to(list_users))
            .route("/users/{id}", web::get().to(get_user)),
    );
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().configure(user_routes))
        .bind(("0.0.0.0", 8080))?.run().await
}
Q7

How do you return different HTTP status codes from a handler?

BasicResponses

Answer

Build an `HttpResponse` with the explicit status: `HttpResponse::Ok()`, `HttpResponse::Created()`, `HttpResponse::NotFound()`, `HttpResponse::BadRequest()`, or a generic `HttpResponse::build(StatusCode::IM_A_TEAPOT)`. Each returns a builder you can chain `.json()`, `.body()`, `.insert_header()`, or `.finish()` on. For the common 'return data or 404' pattern, returning `Option<web::Json<T>>` is concise: Actix maps `Some` to 200 and `None` to 404 automatically.

Returning a `Result<T, E>` works similarly if `E` implements `ResponseError`. Avoid the temptation to wrap everything in `HttpResponse::InternalServerError().body(format!("{:?}", err))`, that leaks internal errors to the client. Use a typed error enum with a `ResponseError` impl that maps each variant to the appropriate status.

For partial successes (e.g. batch endpoint where 3 of 5 items succeeded), use 207 Multi-Status with a body that details per-item outcomes, clients expecting strict 2xx/4xx semantics may not handle this correctly, so document it explicitly. The `.append_header()` vs `.insert_header()` distinction matters: append allows duplicates (Set-Cookie, Link), insert overwrites. For HTTPS redirects on http handlers, use 301 (permanent) only after you're confident, 302 (temporary) is safer for rolling deployments where you might want to revert.

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

#[get("/users/{id}")]
async fn get_user(id: web::Path<u32>) -> HttpResponse {
    match fetch_user(id.into_inner()).await {
        Some(user) => HttpResponse::Ok().json(user),
        None => HttpResponse::NotFound().json(serde_json::json!({"error": "user not found"})),
    }
}
Q8

What is `impl Responder` and when should you use it?

BasicResponses

Answer

`Responder` is the trait Actix uses to convert any return type into an HTTP response. The framework implements it for `String`, `&'static str`, `Vec<u8>`, `web::Json<T>`, `HttpResponse`, `Option<T>`, `Result<T, E>`, and tuple types like `(StatusCode, T)`. Returning `impl Responder` lets you keep the concrete return type unnamed, which is convenient when you want to mix several response shapes.

The trade-off: `impl Responder` hides the type, so when a handler returns different types in different branches, you must explicitly box or convert them to a common type, usually `HttpResponse`. Returning `HttpResponse` directly is the most flexible for non-trivial handlers. Reserve `impl Responder` for simple handlers where there's a single happy-path return.

You can also implement `Responder` for your own types: a `User` struct that knows how to serialize itself into an HTTP response with the right `Content-Type` and a Vary header. This is useful for content-negotiated responses (JSON vs CBOR vs Protobuf based on Accept header), the implementation inspects `req.headers()` and branches. Most teams don't go this deep; they accept JSON-everywhere and call it done.

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

#[get("/greet/{name}")]
async fn greet(name: web::Path<String>) -> impl Responder {
    format!("Hello {}!", name)  // String implements Responder => 200 text/plain
}
Q9

How do you read HTTP headers in an Actix Web handler?

BasicRequests

Answer

Accept `HttpRequest` as an extractor and call `.headers().get("x-api-key")`, which returns `Option<&HeaderValue>`. For typed headers (Authorization Bearer, Content-Type, etc.), use the `actix-web-httpauth` extractor crate or build your own `FromRequest` implementation. Header names are case-insensitive, use the lowercase form when querying.

Reading non-ASCII header values requires `.to_str()`, which returns a Result. For request-scoped values that you want to carry across middleware (request ID, authenticated user), use `req.extensions()`, a typed map that middleware writes to and handlers read from. The pattern: middleware inserts a value with `req.extensions_mut().insert(my_value)`, the handler reads with `req.extensions().get::<MyType>()`.

Note the type-based keying, there can only be one value of each type in the extensions map at a time. If you need multiple instances of the same type (say, multiple feature flags), wrap them in newtype structs. Forwarded headers (`X-Forwarded-For`, `X-Forwarded-Proto`) deserve extra care: only trust them if you know your load balancer sets them and strips any client-supplied versions.

Otherwise an attacker spoofs `X-Forwarded-For` and bypasses your IP-based rate limit. For getting the real client IP, parse the rightmost-untrusted IP from `X-Forwarded-For` (some setups have multiple hops) and validate that the connecting peer IP matches your known load balancer subnet. AWS ALB and Cloudflare both document the exact semantics, read the docs once and write a helper extractor `ClientIp(IpAddr)` that handles it uniformly across the codebase.

use actix_web::{HttpRequest, get};

#[get("/admin")]
async fn admin(req: HttpRequest) -> String {
    let token = req
        .headers()
        .get("x-api-key")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("missing");
    format!("token={}", token)
}
Q10

How does Actix Web's worker model relate to Tokio?

BasicConcurrency

Answer

Actix Web spins up a configurable number of OS threads (default: one per logical CPU) and runs a single-threaded Tokio runtime on each. Inside one worker, async tasks are multiplexed cooperatively, thousands of in-flight requests, one OS thread. Across workers, requests are distributed by the OS via SO_REUSEPORT.

This model has one big implication: the `App` factory closure runs once per worker, and any state inside it is NOT shared across workers unless you put it behind `Arc`/`web::Data`. The default is single-threaded-per-worker (not the multi-threaded Tokio scheduler) because it avoids cross-thread locking inside a worker and yields lower latency at the cost of slightly more thread overhead. You can override with `actix_rt` configuration, but very few production deployments do.

The contrast with frameworks like Axum is instructive: Axum runs on a single multi-threaded Tokio runtime by default, so any task can run on any thread, requiring `Send` bounds everywhere. Actix's per-worker single-threaded model means handlers don't need to be `Send` between yield points within a worker, but you don't get that benefit because handlers must still be `Send` (a future may move across workers in some scenarios). The practical implication: spawn long-running tasks with `actix_web::rt::spawn` (binds to current worker) for shared in-worker state, or `tokio::task::spawn` if you want it to potentially migrate threads.

Key Points

  • One Tokio current_thread runtime per worker (not multi-threaded scheduler)
  • Workers share a listening socket via SO_REUSEPORT
  • App factory runs once per worker, state inside is per-worker
  • Use `actix_web::rt::spawn` for worker-local tasks
Q11

What is the difference between `web::scope` and `web::resource`?

BasicRouting

Answer

`web::scope("/prefix")` groups multiple routes under a URL prefix and is the Actix equivalent of a router/blueprint. You attach middleware, guards, and child services to it. `web::resource("/path")` represents a single URL pattern that may have multiple methods (GET, POST, PUT on the same `/users/{id}`). You attach method handlers to a resource via `.route(web::get().to(handler))`.

Most apps use both: top-level `scope` for `/api/v1`, child `resource` (or attribute-macro `#[get("/path")]`) for each endpoint. Guards on a scope short-circuit before the resource matches, useful for header-based versioning or feature flags. Order matters: more specific scopes should be registered before more general ones, because Actix uses linear traversal.

Nested scopes work naturally, `web::scope("/api").service(web::scope("/v1").service(...))` gives you `/api/v1/...` with two layers of middleware. Each scope inherits its parent's middleware unless you opt out. Versioning strategies in 2026: URL versioning (`/v1`, `/v2`) is the most common but ties releases together; header versioning (`Accept: application/vnd.api+json; version=2`) is cleaner but requires a custom guard. For Indian fintech APIs that integrate with banks, URL versioning wins because bank IT teams find it easier to debug.

use actix_web::{web, App};

fn build_app() -> App<impl actix_web::dev::ServiceFactory<actix_web::dev::ServiceRequest>> {
    App::new().service(
        web::scope("/api/v1")
            .service(
                web::resource("/users/{id}")
                    .route(web::get().to(get_user))
                    .route(web::put().to(update_user)),
            ),
    )
}
Q12

How do you serve static files in Actix Web?

BasicStatic

Answer

Add the `actix-files` crate and call `App::service(Files::new("/static", "./assets"))`. This mounts the on-disk `./assets` directory under the URL prefix `/static`. You can layer in `.show_files_listing()`, `.index_file("index.html")` for SPA fallback, `.use_etag(true)` for cache-friendly responses, and `.default_handler(...)` to customize 404 behavior.

For a SPA where `/` and `/users/42` should both load `index.html` but `/static/app.js` should load the asset, register the Files service with `.index_file("index.html")` and a `.default_handler` that rewrites unknown paths to `index.html`. In production, most teams front the Actix server with nginx or Cloudflare for static asset delivery, the `actix-files` service is mainly for development and admin uploads. Security: never enable `.show_files_listing()` in production; it exposes your directory structure.

Path traversal (`../../etc/passwd`) is blocked by default, actix-files canonicalizes paths and rejects anything outside the configured root, but verify with a test if you write any custom file handlers. For pre-compressed assets (gzip, brotli), serve them directly with `.path_filter(|path, _| path.extension().map(|e| e == "br").unwrap_or(false))` and the `Content-Encoding` header set, avoiding runtime compression cost.

use actix_files::Files;
use actix_web::{App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new().service(
            Files::new("/static", "./assets")
                .index_file("index.html")
                .use_etag(true),
        )
    }).bind(("0.0.0.0", 8080))?.run().await
}
Q13

How do you build a custom middleware in Actix Web 4.x?

IntermediateMiddleware

Answer

Middleware in Actix 4.x is implemented as a pair of traits: `Transform` (the factory that wraps the inner service) and `Service` (the per-request logic). Writing one from scratch is verbose, the canonical 2026 pattern is to use the `actix_web::middleware::from_fn` adapter (added in 4.7) which lets you write middleware as a single async function. For common needs, prefer the built-in middlewares: `Logger`, `Compress`, `NormalizePath`, `DefaultHeaders`, `ErrorHandlers`.

Custom middleware order matters, they execute in registration order on the request and in reverse order on the response. A common gotcha: middleware runs before extractors, so you cannot rely on `web::Json` deserialization inside middleware. Read the raw body with `req.extract::<web::Bytes>()` if you need it (e.g. HMAC signature verification).

Middleware composition: wrapping at scope level versus app level changes which routes the middleware applies to. Authentication middleware typically lives at scope level (`/api/protected`) so public routes (login, signup, health checks) bypass it. Logging and tracing live at app level so every request is observed.

If you have both, the app-level logging runs OUTSIDE the scope-level auth, useful because you want to log failed auth attempts too. A subtle correctness point: middleware bodies are wrapped in `BoxBody` if you change the body type, which adds a small allocation per request. For zero-allocation middleware (e.g. just inspecting headers and inserting extensions), use the `from_fn` helper which preserves the body type via generics.

use actix_web::{dev::Service, middleware::from_fn, App, HttpServer};
use actix_web::body::MessageBody;

async fn request_id(
    req: actix_web::dev::ServiceRequest,
    next: actix_web::middleware::Next<impl MessageBody>,
) -> Result<actix_web::dev::ServiceResponse<impl MessageBody>, actix_web::Error> {
    let id = uuid::Uuid::new_v4().to_string();
    req.extensions_mut().insert(id.clone());
    let mut res = next.call(req).await?;
    res.headers_mut().insert(
        actix_web::http::header::HeaderName::from_static("x-request-id"),
        actix_web::http::header::HeaderValue::from_str(&id).unwrap(),
    );
    Ok(res)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().wrap(from_fn(request_id)))
        .bind(("0.0.0.0", 8080))?.run().await
}

Key Points

  • Use `from_fn` (4.7+) for simple middleware
  • Implement Transform + Service for stateful middleware
  • Middleware runs before extractors, use `req.extract()` for body
Q14

How do you handle errors idiomatically in Actix Web?

IntermediateError Handling

Answer

Define a domain error enum with `thiserror::Error` and implement `actix_web::ResponseError` for it. The trait has two methods: `status_code()` returns the HTTP status, `error_response()` builds the response body. Once implemented, your handler can return `Result<T, AppError>` and Actix turns errors into proper HTTP responses automatically.

The pattern composes with `?` cleanly, `let user = db.find_user(id).await?;` works because your `From<sqlx::Error> for AppError` impl converts the DB error. Avoid the temptation to use `anyhow::Error` as your handler return type, it works but loses the per-variant status code mapping. Use `anyhow` internally (for context chaining) but wrap into your typed error before returning.

For production, log the internal error in `error_response()` and return a sanitized body, don't leak `Display` of internal errors to clients. A common 2026 pattern: a unified error envelope `{ "error": { "code": "USER_NOT_FOUND", "message": "...", "trace_id": "..." } }` where the `code` is machine-readable (clients switch on it) and `trace_id` correlates to your observability backend. Include the trace_id even on 500s, when a user reports an issue, they can give you the ID and you can find the full request trace. For panics in handlers, Actix's default behavior is to return 500 and continue serving other requests; you can wrap critical handlers in `std::panic::AssertUnwindSafe` if you need to instrument panic recovery yourself.

use actix_web::{HttpResponse, ResponseError, http::StatusCode};
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("user {0} not found")]
    NotFound(u32),
    #[error("database error")]
    Db(#[from] sqlx::Error),
    #[error("unauthorized")]
    Unauthorized,
}

impl ResponseError for AppError {
    fn status_code(&self) -> StatusCode {
        match self {
            AppError::NotFound(_) => StatusCode::NOT_FOUND,
            AppError::Unauthorized => StatusCode::UNAUTHORIZED,
            AppError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
    fn error_response(&self) -> HttpResponse {
        if let AppError::Db(e) = self {
            tracing::error!(error = ?e, "database error");
        }
        HttpResponse::build(self.status_code())
            .json(serde_json::json!({ "error": self.to_string() }))
    }
}
Q15

How do you connect Actix Web to PostgreSQL with sqlx?

IntermediateDatabase

Answer

Add `sqlx` with the `postgres`, `runtime-tokio-rustls`, and `macros` features. Build a `PgPool` once at startup, wrap it in `web::Data::new()`, and clone the Data wrapper into the App factory. The pool itself is internally `Arc` so cloning is cheap.

Pool size should match your worker count times in-flight DB calls per request, typically 5-20 connections. For deployments behind PgBouncer in transaction mode, disable prepared statements with `PgPoolOptions::statement_cache_capacity(0)` to avoid statement-name collisions. Use `sqlx::query!` (compile-time checked) when the schema is fixed, `sqlx::query_as` for dynamic shapes.

Common 2026 alternatives: `sea-orm` for full ORM, `diesel-async` for compile-time DSL. ShareChat and Razorpay are known to use sqlx with Actix because the compile-time SQL checking catches mistakes before deployment. Migrations: use `sqlx-cli` or `refinery` to manage schema versions.

For zero-downtime deploys, follow the expand-contract pattern, add columns and indexes online with `CREATE INDEX CONCURRENTLY`, deploy the new code, then drop old columns in a subsequent release. Transactions: `pool.begin().await?` returns a `Transaction` that auto-rolls-back on drop unless you call `.commit().await?`, much safer than the Java/Python pattern where you must explicitly call rollback in a `finally`. For read replicas, hold two pools (`PgPool` for primary, `PgPool` for replica) in your `AppState` and route reads/writes explicitly; sqlx doesn't auto-route.

use actix_web::{web, App, HttpServer, get};
use sqlx::postgres::PgPoolOptions;

#[derive(serde::Serialize, sqlx::FromRow)]
struct User { id: i32, email: String }

#[get("/users/{id}")]
async fn get_user(
    pool: web::Data<sqlx::PgPool>,
    id: web::Path<i32>,
) -> Result<web::Json<User>, AppError> {
    let user = sqlx::query_as::<_, User>("SELECT id, email FROM users WHERE id = $1")
        .bind(id.into_inner())
        .fetch_one(pool.get_ref())
        .await?;
    Ok(web::Json(user))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let pool = PgPoolOptions::new()
        .max_connections(20)
        .connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
    let pool = web::Data::new(pool);
    HttpServer::new(move || {
        App::new().app_data(pool.clone()).service(get_user)
    }).bind(("0.0.0.0", 8080))?.run().await
}
Q16

How do you implement JWT authentication in Actix Web?

IntermediateAuthentication

Answer

Use `jsonwebtoken` for signing/verification and either a custom extractor or `actix-web-httpauth` for bearer-token parsing. The recommended pattern: implement `FromRequest` for an `AuthUser` type that pulls the Authorization header, validates the JWT against your secret, decodes the claims, and returns the user. Handlers that need auth declare `user: AuthUser` as an argument, handlers that don't, simply omit it.

Store the secret in an environment variable injected at runtime (never check it in). Access tokens should be short-lived (15 minutes) with refresh tokens in HttpOnly cookies. Use RS256 over HS256 if you have multiple services verifying the same token, you can distribute the public key without sharing the signing secret.

For India fintech contexts (Razorpay, Cred), the JWT typically embeds a `kid` (key ID) so the server can rotate keys without breaking in-flight tokens. Token revocation is the hard part: pure JWTs are stateless and can't be revoked before expiry. Three patterns: (1) short access token + long refresh token where the refresh token IS stored server-side and can be revoked; (2) a Redis-backed deny list of revoked JTI (token IDs) checked on every request, adds a Redis round trip but immediate revocation; (3) bind the token to a session ID and validate the session is still active.

Most production setups use (1) with refresh tokens that expire after 7-30 days. For sensitive endpoints (payment, password change), even valid JWTs should re-prompt for the password (step-up authentication), set `auth_time` in the JWT and require it to be recent for these flows.

use actix_web::{FromRequest, HttpRequest, dev::Payload, Error};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::Deserialize;
use std::future::{ready, Ready};

#[derive(Deserialize, Clone)]
pub struct Claims { pub sub: String, pub exp: usize }

pub struct AuthUser(pub Claims);

impl FromRequest for AuthUser {
    type Error = Error;
    type Future = Ready<Result<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 "));
        let secret = std::env::var("JWT_SECRET").unwrap();
        let result = token
            .ok_or_else(|| actix_web::error::ErrorUnauthorized("missing token"))
            .and_then(|t| decode::<Claims>(
                t, &DecodingKey::from_secret(secret.as_bytes()), &Validation::default(),
            ).map_err(|_| actix_web::error::ErrorUnauthorized("invalid token")))
            .map(|t| AuthUser(t.claims));
        ready(result)
    }
}
💡 Pro Tip: Cache the DecodingKey in `web::Data` instead of re-parsing from env on every request, `DecodingKey::from_secret` allocates.
Q17

How do you write integration tests for Actix Web handlers?

IntermediateTesting

Answer

Use `actix_web::test`, it provides `init_service` to build a `Service` from your `App`, and `TestRequest` to forge incoming requests. The whole stack runs in-process without binding a real socket, so tests are fast (sub-millisecond). For tests that need a real DB, spin up a Postgres container with `testcontainers` and run migrations in a setup helper.

Override `web::Data` registrations to inject fakes for external services (Stripe, email, S3). Common pattern in 2026: a `tests/common/mod.rs` that exposes `spawn_app() -> TestApp` which configures everything and returns a struct with `client: reqwest::Client` and `address: String`. Each test gets a fresh app, and the Drop impl tears down the container.

For pure unit tests on handlers, you can call the handler function directly with constructed extractors, no Actix runtime needed. The trade-off between `init_service` (in-process) and `spawn_app` (bound to a real port): the former is faster and doesn't need a free port, but loses fidelity for tests that exercise the HTTP/1.1 transport (chunked encoding, connection reuse). For most assertion-heavy tests, `init_service` wins; for end-to-end smoke tests across services, the bound-port approach matches production behavior.

Parallel test execution: by default `cargo test` runs tests in parallel threads, which can collide on a shared test database. Use a per-test schema (random suffix) or a transaction-rollback pattern, sqlx tests support `#[sqlx::test]` which wraps each test in its own DB transaction that rolls back at the end.

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::{test, App, web};

    #[actix_web::test]
    async fn get_user_returns_200() {
        let app = test::init_service(
            App::new().service(get_user)
        ).await;
        let req = test::TestRequest::get().uri("/users/42").to_request();
        let resp = test::call_service(&app, req).await;
        assert_eq!(resp.status(), 200);
        let body: User = test::read_body_json(resp).await;
        assert_eq!(body.id, 42);
    }
}
Q18

What are the trade-offs between Actix Web's actor system and plain async handlers?

IntermediateActor Model

Answer

The actix actor crate is a separate, optional dependency that gives you Erlang-style supervised actors with `Addr<A>` handles, mailboxes, and message-passing. In Actix Web 4.x, you do NOT need actors for normal HTTP handling, async functions are simpler and just as fast. Actors shine when you have stateful in-memory components that need single-owner mutation: WebSocket connection registries, scheduled jobs, rate-limit counters with their own state machine, in-process pub/sub.

The advantage over `Mutex<State>`: actors give you sequential processing automatically (no lock contention), and the supervisor restarts an actor that panics. The disadvantage: more boilerplate, and you lose direct access, you can only interact via `Addr::send`. A common 2026 pattern is a `Database` actor wrapping a connection pool, but this is wrong; the pool is already internally Arc-based and async-safe, so an actor adds latency for no benefit.

Use actors for state, not for routing async I/O. The decision tree: does your component need to (a) sequentialize access to mutable state AND (b) handle failures with restart-from-clean-state? If yes to both, use an actor.

If you only need (a), `Arc<Mutex<T>>` works fine for low-contention or `tokio::sync::mpsc` with a single consumer task for higher throughput. If you only need (b), use `tokio::task::spawn` with a supervisor task that respawns on panic, simpler than learning the actor API. The actor model's reputation comes from Erlang/Elixir, where it's the only concurrency model; in Rust you have more tools and don't need to reach for actors as a default.

Key Points

  • Actors are optional in Actix Web 4.x
  • Useful for in-memory stateful components (WS registry, scheduler)
  • Avoid wrapping I/O (DB pools, HTTP clients) in actors, adds latency
  • Decision rule: need sequential mutation + supervised restart? Use actor.
Q19

How do you implement WebSocket handling in Actix Web?

IntermediateWebSockets

Answer

Actix has two WebSocket libraries: `actix-web-actors` (legacy, actor-based) and `actix-ws` (newer, async/await native). The 2026 default is `actix-ws`, it uses a stream/sink model that's easier to reason about and doesn't require learning the actor API. The handler accepts the request and payload, calls `actix_ws::handle()` to do the upgrade, and gets back a `Session` (send half) and `MessageStream` (receive half).

Spawn a Tokio task that loops over the stream and routes messages. For broadcasting (chat, live cursors), share an `Arc<RwLock<HashMap<UserId, mpsc::Sender<Msg>>>>` so other handlers can push messages to active sessions. For production scale, plug each worker into Redis Pub/Sub so connected clients on any worker receive broadcasts.

Beware of slow clients: if a client stops reading, the send buffer fills and your task blocks, use bounded channels with a configurable drop-on-full policy. Always send ping frames every 30 seconds to detect dead connections, TCP keepalive alone is too slow, and intermediaries (mobile carrier NATs in India are particularly aggressive) drop idle connections after 60-120 seconds. Track `last_seen` per session and close any session that misses two pongs.

Authentication on the upgrade request: use a short-lived single-use token in the URL or a cookie, you can't set custom headers on browser WebSocket connections, so the typical pattern is to fetch a token via REST first, then connect with `wss://api/ws?token=...`. The token should be one-time-use to prevent replay. For massive fan-out (10k+ concurrent connections per pod), consider sharding by user_id hash and pinning a connection to a worker, avoids cross-worker locking inside the broadcast loop. ShareChat's real-time comment system uses this exact pattern, with Redis Pub/Sub for cross-pod fan-out and per-worker connection maps for local delivery.

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

#[get("/ws")]
async fn ws(req: HttpRequest, body: web::Payload) -> Result<HttpResponse, actix_web::Error> {
    let (response, mut session, mut stream) = actix_ws::handle(&req, body)?;
    actix_web::rt::spawn(async move {
        while let Some(Ok(msg)) = stream.next().await {
            match msg {
                Message::Text(text) => {
                    let _ = session.text(format!("echo: {}", text)).await;
                }
                Message::Close(reason) => {
                    let _ = session.close(reason).await;
                    break;
                }
                _ => {}
            }
        }
    });
    Ok(response)
}
Q20

How do you stream large response bodies in Actix Web?

IntermediateStreaming

Answer

Return `HttpResponse::Ok().streaming(stream)` where `stream` is anything implementing `Stream<Item = Result<Bytes, Error>>`. The `bytes` crate's `Bytes` type is the canonical chunk type. Use cases: CSV exports of millions of rows (yield row-by-row from a `sqlx::Stream`), proxying remote downloads, Server-Sent Events for LLM token streaming.

Set the right `Content-Type`, `text/csv`, `text/event-stream` for SSE. For SSE specifically, format chunks as `data: <json>\n\n` and add the header `X-Accel-Buffering: no` to prevent nginx from buffering. The async-stream crate provides `stream!` and `try_stream!` macros that let you write streaming generators with familiar `yield` syntax, sidestepping the manual `Stream` impl.

Common mistake: producing the entire response in memory before streaming, defeats the point. Yield as soon as you have a chunk, even if it's a few KB. Backpressure works automatically: if the client reads slowly, the underlying TCP socket's write buffer fills, your stream's `poll_next` returns `Pending`, and your generator's `.await` on the next chunk simply waits.

This is one of Rust's nicest streaming wins over languages without poll-based futures. For very long streams (LLM generation, live tail of logs), implement a heartbeat, yield a comment `: ping\n\n` every 15 seconds so proxies don't time out the connection. Don't forget the `Cache-Control: no-cache, no-transform` header on SSE responses, some CDNs will buffer otherwise.

use actix_web::{get, web, HttpResponse};
use async_stream::try_stream;
use bytes::Bytes;
use futures_util::stream::Stream;

#[get("/users.csv")]
async fn export(pool: web::Data<sqlx::PgPool>) -> HttpResponse {
    let stream = try_stream! {
        yield Bytes::from("id,email,created_at\n");
        let mut rows = sqlx::query!("SELECT id, email, created_at FROM users")
            .fetch(pool.get_ref());
        while let Some(row) = futures_util::StreamExt::next(&mut rows).await {
            let row = row.map_err(actix_web::error::ErrorInternalServerError)?;
            yield Bytes::from(format!("{},{},{}\n", row.id, row.email, row.created_at));
        }
    };
    HttpResponse::Ok()
        .content_type("text/csv")
        .streaming::<_, actix_web::Error>(stream)
}
Q21

How does Actix Web enforce Send + Sync on shared state, and what goes wrong if you ignore it?

IntermediateConcurrency

Answer

`web::Data<T>` requires `T: 'static`, and because workers run on different OS threads, anything you put into `App::app_data()` must also be `Send + Sync`. The compiler enforces this, if you try to put a `Rc<T>` (not Send) or `RefCell<T>` (not Sync) into `web::Data`, the build fails. The fix is to use `Arc<T>` and `Mutex<T>` / `RwLock<T>` (from std or tokio).

Use `tokio::sync::Mutex` over `std::sync::Mutex` if the critical section awaits anything inside the lock, std mutexes block the whole worker thread, killing concurrency. If you want lock-free shared state for high-throughput cases, look at `arc-swap` for atomic swaps of immutable snapshots, or `dashmap` for sharded concurrent hashmaps. The most common production bug: holding a `std::sync::Mutex` across `.await`, it compiles, but creates a deadlock-prone bottleneck where one slow request blocks the entire worker.

Clippy lints against this; turn on `clippy::await_holding_lock`. A subtler bug: `RwLock` reader-starvation under heavy write contention, if writes are frequent, readers queue up and tail latency spikes. The fix depends on your access pattern: for read-mostly state, `arc-swap` lets readers grab an immutable snapshot in nanoseconds with zero contention; writers swap a new `Arc<T>` atomically.

For per-key state, `dashmap` shards into 64 internal locks so different keys don't contend. For state that must be sequentially consistent across the whole process (e.g. a unique-ID generator), an actor with a single owner is cleaner than threading a global lock through the codebase.

Key Points

  • Wrap shared state in `Arc<Mutex<T>>` or `Arc<RwLock<T>>`
  • Use `tokio::sync::Mutex` if you await inside the lock
  • `arc-swap` for read-mostly immutable snapshots
  • `dashmap` for sharded concurrent hashmaps
💡 Pro Tip: Enable `clippy::await_holding_lock` in `.cargo/config.toml` or your CI to catch the most common shared-state bug.
Q22

How do you implement rate limiting in Actix Web?

IntermediateRate Limiting

Answer

Use the `actix-governor` crate (governor under the hood) for in-memory rate limiting per IP or per identifier. It plugs in as a middleware: `App::wrap(Governor::new(&config))`. The config supports per-second/minute limits, burst sizes, and custom key extractors (e.g. user ID from a JWT instead of IP).

For distributed rate limiting across multiple Actix instances, you need a shared store, Redis is the standard choice via `actix-extensible-rate-limit` or a hand-rolled middleware using the token-bucket pattern on Redis SortedSets. The common Indian fintech setup: 10 req/sec per authenticated user for write endpoints, 100 req/sec per IP for public reads, 5 req/min on the OTP/login endpoint with an exponential lockout. Don't forget to whitelist your own load balancer health checks and internal service IPs, or you'll rate-limit yourself out of monitoring.

The choice of algorithm matters: token bucket (governor's default) allows short bursts up to the bucket size, smoothing out occasional spikes; sliding window log is precise but memory-heavy at scale; fixed window is fastest but has the classic 'double burst at window boundary' issue. For most APIs in 2026, token bucket with a 10-15s burst tolerance is the right default. Return 429 Too Many Requests with a `Retry-After` header so well-behaved clients back off, don't return 400 or close the connection abruptly.

use actix_governor::{Governor, GovernorConfigBuilder};
use actix_web::{App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let config = GovernorConfigBuilder::default()
        .seconds_per_request(1)
        .burst_size(10)
        .finish()
        .unwrap();
    HttpServer::new(move || {
        App::new().wrap(Governor::new(&config))
    }).bind(("0.0.0.0", 8080))?.run().await
}
Q23

How do you handle CORS in Actix Web?

IntermediateCORS

Answer

Use the `actix-cors` crate. It provides a configurable middleware: `Cors::default()` is permissive, `Cors::permissive()` allows everything (dangerous), and a builder pattern lets you whitelist specific origins, methods, and headers. For credentialed requests (cookies, Authorization header from a browser), you must list explicit origins, wildcards are rejected by browsers when `Access-Control-Allow-Credentials: true` is set.

A common production setup: `.allowed_origin_fn(|origin, _| origin.as_bytes().ends_with(b".goodspace.ai"))` for subdomain wildcards. Preflight (OPTIONS) is handled automatically, the middleware short-circuits before the route, so you don't need to register OPTIONS handlers. If you also use `NormalizePath`, register `Cors` AFTER it so the path is canonical before CORS checks run.

Watch for two common bugs: (1) listing `Access-Control-Allow-Origin` as a literal string rather than echoing the request's `Origin`, this breaks when you have multiple allowed origins; the middleware does this correctly but custom middleware sometimes gets it wrong; (2) forgetting `Vary: Origin` on cached responses, which causes shared caches (CDNs) to serve the wrong origin's CORS headers to a different origin. The crate handles both by default. For server-to-server APIs (no browser involved), don't enable CORS at all, it adds a preflight round trip and isn't needed when the client isn't a browser.

use actix_cors::Cors;
use actix_web::{App, HttpServer, http};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        let cors = Cors::default()
            .allowed_origin("https://app.goodspace.ai")
            .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
            .allowed_headers(vec![http::header::AUTHORIZATION, http::header::CONTENT_TYPE])
            .supports_credentials()
            .max_age(3600);
        App::new().wrap(cors)
    }).bind(("0.0.0.0", 8080))?.run().await
}
Q24

What is the lifetime gotcha with `&str` in handler arguments and how do you avoid it?

IntermediateRust Patterns

Answer

Handler arguments must be `'static` because they're stored across `.await` points and the futures need to outlive the function. So you cannot write a handler that takes `&str` directly, the compiler will complain about an unnamed lifetime. The fix: take `String` (owned) when extracting from JSON or path, or take `web::Path<String>` / `web::Query<MyStruct>` (owned wrappers).

For passing data INTO an async function called from a handler, use `&str` freely, that lifetime is tied to the function scope and works fine. The mental model: extractors give you owned values; you can borrow within the handler scope but not across the handler boundary. A related gotcha: returning a `&str` from a handler doesn't work if the string is borrowed from request state.

Convert to `String` or use `Cow<'static, str>` if you need flexibility. The same applies to slices and other references. If you have a frequently-used constant (e.g. an error message), declare it as `static MSG: &str = "..."`, the `'static` lifetime means you can return `&MSG` from a handler.

For complex shared structures (configuration loaded at startup), wrap in `Arc<T>` and clone the Arc into the handler, the Arc itself is owned but the cost of cloning is one atomic increment. This pattern is so common that `web::Data<T>` exists precisely to encapsulate it.

use actix_web::{web, get};

// WRONG, won't compile, '_ doesn't satisfy 'static
// async fn bad(name: &str) -> String { format!("{}", name) }

// RIGHT, own the data
#[get("/hello/{name}")]
async fn good(name: web::Path<String>) -> String {
    let inner: String = name.into_inner();
    process(&inner).await  // borrowing inside the handler is fine
}

async fn process(s: &str) -> String { s.to_uppercase() }
Q25

How do you implement file uploads with size limits in Actix Web?

IntermediateRequests

Answer

Use the `actix-multipart` crate. The `Multipart` extractor yields a stream of `Field` items, each with its own headers and body stream. To enforce per-file and total size limits, configure `MultipartConfig::default().memory_limit(2 * 1024 * 1024).total_limit(50 * 1024 * 1024)` and register via `app_data`.

For each field, read chunks into a `tokio::fs::File` so you don't hold the whole upload in memory. Validate content type early, don't trust the client-provided one alone; sniff the first few bytes against a known signature (PDF magic `%PDF-`, PNG `89 50 4E 47`, etc.) via the `infer` crate. For S3 uploads, prefer pre-signed PUT URLs, let clients upload directly to S3 and avoid the round-trip through your server.

This is the standard pattern for ShareChat's video pipeline and similar high-volume Indian platforms. If you must proxy through your server (e.g. virus scanning, watermarking), use `tokio::io::copy` to stream from the multipart field to the S3 multipart upload, `aws-sdk-s3` exposes a streaming upload API that handles part numbering. Don't store uploaded files on local disk if you have more than one server, they'll be gone on the next deploy.

For images specifically, use `image-rs` to decode, generate thumbnails, re-encode with the right format/quality, and discard EXIF (which often contains GPS data users don't realize they're uploading). On low-bandwidth Indian networks (Jio fiber being the exception), accepting resumable uploads via `tus-resumable` cuts retry pain dramatically.

use actix_multipart::Multipart;
use actix_web::{post, HttpResponse, Error};
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;

#[post("/upload")]
async fn upload(mut payload: Multipart) -> Result<HttpResponse, Error> {
    while let Some(field) = payload.next().await {
        let mut field = field?;
        let filename = field.content_disposition().get_filename().unwrap_or("unnamed").to_string();
        let path = format!("/tmp/{}", filename);
        let mut file = tokio::fs::File::create(&path).await?;
        while let Some(chunk) = field.next().await {
            file.write_all(&chunk?).await?;
        }
    }
    Ok(HttpResponse::Ok().json(serde_json::json!({"status": "ok"})))
}
Q26

How would you optimize an Actix Web service for sub-millisecond p99 latency at 100k+ RPS?

AdvancedPerformance

Answer

The wins, in order of impact: (1) **Use jemalloc or mimalloc**, the default system allocator is a 10-30% throughput tax under heavy allocation churn. Add `jemallocator` and set it as global allocator. (2) **Disable response body buffering** for streaming endpoints with `HttpResponse::streaming`. (3) **Pre-allocate string buffers** with `String::with_capacity` and `write!()` instead of `format!()` in hot paths, `format!` allocates and reallocates. (4) **Use `simd-json` or `sonic-rs`** for JSON parsing; serde_json is good but not the fastest. (5) **Pin workers to CPU cores** with `core_affinity`, eliminates context-switch overhead on machines with 32+ cores. (6) **Increase worker count carefully**, defaulting to num_cpus works for most workloads, but I/O-heavy services benefit from 2x. (7) **Profile with `pprof-rs` or `tokio-console`**, the latter shows task-level scheduling delays. (8) **Avoid Arc clones in hot paths**, pass `&T` from `Data::get_ref()` instead of cloning the Arc. (9) **Use `BytesMut` for byte buffers** instead of `Vec<u8>`. ShareChat's feed service hits 100k RPS per pod with these techniques.

The biggest non-Rust optimization remains the database, use prepared statements (sqlx does this by default), connection pooling via PgBouncer, and Redis caching with `redis::aio::ConnectionManager` (multiplexed, single connection per worker). At the OS level, tune `net.core.somaxconn` (default 128 is way too low; set to 65535), enable `SO_REUSEPORT` (Actix already does this), and disable Nagle's algorithm with `TCP_NODELAY` for low-latency endpoints. Linux kernel 6.6+ added `io_uring` support that Tokio can use for some operations; enable with `runtime_flavor = "multi_thread"` and the `io-uring` feature for 5-15% extra throughput on disk-heavy workloads.

For HTTPS termination, prefer terminating at the load balancer (nginx, Envoy, AWS ALB) and running Actix on plain HTTP behind it, TLS in-process costs 5-10% CPU; offloading frees that for application logic. If you must do TLS in Actix, use `rustls` over `openssl` (faster, no native dependency).

Key Points

  • Switch global allocator to jemalloc or mimalloc
  • simd-json / sonic-rs for JSON serialization
  • Pin workers to cores on 32+ core machines
  • Profile with tokio-console for scheduler stalls
  • Avoid Arc clones in hot paths, use Data::get_ref()
Q27

How do you implement graceful shutdown in Actix Web that drains in-flight requests?

AdvancedDeployment

Answer

`HttpServer` exposes a `Server` handle via `.run()`, call `.handle()` before `.await` to get an `actix_web::dev::ServerHandle` you can use to trigger shutdown. Wire a Tokio signal handler (`tokio::signal::unix::signal` for SIGTERM, SIGINT) that calls `handle.stop(true)`. The `true` flag means graceful, Actix stops accepting new connections, lets in-flight requests finish (up to `HttpServer::shutdown_timeout`, default 30s), then exits.

For long-running streaming responses (SSE, WebSocket), graceful drain alone won't disconnect them, add an Arc<AtomicBool> that handlers check inside their loop and close cleanly. In Kubernetes, set `terminationGracePeriodSeconds` to be longer than your `shutdown_timeout` (typically 35s grace + 30s shutdown) so the platform doesn't SIGKILL mid-drain. Also: kube sends SIGTERM, then waits, then SIGKILL, but the pod is removed from the Service endpoints BEFORE SIGTERM.

A 5-10s preStop sleep lets in-flight requests already routed to this pod complete before shutdown begins. Don't skip this; it's the difference between zero-downtime deploys and 502 spikes. Health checks deserve special handling during shutdown: as soon as you receive SIGTERM, flip a flag that makes `/health/ready` return 503.

The load balancer notices and stops sending new requests within 1-2 health-check intervals (configure these to be short, every 2s). Liveness checks (`/health/live`) should keep returning 200 until the very end so the platform doesn't force-kill you before drain finishes. For background tasks spawned with `actix_web::rt::spawn`, signal them via the same Arc<AtomicBool> and use `tokio::select!` on the shutdown signal inside their loops to break out promptly.

use actix_web::{App, HttpServer};
use tokio::signal::unix::{signal, SignalKind};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let server = HttpServer::new(|| App::new())
        .bind(("0.0.0.0", 8080))?
        .shutdown_timeout(30)
        .workers(num_cpus::get())
        .run();
    let handle = server.handle();
    let server_task = actix_web::rt::spawn(server);
    let mut sigterm = signal(SignalKind::terminate())?;
    let mut sigint = signal(SignalKind::interrupt())?;
    tokio::select! {
        _ = sigterm.recv() => tracing::info!("got SIGTERM"),
        _ = sigint.recv() => tracing::info!("got SIGINT"),
    }
    handle.stop(true).await;
    server_task.await?
}
Q28

How do you architect a multi-tenant SaaS on Actix Web with strict tenant isolation?

AdvancedArchitecture

Answer

Three patterns, same as any backend, but Rust's type system gives you a unique safety advantage: (1) **Schema-per-tenant** in Postgres, one database, one schema per tenant. Set `search_path` in a per-request DB connection from a middleware. Best isolation, hardest to migrate at scale. (2) **Row-level tenancy**, every table has a `tenant_id` column.

Inject `tenant_id` from the JWT into every query. The Rust-idiomatic way: a `TenantId(uuid::Uuid)` newtype that handlers extract via `FromRequest`, and a `TenantScopedDb` wrapper around your pool that requires `TenantId` on every query method, the compiler then prevents writing a cross-tenant query because you'd have to construct a fake `TenantId` explicitly. (3) **Database-per-tenant**, complete isolation, expensive at 1000+ tenants. For SaaS under 5k tenants, pattern (2) with the newtype enforcement is the sweet spot, type-safe, scales linearly, fast.

For regulated industries (Indian banking, healthcare) pattern (1) gives the per-tenant backup/restore story regulators prefer. Across all patterns, NEVER trust a `tenant_id` field in the request body, extract it from the verified JWT only. Add a clippy lint or integration test that asserts every SQL query in your codebase has a `tenant_id =` clause.

Add a defense-in-depth layer with Postgres Row Level Security (RLS) policies that gate every row by `current_setting('app.tenant_id')`, set at the start of each transaction by your middleware. RLS is belt-and-suspenders, if a developer forgets the `WHERE tenant_id =` clause, the database silently filters anyway. The downside is debugging confusion when queries return empty results because the session variable wasn't set; mitigate with an integration test that asserts a missing `app.tenant_id` causes a clear error.

For caching, namespace every Redis key by tenant_id (`tenant:42:user:1234`) so a cache clear for one tenant doesn't affect others. For analytics queries that aggregate across tenants (admin dashboards), use a separate set of credentials with RLS bypass, and treat those queries with extra scrutiny in code review.

Q29

How do you instrument Actix Web for distributed tracing with OpenTelemetry?

AdvancedObservability

Answer

Use `tracing` + `tracing-opentelemetry` + `opentelemetry-otlp`. The flow: initialize a `tracer_provider` that exports to your OTLP collector (Jaeger, SigNoz, Honeycomb), wrap it as a `tracing` subscriber, and use the `actix-web-opentelemetry` crate's `RequestTracing::new()` middleware to create a span per HTTP request. The middleware reads `traceparent` from incoming headers (W3C Trace Context) so traces continue across service boundaries, essential for any micro-service architecture.

Inside handlers, use `#[tracing::instrument(skip(pool))]` to wrap functions in spans; the macro emits structured events you can query later. For propagation to downstream services, use `reqwest-tracing` or manually inject `traceparent` into outgoing request headers. In production, use the Tail-Based sampling pattern (collect everything, decide whether to keep based on error/latency), head-based sampling at the edge loses interesting outliers.

SigNoz, used by GoodSpace and several Indian startups, has a free Actix dashboard template you can import directly. Beyond tracing, instrument three more signal types: (1) **Metrics** with `prometheus` or `metrics` crate, at minimum, p50/p95/p99 latency, request rate, error rate, in-flight requests per worker. The RED method (Rate, Errors, Duration) covers most needs. (2) **Logs** with structured JSON via `tracing-subscriber` JSON layer, every log line includes trace_id and span context, letting you jump from a slow trace to its logs in one query. (3) **Profiling** with `pprof-rs` exposing `/debug/pprof/profile` for on-demand CPU profiles.

SigNoz, Datadog, Honeycomb, and OpenObserve all ingest OTLP natively in 2026; pick whichever your team already uses. Budget 1-2% CPU overhead for full instrumentation in production, much cheaper than debugging in the dark.

use actix_web::{App, HttpServer};
use actix_web_opentelemetry::RequestTracing;
use tracing_subscriber::layer::SubscriberExt;

fn init_tracing() {
    let exporter = opentelemetry_otlp::new_exporter()
        .tonic().with_endpoint("http://collector:4317");
    let tracer = opentelemetry_otlp::new_pipeline()
        .tracing().with_exporter(exporter)
        .install_batch(opentelemetry_sdk::runtime::Tokio).unwrap();
    let subscriber = tracing_subscriber::registry()
        .with(tracing_opentelemetry::layer().with_tracer(tracer))
        .with(tracing_subscriber::fmt::layer());
    tracing::subscriber::set_global_default(subscriber).unwrap();
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    init_tracing();
    HttpServer::new(|| App::new().wrap(RequestTracing::new()))
        .bind(("0.0.0.0", 8080))?.run().await
}
Q30

How would you migrate an existing Actix Web 3.x service to 4.x in 2026?

AdvancedMigration

Answer

Actix Web 4.0 (released March 2022) was a major rewrite that aligned with `tokio 1.x` and stable Rust async. By 2026, 3.x is unsupported and many critical dependencies (sqlx, reqwest, tokio) no longer publish compatible versions. The migration: (1) **Update Cargo.toml**, `actix-web = "4"`, `actix-rt = "2"`, replace `actix-web-codegen = "0.4"` with the bundled macros. (2) **Update the runtime macro**, `#[actix_web::main]` replaces `#[actix_rt::main]` for binaries that need the local-set Tokio runtime. (3) **Rewrite handlers**, 3.x used `Responder` with associated types; 4.x changed `Responder::respond_to` to be synchronous and return `HttpResponse` directly.

Any custom Responder implementations need adjustment. (4) **Migrate middleware**, the `Transform`/`Service` traits changed signatures. Most third-party middleware crates have 4.x branches; pin to those. (5) **Update extractors**, `web::Json<T>` JSON error handling moved from `JsonConfig::error_handler` returning an `Error` to a closure-based pattern. (6) **Test for `Send` regressions**, 4.x is stricter about `Send` bounds on futures; some handlers that compiled in 3.x will now require restructuring. Budget 2-4 engineer-weeks for a medium service (50-100 routes).

The big wins post-migration: stable async without `actix-rt` quirks, modern tokio 1.x ecosystem, type-safe error handling via `ResponseError`. Don't migrate piecemeal, 3.x and 4.x can't coexist in the same binary because of the runtime conflict. The migration path I'd recommend: branch from main, do the upgrade end-to-end on the branch, get the test suite green, then merge in one PR.

Trying to maintain a long-lived 'half-migrated' branch is a recipe for conflicts as new features land on main. Run the upgraded service in shadow traffic mode for a week (same requests sent to both old and new, but only old's responses are user-facing) to catch behavioral differences before switching production traffic. The most common subtle break: error response bodies have a different default shape in 4.x, which can surprise clients that parse error JSON. For service-to-service consumers within your company, coordinate the error-shape change as a deliberate API version bump.

Key Points

  • actix-rt 1.x → 2.x; actix-web 3.x → 4.x in lockstep
  • Responder trait signature changed, synchronous in 4.x
  • Stricter Send bounds may surface in handlers
  • Budget 2-4 engineer-weeks for a 50-100 route service
  • Use shadow traffic to catch behavioral regressions before cutover

Companies Hiring Actix

Discord
Cloudflare
1Password
ShareChat
Razorpay
Polygon
Fastly

Salary Insights

Average in India
₹12-32 LPA

Frequently Asked Questions

Is Actix Web still relevant in 2026 with Axum gaining popularity?

Yes, Actix Web 4.x is actively maintained, holds top TechEmpower rankings, and has a richer middleware ecosystem than Axum. Axum is the Tokio team's official framework and is preferred for greenfield projects in many shops, but Actix Web is still the most-deployed Rust framework in 2026. The two are interchangeable for most use cases; the choice often comes down to team familiarity and existing middleware needs. If you're learning Rust web development from scratch today, learn the underlying primitives (Tokio, Hyper, Tower) first, both frameworks become much easier once you understand the layer beneath them. Most engineers comfortable in one can pick up the other in a week.

How much does a Rust/Actix Web developer earn in India?

₹12-32 LPA in 2026 for mid-to-senior Rust backend developers. Companies hiring: ShareChat, Razorpay (payment-gateway team), Polygon (Mumbai blockchain office), CRED, Hasura, Postman, and several Bengaluru and Hyderabad-based fintechs. The upper end (₹25-32 LPA) usually requires demonstrated systems-level depth, designing for sub-millisecond latency, lock-free concurrency, custom Tokio runtime tuning. Bengaluru Rust meetups (monthly) and the India Rust India community on Discord are the main hiring funnels. Remote roles for US or European companies typically pay 1.5-3x the local India range for senior engineers but require strong async fundamentals and the ability to ship production systems unsupervised. Junior Rust positions are rare in India because most companies want at least 2 years of Rust experience; the standard path is to pivot from Go or Java backend roles with a strong side-project portfolio.

Do I need to know the actor model to use Actix Web?

No. Actix Web 4.x handlers are plain async functions, you can build a full production service without writing a single actor. The `actix` crate (separate from `actix-web`) is only needed for stateful in-process components like WebSocket connection registries or scheduled jobs. Most teams use a plain `Arc<RwLock<...>>` or `tokio::sync::Mutex` instead, and reserve actors for cases where supervised restart semantics or sequential mailbox processing are actually useful. The 'Actix' name is essentially historical at this point, the framework outgrew its origins. If you specifically need actor primitives, consider the more general-purpose `ractor` crate, which has cleaner ergonomics than the original actix crate and works equally well with Actix Web. Don't let the name scare you away from the framework, onboarding is about the same time as learning Axum once you grok Tokio basics.

How does Actix Web compare to Go's Gin or Node.js Express?

Raw throughput: Actix Web is roughly 2-4x faster than Gin and 5-10x faster than Express on the same hardware (per TechEmpower 2025-2026). Memory: Actix uses 5-10x less memory under load. Developer velocity: Express ships features faster for prototypes; Actix wins for stable, long-lived services where correctness and performance matter. The big trade-off is the Rust learning curve, 3-6 months to ship confidently for a Node/Go developer. Operationally, Rust services need less monitoring of memory leaks and GC pauses (there's no GC) but more attention to compile times during CI, a fresh-clone full build of a 50-route service takes 5-10 minutes, vs seconds for Go. Use `sccache` and incremental Docker layers to mitigate. Most teams pick Actix when latency tail and cost-per-request matter (high-traffic APIs, edge compute) and stay with Go/Node when developer iteration speed matters more.

Which Rust version should I use with Actix Web in 2026?

Rust 1.83 or newer. Actix Web 4.9+ requires at least 1.75 for async-fn-in-trait stabilization. Most Bengaluru-based shops pin to the latest stable (currently 1.85 as of mid-2026) and update monthly. Avoid nightly Rust in production unless you have a specific feature need, the ecosystem is mature enough that stable covers 99% of cases. Use a `rust-toolchain.toml` file in your repo to lock the version across team members and CI; that prevents the 'works on my machine' class of bugs caused by compiler version differences. The Rust release cadence is 6 weeks, predictable enough that staying current adds minimal friction.

Should I learn Tokio before Actix Web?

Surface-level Tokio knowledge is helpful but not required to start. You'll need to understand `async`/`await`, what a Future is, and why `.await` can be cancelled (drop-on-cancellation is a common pitfall). For advanced work, custom middleware, WebSocket broadcast registries, graceful shutdown, deeper Tokio knowledge (Send + Sync, `tokio::select!`, channels) becomes essential. The official Tokio tutorial (~3-4 hours) is the right first step. Pair it with Luca Palmieri's 'Zero to Production in Rust' which uses Actix as its example framework and walks through every production concern, testing, logging, deployment, error handling, in 600 pages of well-paced code. That book has become the de-facto onboarding text for new Rust backend developers in India; expect interviewers to reference patterns from it.

Introduction

Actix Web (currently 4.x, with 4.9 as the latest minor release in 2026) is the highest-performing general-purpose Rust web framework, consistently topping the TechEmpower benchmarks since 2018. It pairs Tokio's async runtime with an actor system that originally inspired the name, though most modern Actix Web handlers no longer interact with actors directly, they're plain async functions that the framework dispatches per worker thread. The framework's design prioritises sub-millisecond latency at the cost of a slightly steeper learning curve compared to Axum or Rocket, but in production, Actix services routinely sustain 100k+ RPS per pod on 4-vCPU Kubernetes nodes, with p99 latencies under one millisecond when the database isn't the bottleneck.

If you're interviewing for a Rust backend role in India in 2026, Actix is still the framework most commonly asked about alongside Axum. Expect deep questions on extractors, the App/Service builder, middleware ordering, the actor model, Send + Sync bounds in shared state, lifetime issues in handlers, and how Actix maps to multi-threaded Tokio workers. Companies like ShareChat, Razorpay's payment-gateway team, and several Bengaluru-based fintechs run Actix in production for low-latency APIs (sub-millisecond p99). Interviews often start with a coding round (build a basic CRUD endpoint with error handling, validation, and tests) and progress to system-design discussions about how you'd scale to a specific RPS or implement a specific failure-handling pattern.

This guide covers 30 Actix Web interview questions seen most often in 2026, grouped by difficulty. Every answer includes the underlying concept, the gotchas that trip up new Rust developers, and a code example where it adds clarity. Salaries for Rust backend roles in India currently sit at ₹12-32 LPA depending on system-design depth, with the top end going to teams shipping high-frequency-trading, payment infrastructure, or real-time messaging. The Indian Rust community is small but tightly-knit, the Bengaluru Rust meetup (monthly, in-person), the Rust India Discord (around 4,000 members in 2026), and the annual RustConf India are the main networking venues; many hiring conversations start at these events. Pair this guide with hands-on practice, build a small URL shortener or chat backend with Actix, deploy it to Fly.io or Railway, and have something concrete to point to in interviews. Real production experience, even on a side project, beats memorising answers any day.

Ready to practice Actix interviews?

Don't just read, practice these Actix questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview