Async Handlers
Write async route handlers using AsyncAppWithState for non-blocking I/O with databases, HTTP clients, and other async libraries.
AsyncAppWithState<S> is the async counterpart to AppWithState<S>. Its handlers are async fn closures that can await database queries, outbound HTTP calls, timers, or any other async I/O.
AsyncAppWithState requires the http2 feature (or the default http3 feature). It is not available in --no-default-features --features http1 builds.
[dependencies]
rust-web-server = { version = "18" } # default features include http2 + http3Creating an AsyncAppWithState
Call AsyncAppWithState::new(state) with your shared state value, then chain .get(), .post(), etc. to register handlers.
use std::sync::Arc;
use rust_web_server::async_state::AsyncAppWithState;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::router::PathParams;
use rust_web_server::request::Request;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::core::New;
struct AppState {
db_url: String,
}
let app = AsyncAppWithState::new(AppState { db_url: "postgres://…".to_string() })
.get("/greet/:name", |_req, params, _conn, state| async move {
let name = params.get("name").unwrap_or("world");
let body = format!("Hello, {}! DB: {}", name, state.db_url);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(body.into_bytes(), MimeType::TEXT_PLAIN.to_string())
];
r
});Handler signature
Async handlers take four owned parameters so the returned future is 'static:
async fn handler(
req: Request, // owned — not a reference
params: PathParams,
conn: ConnectionInfo,
state: Arc<S>,
) -> ResponseThe state is passed as Arc<S> (a cheap clone), keeping all handlers behind shared ownership.
Named handler functions
You can name your handlers instead of using inline closures. This is easier to test and keeps long business logic out of the router call-site:
use std::sync::Arc;
use rust_web_server::async_state::AsyncAppWithState;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
struct Db {
url: String,
}
async fn get_user(
_req: Request,
params: PathParams,
_conn: ConnectionInfo,
state: Arc<Db>,
) -> Response {
let user_id = params.get("id").unwrap_or("0");
// Await an async database call here
// let user = sqlx::query!(...).fetch_one(&pool).await?;
let body = format!("{{\"id\":{}}}", user_id);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
}
let app = AsyncAppWithState::new(Db { url: "sqlite::memory:".to_string() })
.get("/users/:id", get_user);Supported HTTP methods
| Builder method | HTTP method |
|---|---|
.get(pattern, handler) |
GET |
.post(pattern, handler) |
POST |
.put(pattern, handler) |
PUT |
.patch(pattern, handler) |
PATCH |
.delete(pattern, handler) |
DELETE |
Path pattern syntax is identical to the synchronous router: :name matches a single segment, *name matches the rest of the path.
Where handlers run
AsyncAppWithState implements Application, so it fits into Server::run_tls() / Server::run_quic() / Server::run() and behind middleware like any other app. A matching route's handler runs on a long-lived runtime in every case:
- Async servers, nothing synchronous in front (no middleware, or only layers that pass the request through): the server awaits the handler through
Application::execute_asyncon the connection's own runtime — with the default shared runtime, no thread hand-off at all. Under per-core runtimes (RWS_CONFIG_RUNTIME=per-core, single-threaded) it runs on rws's process-widerws-asyncruntime instead and the connection's runtime awaits it. - Everything else — a middleware layer in front (the server then calls
executeon tokio's blocking pool), the HTTP/1.1-only build's thread pool,TestClient::send(), any directexecutecall: the calling thread waits while the handler runs onrws-async(one worker per CPU, orTOKIO_WORKER_THREADS).
Requests no route matches go to the built-in App without touching a runtime.
Because that runtime outlives the request, tasks a handler leaves behind keep running — a DbPool gets its connection back (sqlx returns it from a spawned task) and reuses it on the next request. The caller's tracing span, Identity and early-hints destination stay current inside the handler wherever it runs.
Handlers share worker threads with other requests, so they must not block: use .awaitable I/O, and tokio::task::spawn_blocking for blocking calls.
Up to 18.28, execute ran each handler on a runtime made for the call and dropped after it: tasks left behind never ran, so a PostgreSQL/MySQL DbPool reconnected on every request and DbPool::memory() lost its database between requests.
Custom Applications: event loop and async path
If you implement Application yourself, two optional methods control where the async servers run a request.
runs_inline — the servers hand every request to the blocking pool by default. A request that is quick and never waits can skip that handoff (the built-in App does this for static files and health checks):
use rust_web_server::application::Application;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::server::ConnectionInfo;
#[derive(Clone)]
struct Ping;
impl Application for Ping {
fn execute(&self, _request: &Request, _connection: &ConnectionInfo) -> Result<Response, String> {
Ok(Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "pong"))
}
// In-memory, no I/O: safe to run on the event loop.
fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
true
}
}Return true only for work that never waits on a database, the network or a lock held across I/O — on the event loop that would stall every connection on the same worker thread. Behind middleware, the request runs inline only if every layer opts in too (Middleware::runs_inline).
execute_async (http2 feature) — execute is synchronous, so a blocking-pool thread sits idle while it waits on the network. An application with an async way to answer can offer it; the server awaits it on the connection's own task and falls back to execute when it returns None:
use rust_web_server::application::{Application, AsyncResponse};
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::server::ConnectionInfo;
#[derive(Clone)]
struct Slow;
impl Application for Slow {
fn execute(&self, _request: &Request, _connection: &ConnectionInfo) -> Result<Response, String> {
std::thread::sleep(std::time::Duration::from_millis(50)); // blocks a pool thread
Ok(Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "done"))
}
fn execute_async<'a>(&'a self, _request: &'a Request, _connection: &'a ConnectionInfo) -> Option<AsyncResponse<'a>> {
Some(Box::pin(async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await; // holds no thread
Ok(Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "done"))
}))
}
}Behind middleware, the async path is offered only if every layer passes the request through unchanged (Middleware::is_passthrough); otherwise execute runs as before. The config-driven proxy uses this for plain-HTTP routes without middleware. AsyncAppWithState itself does not use execute_async — its handlers go through the bridge above.
Unmatched routes fall through
Routes that do not match any registered pattern are forwarded to the built-in App controller chain (static files, health checks, metrics, etc.). You only need to register handlers for your application routes.
When to use async handlers
Use AsyncAppWithState when your handlers need to:
- Query an async database driver (e.g.
sqlx,tokio-postgres) - Make outbound HTTP calls via
reqwestorAsyncClient - Wait on async channels or timers
- Call any
async fnfrom a third-party library
For handlers that only do synchronous work (reading headers, JSON parsing, string formatting), the synchronous App::with_state is sufficient and avoids the overhead of the runtime bridge.
Full example — async HTTP call
use std::sync::Arc;
use rust_web_server::async_state::AsyncAppWithState;
use rust_web_server::http_client::AsyncClient;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::core::New;
struct Config {
upstream_url: String,
}
async fn proxy_data(
_req: Request,
_params: PathParams,
_conn: ConnectionInfo,
state: Arc<Config>,
) -> Response {
let result = AsyncClient::new()
.get(&state.upstream_url)
.send()
.await;
match result {
Ok(upstream_resp) => {
let body = upstream_resp.bytes().to_vec();
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(body, MimeType::APPLICATION_JSON.to_string())
];
r
}
Err(_) => {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n502_bad_gateway.reason_phrase.to_string();
r
}
}
}
let app = AsyncAppWithState::new(Config {
upstream_url: "https://api.example.com/data".to_string(),
})
.get("/data", proxy_data);