Skip to content

Top 33 Actix Web Interview Questions and Answers [2026]

November 8, 2026 28 min read
Actix Web interview guide by Second Talent
TL;DR: Actix Web interviews in 2026 test whether a candidate understands the per-worker runtime model, can pick the right extractor and state wrapper, and can find a middleware or blocking-call bug in a short handler. Candidates should know Actix Web 4.18.2 (November 6, 2026), the security fix in that release, the deprecation of actix-web-actors in favor of actix-ws, and how Actix differs from Axum 0.8.

Actix Web 4.18.2 shipped on November 6, 2026 with a high-severity fix that bounds decompressed request bodies by the same size limits the JSON, form, bytes and string extractors already enforce, and it raised the default form limit to 32 KiB.

Upgrade any server on 4.18.1 or earlier first. The full history is in the actix-web changelog. The questions below are for senior Rust backend interviews, with four find-the-bug rounds and two design tradeoffs against Axum.

Key takeaways
  1. 1The Json extractor accepts 2 MiB by default, Bytes and String accept 256 KiB, and Form accepts 32 KiB since 4.18.2.
  2. 2Each worker runs its own single-threaded Tokio runtime, so handler futures do not need to be Send, and one blocking call stalls every connection on that worker.
  3. 3A Path extractor that fails to parse returns 404 Not Found, while a bad Query or Json returns 400.
  4. 4Middleware runs in reverse registration order: the last .wrap() on an App sees the request first and the response last.

Handlers and Extractors

1. How does an Actix Web handler get its inputs?

Through extractors: any handler argument whose type implements FromRequest is built from the request before the handler runs. A handler is an async function that takes up to 16 extractors and returns something that implements Responder.

The extractors guide lists the built-in ones: Path, Query, Json, Form, Bytes, String, Payload, Header, Data, HttpRequest and more.

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

#[derive(Deserialize)]
struct NewComment {
    body: String,
}

#[derive(Deserialize)]
struct Paging {
    notify: Option<bool>,
}

#[post("/posts/{post_id}/comments")]
async fn create_comment(
    path: web::Path<u64>,
    query: web::Query<Paging>,
    payload: web::Json<NewComment>,
) -> impl Responder {
    let post_id = path.into_inner();
    let notify = query.notify.unwrap_or(false);
    HttpResponse::Created().json(serde_json::json!({
        "post_id": post_id,
        "notify": notify,
        "length": payload.body.len(),
    }))
}

A strong candidate adds that only one extractor can consume the body. Json, Form, Bytes, String and Payload all read the same stream, so declaring two of them means the second sees an empty body.

A weak answer treats extractors as magic and cannot say what happens when one fails.

2. What status does GET /users/abc return here?

#[get("/users/{id}")]
async fn get_user(id: web::Path<u32>) -> impl Responder {
    format!("user {}", id.into_inner())
}

404 Not Found. When Path fails to deserialize a segment, its default error handler wraps the error in ErrorNotFound, so the client sees the same status as an unknown route. A failed Query or Json extraction returns 400 Bad Request instead.

Many candidates guess 400. The follow-up is whether 404 is right for your API.

It hides a client bug behind a "no such page" response, so teams that want a 400 with a JSON body register a web::PathConfig with a custom error_handler through app_data.

A strong candidate also says the handler never runs, so logging inside it will not show the failure.

3. What are the default body size limits, and how do you change them?

The defaults come from three config types, each read from app_data:

  • JsonConfig: 2 MiB (2,097,152 bytes), plus an optional content type check and error handler.
  • PayloadConfig: 256 KiB, used by Bytes and String.
  • FormConfig: 32 KiB since 4.18.2, up from 16 KiB.
App::new()
    .app_data(web::JsonConfig::default().limit(64 * 1024))
    .service(
        web::scope("/uploads")
            .app_data(web::PayloadConfig::new(20 * 1024 * 1024))
            .route("", web::post().to(upload)),
    )

Config set on a scope or resource overrides the app-level value for routes under it, so a large-upload endpoint does not force a high limit on every JSON route.

Since 4.18.2 the limits also apply after decompression, which closes the case where a small gzip or zstd body expanded past the limit inside the decoder.

A candidate who streams large uploads should reach for web::Payload and enforce a limit chunk by chunk, or call Payload::to_bytes_limited.

4. Find the bug: why do malformed bodies get a 200?

#[post("/events")]
async fn ingest(event: Option<web::Json<Event>>) -> impl Responder {
    if let Some(event) = event {
        store(event.into_inner()).await;
    }
    HttpResponse::Ok().finish()
}

Option<T> turns any extraction error into None. A body with a missing field, the wrong content type or a size overflow is silently dropped, and the client sees success. Events disappear with no log line and no error rate to alert on.

The fix depends on intent. If the body is required, take web::Json<Event> directly and let the extractor return 400.

If the handler needs to react to the error, take Result<web::Json<Event>, actix_web::Error> and log or map the error explicitly.

A strong candidate reserves Option for inputs that are optional, such as an Option<web::Header<...>>, and never wraps a body extractor in it.

5. How do you write a custom extractor?

Implement FromRequest with an error type and a future. For synchronous work such as reading a header, use std::future::Ready; for async work such as a database lookup, return a boxed future.

use std::future::{ready, Ready};

use actix_web::{dev::Payload, error::ErrorUnauthorized, Error, FromRequest, HttpRequest};

pub struct ApiKey(pub String);

impl FromRequest for ApiKey {
    type Error = Error;
    type Future = Ready<Result<Self, Self::Error>>;

    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
        let key = req
            .headers()
            .get("x-api-key")
            .and_then(|v| v.to_str().ok())
            .map(|s| ApiKey(s.to_owned()));

        ready(key.ok_or_else(|| ErrorUnauthorized("missing api key")))
    }
}

Any handler that declares ApiKey now rejects requests without the header before its body runs.

A strong candidate compares this with doing the same check in middleware: an extractor is opt-in per handler and gives the handler a typed value, while middleware applies to a whole scope and cannot hand a typed value to the handler except through request extensions.

They also avoid reading the body in a custom extractor unless that is its whole job.

6. How does routing work, and what happens when two routes overlap?

Routes are registered with macros (#[get], #[post], #[route]) and .service(), or with the builder API: App::route, web::resource and web::scope. Path segments use {name} syntax, and {tail:.*} matches the rest of the path.

The URL dispatch docs describe matching as first match wins in registration order.

That order is the trap. A resource for /users/{id} registered before /users/me swallows the second, and me then fails to parse as an integer and returns 404 (question 2).

Guards such as guard::Header or guard::Host can disambiguate resources with the same path.

Since 4.18.2, when a path matches but every method guard rejects the request, the built-in default returns 405 with an Allow header instead of 404, which also applies to macro routes.

7. What can a handler return?

Anything that implements Responder: HttpResponse, String, &'static str, web::Json<T>, web::Html (added in 4.8), Either<L, R>, and Result<T, E> where E converts into actix_web::Error.

Since 4.10, Result<(), E> is a responder too, and Ok(()) becomes 204 No Content.

The Responder trait has customize(), which lets a handler change the status or add headers without building an HttpResponse by hand:

async fn create() -> impl Responder {
    web::Json(Created { id: 42 })
        .customize()
        .with_status(StatusCode::CREATED)
        .insert_header(("location", "/items/42"))
}

A weak answer returns HttpResponse everywhere and serializes JSON by hand. A strong one picks typed responders so the handler signature documents what it returns.

Application State

8. How do you share state between handlers?

Register it with App::app_data(web::Data::new(value)) and extract it with web::Data<T>. Data wraps an Arc, so clones are cheap and every handler sees the same value.

The application guide notes that state can also be set on a scope or resource.

Two failure modes are worth testing. If no data of the requested type is registered, the Data<T> extractor fails with a 500 Internal Server Error at request time; nothing checks it at compile time.

And types must match exactly: registering Data::new(Arc::new(pool)) and extracting Data<Pool> fails, because the stored type is Arc<Pool>.

A strong candidate mentions Data::from(arc) for an existing Arc and writes one test per route that touches state, since the compiler will not catch a missing registration.

9. Find the bug: why does each request see a different counter?

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        let hits = web::Data::new(Mutex::new(0u64));
        App::new()
            .app_data(hits)
            .route("/", web::get().to(index))
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

The closure passed to HttpServer::new is an app factory that runs once per worker. Each worker builds its own Mutex, so a server with eight workers has eight counters, and the value a client sees depends on which worker took the connection.

The server docs say each worker gets its own application instance.

let hits = web::Data::new(Mutex::new(0u64));

HttpServer::new(move || {
    App::new()
        .app_data(hits.clone())
        .route("/", web::get().to(index))
})

Creating the Data outside the closure and cloning it inside shares one Arc across workers.

A strong candidate also points out that per-worker state is sometimes what you want, for a thread-local cache that needs no lock, and that an AtomicU64 is a better fit than a Mutex for a counter.

10. Why does holding a std Mutex guard across .await compile in Actix but not in Axum?

Actix handler futures do not need to be Send, because each request is polled on one worker thread from start to finish.

A std::sync::MutexGuard is not Send, so Axum, whose handlers run on Tokio's multi-threaded runtime, rejects a guard held across an .await at compile time. Actix accepts the same code.

async fn update(cache: web::Data<std::sync::Mutex<Cache>>) -> impl Responder {
    let mut guard = cache.lock().unwrap();
    let fresh = fetch_remote().await; // guard still held here
    guard.insert(fresh);
    HttpResponse::Ok()
}

Compiling does not make it safe. While this future is suspended, another request on the same worker can try to lock the mutex, and that blocking lock() call stops the only thread that could finish the first request. The worker deadlocks.

The fix is to drop the guard before the await, scope it in a block, or use tokio::sync::Mutex when the lock must be held across an await. A candidate who knows this has debugged a real Actix service.

11. When do you use web::ThinData instead of web::Data?

For types that are already cheap to clone and share state internally, such as database pools that wrap an Arc. ThinData was added in 4.9 and, unlike Data, clones the inner value on each extraction instead of adding another Arc layer.

async fn list(ThinData(pool): web::ThinData<PgPool>) -> actix_web::Result<impl Responder> {
    let rows = fetch_items(&pool).await.map_err(error::ErrorInternalServerError)?;
    Ok(web::Json(rows))
}

App::new().app_data(web::ThinData(pool.clone()))

The docs warn that the caller must make sure clones share state, otherwise each request gets an independent copy. A strong candidate states that rule and checks that the pool type is Clone in the shared sense.

The map_err call in the example has its own problem, covered in question 25.

12. How do you structure a large Actix app across modules?

Each module exposes a fn config(cfg: &mut web::ServiceConfig) that registers its routes and data, and the app mounts it with App::configure or Scope::configure.

That keeps main short and lets tests build an App with only the module under test.

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/billing")
            .app_data(web::JsonConfig::default().limit(16 * 1024))
            .service(list_invoices)
            .service(create_invoice),
    );
}

App::new().configure(billing::config).configure(users::config)

A strong candidate also builds the app in a function that both main and tests call, so tests exercise the same middleware and data registration as production instead of a hand-copied subset.

Middleware

13. In what order does middleware run?

In reverse order of registration. The middleware docs state that if you call wrap() several times, the last one runs first. The last-registered middleware is the outermost layer: it sees the request first and the response last.

App::new()
    .wrap(Logger::default())   // inner
    .wrap(Compress::default()) // outer: runs first on the request
    .service(index)

Here Compress wraps Logger, so the logger records the response before compression. Order matters most for authentication and CORS: CORS must sit outside auth so that preflight OPTIONS requests get CORS headers instead of a 401.

Middleware on a scope or resource runs inside app-level middleware. A weak answer assumes top-to-bottom order, which is the reverse of what happens.

14. How do you write middleware with middleware::from_fn?

from_fn, added in 4.9, turns an async function into middleware. The function takes the ServiceRequest and a Next, and can also take extractors as its first parameters.

use std::time::Instant;

use actix_web::{
    body::MessageBody,
    dev::{ServiceRequest, ServiceResponse},
    http::header::{HeaderName, HeaderValue},
    middleware::{from_fn, Next},
    Error,
};

async fn timing(
    req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    let start = Instant::now();
    let mut res = next.call(req).await?;
    let ms = start.elapsed().as_millis() as u64;
    res.headers_mut().insert(
        HeaderName::from_static("x-response-time-ms"),
        HeaderValue::from(ms),
    );
    Ok(res)
}

// App::new().wrap(from_fn(timing))

The docs warn about extractors that consume the body: if middleware takes Json or Bytes, the handler cannot read the body again unless the middleware puts it back with req.set_payload.

A strong candidate also notes that the ? on next.call returns early on an error, so the timing header is missing on error responses; logging that must cover errors needs to handle the Err branch.

15. When do you still implement Transform and Service by hand?

When the middleware needs configuration built once per worker, its own poll_ready logic, or is published as a reusable crate with a builder.

Transform is the factory that the app calls per worker, and it produces a Service that wraps the inner service and handles each request.

Points a strong candidate raises:

  • The inner service is usually held in an Rc so the returned future can call it without borrowing self, which works because Actix services are not required to be Send.
  • forward_ready! delegates readiness to the inner service.
  • Changing the body type requires EitherBody or BoxBody, since a middleware that returns an early 401 produces a different body type from the inner service.
  • For most app-level needs, from_fn or wrap_fn is shorter and harder to get wrong.

16. Find the bug: why does this route panic at startup on 4.14 or later?

web::resource("/admin")
    .route(web::get().wrap(from_fn(require_admin)).to(admin_page))

Route::wrap was called before Route::to. Before 4.14.0, calling to() after wrap() replaced the service and silently dropped the middleware, so the admin check never ran.

Since 4.14.0, it panics with a message that the route middleware was already registered. The correct order is web::get().to(admin_page).wrap(from_fn(require_admin)).

The strong answer goes further: code written before 4.14 with this pattern ran without its middleware in production.

A candidate who upgrades and sees this panic should check access logs for that route, because the authorization check may have been missing the whole time.

17. Which built-in middleware do you use, and what does each one miss?

  • Logger: access logs through the log crate. Since 4.11, Logger::log_level() sets the level. For structured tracing with spans, teams usually add a tracing-based middleware instead.
  • Compress: negotiates brotli, zstd or gzip from Accept-Encoding. It skips images and video, and since 4.13 it does not compress 206 Partial Content responses.
  • NormalizePath: merges or trims trailing slashes. 4.13 fixed a panic when it rewrote a scoped dynamic path before extraction.
  • DefaultHeaders: adds headers such as security headers when the response does not already set them.
  • ErrorHandlers: rewrites responses by status code (question 26).
  • Condition: enables a middleware based on a runtime flag.

CORS, sessions and identity live in the separate actix-extras crates (actix-cors, actix-session, actix-identity). A candidate who has shipped Actix should know that Cors::permissive() is meant for development only.

Runtime, Actors and Async

18. Do you need the actor framework to use Actix Web?

No. Actix Web 4 is a plain async framework on top of Tokio, and the actix actor crate is optional.

The main reason teams used actors with Actix Web was WebSockets through actix-web-actors, and that crate is now deprecated with a README that says to migrate to actix-ws (currently 0.4.1), which handles WebSockets without actors.

The actix crate itself is still maintained (0.13.6 on crates.io) and fits code that already models state as message-passing actors.

A strong candidate can explain the cost of actors, such as mailbox types, Handler boilerplate and harder testing, and prefers tasks plus channels (tokio::sync::mpsc, broadcast) for new code.

A weak answer says Actix Web "is actor-based", a description that dates from version 1.

19. How does Actix Web use the Tokio runtime?

Through actix-rt, which runs one single-threaded Tokio runtime per worker thread. HttpServer starts N workers, each with its own copy of the App, and an accepted connection stays on the worker that took it.

The HttpServer::workers rustdoc says the default count comes from std::thread::available_parallelism().

Consequences a senior candidate names:

  • Handler futures and app state do not need to be Send, which allows Rc and RefCell in per-worker state.
  • Work does not move between workers. One worker with a slow handler can have a long queue while others sit idle, unlike a work-stealing scheduler.
  • CPU-heavy or blocking code in a handler stalls every connection on that worker (question 20).
  • actix_web::rt::spawn calls tokio::task::spawn_local, so a spawned task runs on the same worker and can hold non-Send values.

20. Find the bug: why does latency spike for unrelated requests?

#[post("/hash")]
async fn hash_password(form: web::Json<Login>) -> impl Responder {
    let hash = bcrypt::hash(&form.password, 12).unwrap(); // about 250 ms of CPU
    HttpResponse::Ok().body(hash)
}

The bcrypt call blocks the worker thread. Every other request on that worker, including health checks, waits until it returns. The server docs say a blocked worker stops processing new requests.

The same applies to std::thread::sleep, a synchronous database driver or reqwest::blocking.

#[post("/hash")]
async fn hash_password(form: web::Json<Login>) -> actix_web::Result<impl Responder> {
    let password = form.into_inner().password;
    let hash = web::block(move || bcrypt::hash(password, 12))
        .await?
        .map_err(error::ErrorInternalServerError)?;
    Ok(HttpResponse::Ok().body(hash))
}

web::block runs the closure on the worker's blocking thread pool, which defaults to 512 threads divided by available parallelism.

A strong candidate adds that a blocking pool does not make CPU free: a flood of hash requests still saturates the cores, so the endpoint needs rate limiting.

They also note the closure must be Send, which is why the password is moved out of the extractor first.

21. What is the difference between #[actix_web::main] and #[tokio::main]?

Both can run an Actix Web server. #[actix_web::main] starts an actix-rt System, and the crate docs show #[tokio::main] as an alternative.

The 4.0 migration guide says the server works within Tokio's multi-threaded runtime, which helps when the binary also runs other Tokio services.

The same guide notes that actor-based code still needs #[actix_web::main] so a System exists.

A strong candidate also separates the two spawn functions: tokio::spawn requires a Send + 'static future and can run it on any thread of a multi-threaded runtime, while actix_web::rt::spawn keeps it on the current worker.

Mixing them without understanding which thread a task lands on is how non-Send types end up in compile errors far from their cause.

22. How do you configure the server for production and graceful shutdown?

Know the defaults, then set the ones your load balancer depends on:

  • Keep-alive: 5 seconds by default; set it above the load balancer's idle timeout or the proxy will reuse a connection the server already closed.
  • Client request timeout: 5,000 ms to receive the request head, then 408.
  • Backlog: 1,024 pending connections.
  • max_connections: 25,000 per worker; max_connection_rate caps concurrent TLS handshakes at 256 per worker.
  • Shutdown timeout: 30 seconds for in-flight requests after a stop signal, then workers are dropped.

On Unix, SIGTERM triggers graceful shutdown.

Since 4.11, HttpServer::shutdown_signal() accepts any future as the stop trigger, and since 4.14.1 graceful shutdown closes idle keep-alive connections and lets active ones finish only their current request.

A strong candidate sets the Kubernetes terminationGracePeriodSeconds above the shutdown timeout and fails the readiness probe first, so the pod leaves the load balancer before connections drain.

23. How do you handle WebSockets without actors?

With actix-ws. actix_ws::handle returns the upgrade response, a Session for sending and a MessageStream for receiving, and the handler spawns a task that owns the session.

use actix_web::{rt, web, Error, HttpRequest, HttpResponse};
use actix_ws::Message;

async fn ws(req: HttpRequest, body: web::Payload) -> Result<HttpResponse, Error> {
    let (response, mut session, mut stream) = actix_ws::handle(&req, body)?;

    rt::spawn(async move {
        while let Some(Ok(msg)) = stream.recv().await {
            match msg {
                Message::Ping(bytes) => {
                    if session.pong(&bytes).await.is_err() {
                        return;
                    }
                }
                Message::Text(text) => {
                    if session.text(text).await.is_err() {
                        return;
                    }
                }
                Message::Close(reason) => {
                    let _ = session.close(reason).await;
                    return;
                }
                _ => {}
            }
        }
    });

    Ok(response)
}

Follow-ups that separate levels: the task must respond to pings and close frames itself, there is no built-in heartbeat, so a strong candidate adds a timer that closes idle clients, and broadcasting to many clients needs a shared registry of Session clones or a broadcast channel. handle_with_protocols negotiates Sec-WebSocket-Protocol values such as graphql-transport-ws.

Errors and Testing

24. How do you turn domain errors into HTTP responses?

Implement ResponseError for an error enum. The trait has two methods with defaults: status_code() returns 500, and error_response() builds a response from the status and the error's Display output.

Any ResponseError converts into actix_web::Error, so handlers can use ?. The errors guide covers the pattern.

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

#[derive(Debug, thiserror::Error)]
pub enum ApiError {
    #[error("not found")]
    NotFound,
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("internal error")]
    Internal(#[from] sqlx::Error),
}

impl ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        match self {
            ApiError::NotFound => StatusCode::NOT_FOUND,
            ApiError::Conflict(_) => StatusCode::CONFLICT,
            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code())
            .json(serde_json::json!({ "error": self.to_string() }))
    }
}

The #[error("internal error")] message on the database variant is deliberate: to_string() goes into the body, so the variant must not print the inner error. A strong candidate logs the source error server-side before mapping it.

25. Find the bug: what does a client see when the database fails?

async fn get_order(pool: web::ThinData<PgPool>, id: web::Path<i64>)
    -> actix_web::Result<web::Json<Order>>
{
    let order = sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = $1")
        .bind(id.into_inner())
        .fetch_one(&pool.0)
        .await
        .map_err(actix_web::error::ErrorInternalServerError)?;
    Ok(web::Json(order))
}

ErrorInternalServerError wraps the error in InternalError, whose response body is the error's Display output. A failed query sends the database error text to the client, which can include table names, constraint names or connection details.

A missing row also becomes a 500 instead of a 404, because fetch_one returns RowNotFound.

The fix is a domain error type (question 24) that logs the source and returns a generic message, with RowNotFound mapped to NotFound.

A candidate who asks "why not use ? on the sqlx error" should know the answer: sqlx::Error does not implement ResponseError, and the orphan rule stops you from implementing it in your crate, so a local wrapper type is required.

The same applies to anyhow::Error.

26. How do you make every error response use one JSON shape?

Errors come from three places, and each needs its own hook:

  • Your handlers: a ResponseError type that builds the JSON body.
  • Extractors: JsonConfig, QueryConfig, PathConfig and FormConfig each take an error_handler that can return a custom error.
  • Everything else (unknown routes, 405s, middleware errors): the ErrorHandlers middleware, which rewrites responses by status code, and Error::add_response_mapper(), added in 4.15, which lets middleware modify error-generated responses before they are sent.
let json_cfg = web::JsonConfig::default()
    .limit(64 * 1024)
    .error_handler(|err, _req| {
        let body = serde_json::json!({ "error": err.to_string() });
        error::InternalError::from_response(err, HttpResponse::BadRequest().json(body)).into()
    });

App::new().app_data(json_cfg)

A weak answer covers only handler errors and is surprised when a malformed JSON body returns plain text. A strong one tests each of the three paths.

27. How do you unit test a handler with its middleware?

Build the app with test::init_service, send a TestRequest, and check the response. #[actix_web::test] runs the test on an actix runtime. The testing guide shows the helpers.

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

    use super::*;

    #[actix_web::test]
    async fn rejects_missing_api_key() {
        let app = test::init_service(
            App::new()
                .app_data(web::Data::new(AppState::for_tests()))
                .configure(billing::config),
        )
        .await;

        let req = test::TestRequest::get().uri("/billing/invoices").to_request();
        let resp = test::call_service(&app, req).await;
        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
    }

    #[actix_web::test]
    async fn lists_invoices() {
        let app = test::init_service(build_app(AppState::for_tests())).await;
        let req = test::TestRequest::get()
            .uri("/billing/invoices")
            .insert_header(("x-api-key", "test"))
            .to_request();
        let invoices: Vec<Invoice> = test::call_and_read_body_json(&app, req).await;
        assert!(invoices.is_empty());
    }
}

A strong candidate tests through the real app builder (question 12), so missing app_data shows up as a failing test rather than a production 500. They test error paths too: a malformed body, a missing header, a path that does not parse.

28. When do you need a real server in tests?

When the behavior under test lives below the service layer: TLS, HTTP/2, keep-alive, connection limits, streaming, WebSocket upgrades, or the interaction with a real HTTP client. init_service calls the service directly and skips the HTTP stack.

The actix-test crate (0.1.6) starts a server on a random port and returns a client for it.

Recent releases fixed test-only issues worth knowing: 4.16.0 stopped TestRequest from retaining app data by disabling its request pooling, and 4.18.1 added TestRequest::query() for building HTTP QUERY requests.

A strong candidate keeps most tests at the init_service level for speed and writes a handful of real-server tests for the network behavior.

Actix Web vs Axum

29. What are the design differences between Actix Web and Axum?

  • Runtime: Actix runs one single-threaded runtime per worker, and handlers need not be Send. Axum runs on Tokio's work-stealing multi-threaded runtime, and handler futures must be Send.
  • Middleware: Actix has its own Transform/Service traits. Axum uses Tower, so tower-http layers for tracing, timeouts, CORS and compression work unchanged.
  • State: Actix stores state in a type map and fails at runtime with a 500 when it is missing (question 8). Axum's State<T> is checked at compile time against Router::with_state.
  • Extractor order: Axum requires the body extractor to be the last argument and splits traits into FromRequestParts and FromRequest. Actix allows any order but only one body extractor.
  • Path syntax: Axum 0.8.0 (January 1, 2025) switched from /:id to /{id}, per the axum changelog, so both frameworks now use braces. Axum 0.8.9 is the current release.
  • Built-ins: Actix ships its own HTTP/1 and HTTP/2 server, compression, static files (actix-files) and routing macros. Axum relies on Hyper and Tower crates.

A strong candidate names the tradeoff each difference creates instead of declaring a winner.

30. You are starting a new Rust API service. Which framework do you pick, and why?

This is a design question, and the answer should depend on the team and the workload. Reasons a candidate might give for Actix Web:

  • Per-worker state without locks, and non-Send types such as Rc caches, are simple to use.
  • The existing codebase, internal crates or team experience are already on Actix.
  • The built-in server features (connection limits, TLS handshake limits, per-worker blocking pools) cover what the team needs without assembling Tower layers.

Reasons for Axum: the service shares middleware with other Tower-based services (tonic gRPC, for example), compile-time state checking matters, or uneven request costs make a work-stealing scheduler a better fit.

A weak answer quotes a benchmark from years ago. A strong one says both frameworks are fast enough for almost every API, and that database access, serialization and blocking calls dominate latency in practice.

What Changed Recently

May 12, 2025
4.11.0 adds HttpServer::shutdown_signal() and Logger::log_level()
Nov 16, 2025
4.12.0 adds the ws feature and changes streaming() defaults
Feb 18, 2026
4.13.0 raises MSRV to 1.88, larger HTTP/2 windows, TCP_NODELAY option
Jun 21, 2026
4.14.0 panics on Route::to() after Route::wrap()
Aug 21, 2026
4.15.0 adds Error::add_response_mapper(), removes experimental io-uring
Oct 23, 2026
4.18.0 adds the HTTP QUERY method and raises MSRV to 1.89
Nov 6, 2026
4.18.2 bounds decompressed bodies (GHSA-qph8-7859-gq2f), form limit 32 KiB

31. What did the November 2026 security release fix?

The GHSA-qph8-7859-gq2f advisory, published November 6, 2026 and rated high (CVSS 4.0 score 8.7), covers decompression amplification in the actix-http Decoder.

The decoder did not cap how far gzip, deflate, brotli or zstd content could expand, so a small compressed payload could cost far more memory and CPU than its size suggested.

Affected versions are actix-http before 3.20.1 and actix-web before 4.18.2.

On the server side, 4.18.2 now applies the configured form, JSON, bytes and string limits to the decompressed size, and adds UrlencodedError::OverflowUnknownLength for streamed forms that exceed the limit.

A strong candidate checks Cargo.lock for both crates, not only Cargo.toml, and knows that awc clients reading responses from untrusted servers were the most exposed case in the advisory.

32. What is the HTTP QUERY support added in 4.18?

4.18.0 added a #[query] route macro, method = "QUERY" in #[route] and a guard::Query() method guard, and 4.18.1 added TestRequest::query().

QUERY is a safe, idempotent HTTP method that carries a request body, aimed at search and filter requests too large or too structured for a URL query string.

The interview angle is the tradeoff against POST /search. QUERY tells caches and clients that the request has no side effects and can be retried, which POST does not.

Proxies, CDNs and client libraries in the path must support the method, so a strong candidate checks the whole chain before adopting it and keeps a POST fallback for clients that cannot send it.

33. Which 2026 changes can break a build or change behavior on upgrade?

  • MSRV: 4.13.0 raised the minimum Rust version to 1.88, and 4.18.0 raised it to 1.89. Teams pinned to an older toolchain stay on 4.12.x.
  • TLS and HTTP/2: since 4.16.0, the TLS crate features no longer enable the http2 feature. A build that relied on rustls-0_23 to get HTTP/2 must add http2 explicitly, or clients silently fall back to HTTP/1.1.
  • Route middleware: the 4.14.0 panic from question 16.
  • io-uring: the experimental experimental-io-uring feature was removed in 4.15.0, and actix-rt dropped its io-uring support at the same time.
  • Form limit and 405s: 4.18.2 doubled the form limit to 32 KiB and returns 405 with Allow where some routes used to return 404, which can change monitoring dashboards and client error handling.

The main branch also enables TCP_NODELAY on accepted connections by default, listed as unreleased in the changelog.

A strong candidate reads the changelog between their locked version and the target before bumping, rather than relying on semver to mean nothing changes.

Signs of a Strong Answer

  • They know the app factory runs once per worker and say where each piece of state is created.
  • They spot blocking calls in handlers and move them to web::block or an async client without being prompted.
  • They give the status code each extractor returns on failure and know how to change it through config types.
  • They read a chain of .wrap() calls bottom-up and place CORS outside authentication.
  • They never send Display output of an internal error to the client, and they log the source error before mapping it.
  • They can name the 4.18.2 security fix and the actix-web-actors deprecation, and they compare Actix and Axum on runtime model rather than benchmarks.

Hiring Rust Backend Engineers

Rust engineers who have run an Actix Web service under load, debugged a stalled worker and upgraded through a breaking minor release are a small pool on general job boards.

Second Talent helps US startups hire vetted AI-Native Engineers from Asia, including Rust developers and back-end developers, screened with questions like these.

For the language itself, see our Rust interview guide, and for the service around it, the backend developer interview guide.

Rates run $23 to $39 USD an hour, billed monthly, with matches in 24 hours and a 90-day one-time replacement guarantee. Compare rates on the cost to hire a Rust developer page, or tell us what you need.

Get developers who ship with AI agents.

Starts at $23/Hour.

Start Hiring

$0 cost until you hire