Skip to content

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.

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.

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 nothing
let n = fut.await; // NOW "fetching..." prints and n == 42

An 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 later

The 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.

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.

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.

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 body
async 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.

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 →

  1. In Rust, what does calling an async fn actually return, and how much work has happened at that point? What finally drives the work?
  2. What does .await do when the future it’s driving isn’t ready yet — and why is it the only place a task can pause?
  3. Give the rough memory argument for why a high-concurrency server prefers async tasks over a thread per connection.
  4. What is an axum extractor? Name two, and say where each gets its data from.
  5. Why does holding a value across an .await point bring the Send trait into the picture — and what bug is that guarding against?
Show answers
  1. 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 .await it, or you hand it to the runtime (e.g. tokio::spawn).
  2. .await yields control back to the runtime so it can poll other tasks; when the awaited future is ready (its Waker fires), the runtime resumes this task where it left off. It’s the only pause point because the compiler turns the async fn into a state machine whose states are exactly the gaps between .awaits — code between them runs to completion uninterrupted.
  3. 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.
  4. 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).
  5. A value held across an .await lives inside the generated state machine, which the runtime may resume on a different OS thread — so that value must be Send (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.