Day 8 · Async & axum
The Project 3 overview promised a server that juggles hundreds of requests at once. The naive way to do that is one OS thread per connection — and it falls over at scale. This day is about the model that replaces it: async. We’ll build the smallest possible axum server, then add routing, handlers, and JSON. But first, the one idea that makes async make sense.
Why async at all
Section titled “Why async at all”A web server spends almost all its time waiting — for the network, for the database, for the disk. While one request waits on the database, the CPU is idle and could be serving a thousand others. The question is: how do you keep going during the wait?
The old answer is a thread per connection. It works, but threads are expensive: each one reserves a chunk of stack (Rust’s default for a spawned thread is 2 MiB of address space), and the OS has to context-switch between them. Ten thousand idle connections means ten thousand mostly-sleeping threads — a lot of memory reserved to do nothing.
The async answer: run many tasks on a few threads, and whenever a task would block on I/O, set it aside and run a different one. A handful of OS threads stay busy; the waiting happens cooperatively.
Futures are lazy — the one surprise
Section titled “Futures are lazy — the one surprise”Here is the thing that trips up everyone arriving from other languages. In JavaScript or Python,
calling an async function starts the work. In Rust it does not.
async fn fetch() -> u32 { println!("fetching..."); 42}
let fut = fetch(); // nothing prints! `fut` is a Future, inert, doing nothinglet n = fut.await; // NOW "fetching..." prints and n == 42An async fn returns a Future: a value that describes work but hasn’t done any. It does
nothing until something drives it — either you .await it, or you hand it to the runtime with
tokio::spawn. This laziness is what lets the runtime compose thousands of futures and decide when
each makes progress.
.await is the other half: it means “drive this future, and if it isn’t ready, yield — let the
runtime run someone else until it is.” Crucially, .await is the only place a task can pause. Code
between two .await points runs straight through, uninterrupted.
Under the hood — what async/.await compiles to
Section titled “Under the hood — what async/.await compiles to”An async fn is not a function the runtime calls repeatedly. The compiler rewrites it into a state
machine — an enum whose variants are “the points between .awaits.” The runtime drives it through
one trait:
trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;}// poll returns either:// Poll::Ready(value) → done, here's the result// Poll::Pending → not yet; I've registered a Waker, call me again laterThe runtime (tokio) is an executor: it polls a task’s future. If the future returns Pending
(say, waiting on a socket), tokio parks the task and runs another. When the socket is readable, a
Waker notifies tokio to poll that task again — which resumes the state machine exactly where
it left off. No thread was blocked; the wait cost nothing but a parked struct.
This is why .await points matter for the borrow checker: a value held across an .await lives
inside that generated state machine, so it must survive a pause and a possible move to another thread.
That’s where Send shows up — and where the compiler starts protecting you.
A first axum server
Section titled “A first axum server”axum is built on tokio. The smallest server is three moves: build a Router, bind a listener, serve.
use axum::{routing::get, Router};
#[tokio::main]async fn main() { let app = Router::new().route("/", get(root));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); axum::serve(listener, app).await.unwrap();}
async fn root() -> &'static str { "hello from apilite"}#[tokio::main] is a macro that builds the runtime and runs your async fn main on it — every
.await below it is driven by that runtime. axum::serve accepts connections forever, spawning a
task per request and routing each to a handler.
Routing and handlers
Section titled “Routing and handlers”A Router maps a path to a set of HTTP methods, each pointing at a handler — an async fn. You
compose methods on one path with .post(), .put(), etc.:
let app = Router::new() .route("/health", get(health)) .route("/notes", get(list_notes).post(create_note)) .route("/notes/{id}", get(get_note).delete(delete_note));A handler is “just” an async fn whose arguments are extractors — types axum knows how to build
from the incoming request. You don’t parse the request yourself; you declare what you need in the
signature and axum supplies it:
Path<i64> ← a {id} segment from the URL path Query<Params> ← parsed ?key=value query string Json<CreateNote> ← the request body, deserialized from JSON State<AppState> ← shared application state (Day 9)The return type is anything implementing IntoResponse — &str, String, a status code, a tuple
like (StatusCode, Json<T>), or your own error type (Day 10). The framework meets you at the type
system: declare the right types and the wiring is generated for you.
JSON via serde
Section titled “JSON via serde”serde turns your structs into JSON and back, derived — no hand-written parsing. Tag a struct
Serialize to send it out, Deserialize to read it in:
use serde::{Deserialize, Serialize};use axum::Json;
#[derive(Serialize)]struct Note { id: i64, title: String }
#[derive(Deserialize)]struct CreateNote { title: String }
// extract a JSON body, return a JSON bodyasync fn create_note(Json(input): Json<CreateNote>) -> Json<Note> { Json(Note { id: 1, title: input.title })}Json<T> is an extractor and a response: as an argument it deserializes the request body (and
auto-replies 400 if the JSON is malformed, or 422 if it’s valid JSON that doesn’t match your
struct); as a return value it serializes T and sets the
content-type. Separating Note (what you send) from CreateNote (what you accept) is deliberate —
it stops a client from ever setting fields like id that the server owns. We lean on that in Day 9.
The thread
Section titled “The thread”What does building this force you to understand — and what is Rust’s compiler protecting you from?
Async makes the answer concrete. Because a task can pause at an .await and resume on a different
thread, any value held across that pause must be safe to send between threads — that’s the Send
bound axum requires of your handlers’ futures. Forget it (say, by holding an Rc or a raw pointer
across an .await) and the compiler stops you before that future ever races. The lazy-future model
isn’t just an efficiency trick; it’s the structure that lets “shared XOR mutable” extend cleanly from
one thread to thousands of cooperating tasks.
Next: give every handler access to a database. Day 9 · State & sqlx →
Check your understanding
Section titled “Check your understanding”- In Rust, what does calling an
async fnactually return, and how much work has happened at that point? What finally drives the work? - What does
.awaitdo when the future it’s driving isn’t ready yet — and why is it the only place a task can pause? - Give the rough memory argument for why a high-concurrency server prefers async tasks over a thread per connection.
- What is an axum extractor? Name two, and say where each gets its data from.
- Why does holding a value across an
.awaitpoint bring theSendtrait into the picture — and what bug is that guarding against?
Show answers
- It returns a
Future— an inert value describing the work; no work has run yet (futures are lazy). The work only happens when something drives the future: you.awaitit, or you hand it to the runtime (e.g.tokio::spawn). .awaityields control back to the runtime so it can poll other tasks; when the awaited future is ready (itsWakerfires), the runtime resumes this task where it left off. It’s the only pause point because the compiler turns theasync fninto a state machine whose states are exactly the gaps between.awaits — code between them runs to completion uninterrupted.- A thread reserves megabytes of stack and must be scheduled by the OS; an async task is a small state-machine struct (often well under a KiB). At ~10,000 connections that’s roughly tens of GiB of thread stacks versus a few MiB of tasks — about a 1000× difference in per-connection cost.
- An extractor is a type axum knows how to construct from the incoming request, supplied as a
handler argument. Examples:
Path<i64>(a URL path segment like{id}),Json<T>(the request body, deserialized),Query<T>(the parsed query string),State<T>(shared app state). - A value held across an
.awaitlives inside the generated state machine, which the runtime may resume on a different OS thread — so that value must beSend(safe to move between threads). The bound guards against data races: it’s the compiler refusing to let non-thread-safe data cross a thread boundary.