← Table of contents

8.5 Before the Door Closes

The ledger now shows everything. Every request leaves a record: what arrived, what was returned, when and why. One thing cannot be recorded: the moment the ledger stops. Ctrl+C leaves no entry. The process disappears - and in the ledger, whatever came before it. An end without a mark.

What You Need

Ctrl+C sends the process a termination signal. By default the process stops immediately - regardless of what was happening inside. A request in the middle of processing is cut short: the client receives no response, a write to disk may remain incomplete.

Graceful shutdown addresses this in three steps: stop accepting new connections, wait for in-flight requests to finish, exit. Axum supports this directly - you only need to describe when exactly the server should stop.

Reproducing the Problem

To see the problem, you need a request that lives long enough. Add a handler to crates/api/src/routes.rs that intentionally waits 30 seconds:

use std::time::Duration;
use tokio::time::sleep;

async fn slow() -> &'static str {
    sleep(Duration::from_secs(30)).await;
    "done\n"
}

And in the router function - the route:

.route("/slow", get(slow))

Open two terminals. In the first - start the server:

$ make serve
2026-06-25T10:00:00Z  INFO tq_api: listening on 0.0.0.0:3000

In the second - send a request:

$ curl localhost:3000/slow

Return to the first terminal and press Ctrl+C. In the second:

curl: (52) Empty reply from server

The server disappears. The connection drops. The request vanishes without a trace.

The Build

In crates/api/src/main.rs - a new signal function:

async fn shutdown_signal() {
    tokio::signal::ctrl_c().await.unwrap();
    tracing::info!("shutdown signal received");
}

And at the end of main, replacing axum::serve(listener, app).await.unwrap():

axum::serve(listener, app)
    .with_graceful_shutdown(shutdown_signal())
    .await
    .unwrap();
tracing::info!("server stopped");

tokio::signal::ctrl_c() waits for Ctrl+C and completes exactly once. .with_graceful_shutdown(shutdown_signal()) tells the server: when shutdown_signal() completes - stop accepting new connections, wait for current requests to finish, and only then exit. The tracing::info!("server stopped") line executes when the server has actually stopped - not before.

The Result

Repeat the scenario from Reproducing the Problem: make serve in one window, curl localhost:3000/slow in the second, Ctrl+C in the first. Now in the second window - not a dropped connection, but a response:

$ curl localhost:3000/slow
done

And in the first window the log shows the full path to the end:

2026-06-25T10:00:00Z  INFO tq_api: listening on 0.0.0.0:3000
^C
2026-06-25T10:00:05Z  INFO tq_api: shutdown signal received
2026-06-25T10:00:35Z  INFO tq_api: server stopped

The ledger now has a mark for the end. make ci passes.

The complete tq code for this chapter is in 8-ready/05-before-the-door-closes/.


Lore: Signals

Ctrl+C is not just a key combination. The terminal converts it to SIGINT - an operating system signal sent to the process. Without handling it, the process terminates immediately.

tokio::signal::ctrl_c() intercepts this signal - instead of immediate death, the process waits for it through .await and decides itself what to do next. with_graceful_shutdown uses that opportunity: finish reading, finish writing, close connections.

Lore: When It Matters

/slow is a synthetic example: real requests take milliseconds, and catching one at the moment of shutdown alone is difficult. Under high load this stops being rare - at a thousand requests per second several are always in flight. Without graceful shutdown, every restart drops some of them.