How to Implement Graceful Shutdown in Async Rust

Implement graceful shutdown in async Rust by using tokio::signal to catch interrupts and broadcast::channel to cancel tasks via tokio::select.

When Ctrl+C isn't enough

You hit Ctrl+C on your production server. The process vanishes instantly. A database transaction halfway through commits gets aborted. A user's upload cuts off at 99%. The logs stop mid-line. Your monitoring alerts scream "Crash" even though you just wanted to restart.

Graceful shutdown fixes this. It gives your tasks a chance to finish what they're doing, close connections cleanly, and exit without leaving a mess. In async Rust, this requires coordination. You can't just kill a task. You have to ask it to stop, and the task has to listen.

Cooperative cancellation

Rust doesn't have a forceful kill switch for async tasks. You can't yank the power cord on a running future. Instead, Rust uses cooperative cancellation.

Think of a busy restaurant kitchen. The manager yells "Last call!" over the intercom. The cooks don't drop their knives and sprint out the door. They finish plating the current order, wipe down their station, and then walk out. If a cook is waiting for the oven, they check the intercom and stop waiting.

Graceful shutdown works the same way. Your main task sends a signal. Your workers listen for that signal. When they hear it, they finish the current operation and exit. The key is that the worker must actively check for the signal. If the worker is stuck in a long operation without checking, it won't hear the shout.

The broadcast channel

The standard tool for this job is tokio::sync::broadcast. It creates a channel where one sender can notify many receivers. This fits shutdown perfectly. You have one source of truth (the main task listening for OS signals) and many workers that need to react.

broadcast differs from other channels in a specific way. You don't clone the receiver. You call subscribe() on the sender to create a new receiver. This is a community convention that pays off. Cloning a receiver would copy the internal state, which can lead to subtle bugs with message lag. subscribe() gives you a fresh handle that starts listening from the next message.

Convention also dictates using () as the message type. The payload doesn't matter. You only care that the signal arrived. Using () makes the intent clear to anyone reading the code.

use tokio::sync::broadcast;

fn setup_shutdown() -> (broadcast::Sender<()>, broadcast::Receiver<()>) {
    // Capacity 1 is sufficient for shutdown.
    // We only need to deliver one signal to trigger the break.
    // If the receiver is slow, the signal stays in the buffer until polled.
    let (tx, rx) = broadcast::channel::<()>(1);
    (tx, rx)
}

Minimal example

Here is the smallest working pattern. It spawns a worker, waits for Ctrl+C, sends the signal, and waits for the worker to finish.

use tokio::signal;
use tokio::sync::broadcast;

#[tokio::main]
async fn main() {
    // Create the channel. Sender lives in main, receiver moves to worker.
    let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(1);

    // Spawn the worker.
    // The receiver is moved into the closure.
    let handle = tokio::spawn(async move {
        loop {
            tokio::select! {
                // Check for shutdown signal.
                // If recv completes, we break the loop.
                _ = shutdown_rx.recv() => {
                    println!("Worker received shutdown. Exiting loop.");
                    break;
                }
                // Simulate work.
                // The worker does a tick, then loops back to check the channel.
                _ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => {
                    println!("Worker tick.");
                }
            }
        }
        println!("Worker cleanup complete.");
    });

    // Block until the user presses Ctrl+C.
    // This is the trigger for the whole shutdown sequence.
    let _ = signal::ctrl_c().await;
    println!("Main received Ctrl+C. Broadcasting shutdown.");

    // Send the signal.
    // Ignore the result. Some receivers might have already dropped,
    // which is fine. We just need to wake up the ones that are still listening.
    let _ = shutdown_tx.send(());

    // Wait for the worker to finish.
    // This ensures the worker completes its cleanup before main exits.
    handle.await.ok();
    println!("Shutdown complete.");
}

How the signal flows

When you run this, the worker enters a loop. Inside the loop, tokio::select! polls both branches. If the sleep finishes first, the worker prints a tick and loops back. If you hit Ctrl+C, signal::ctrl_c().await returns. The main task sends () through the channel. On the next iteration, shutdown_rx.recv() completes. The worker breaks and exits. The main task awaits the handle, ensuring the worker finished before the process ends.

The select! macro is the engine here. It races the work against the signal. As soon as one completes, it runs that branch and discards the other for this iteration. This allows the worker to react to shutdown immediately after finishing the current tick.

Realistic worker pool

Real applications have multiple workers. You need to spawn several tasks, each with its own receiver, and wait for all of them to finish.

use tokio::signal;
use tokio::sync::broadcast;
use std::time::Duration;

/// A worker that processes items and listens for shutdown.
struct Worker {
    id: u32,
    /// Receiver for the shutdown signal.
    /// Mutable because recv() consumes the message.
    shutdown_rx: broadcast::Receiver<()>,
}

impl Worker {
    /// Run the worker loop.
    /// Consumes self to move the receiver into the async context.
    async fn run(mut self) {
        loop {
            // Bias the select towards shutdown.
            // This prevents the work branch from starving the signal
            // if the work branch is always ready.
            tokio::select! {
                biased;

                _ = self.shutdown_rx.recv() => {
                    println!("Worker {} shutting down.", self.id);
                    break;
                }
                _ = tokio::time::sleep(Duration::from_millis(500)) => {
                    println!("Worker {} processing request.", self.id);
                }
            }
        }
        println!("Worker {} cleanup done.", self.id);
    }
}

#[tokio::main]
async fn main() {
    // Create channel with buffer for multiple subscribers.
    let (shutdown_tx, _) = broadcast::channel::<()>(16);

    let mut handles = Vec::new();
    for i in 0..3 {
        // Subscribe creates a new receiver for this worker.
        let rx = shutdown_tx.subscribe();
        let worker = Worker { id: i, shutdown_rx: rx };
        handles.push(tokio::spawn(worker.run()));
    }

    let _ = signal::ctrl_c().await;
    println!("Initiating shutdown...");

    // Send signal to all subscribers.
    let _ = shutdown_tx.send(());

    // Wait for all workers to finish.
    for handle in handles {
        handle.await.ok();
    }
    println!("All workers stopped.");
}

The bias trap

The biased; keyword in the realistic example is not optional decoration. It is a safety mechanism.

By default, tokio::select! picks a ready branch randomly. If your work branch is always ready, select! might keep picking the work branch forever. The shutdown signal sits in the channel, but the worker never checks it. The app hangs.

This happens when the work branch completes instantly. A loop that does nothing but yield, or a channel that always has data, creates this condition. Adding biased; forces select! to check branches in order. The shutdown branch is checked first. If the signal is there, it runs. If not, it checks the work branch. This guarantees the signal is never starved.

Always bias your select loops towards shutdown. A non-biased select can starve your cancellation signal if the work branch is always ready.

Pitfalls and errors

Blocking work blocks shutdown

If your work branch calls a blocking function or a long sleep without checking the channel, your app hangs. The worker won't notice the shutdown signal until the work finishes. You must break long operations into smaller chunks that check the channel between steps.

Handling recv errors

broadcast::Receiver::recv() returns a Result. If the sender is dropped, it returns Err(RecvError::Closed). If the receiver falls too far behind, it returns Err(RecvError::Lagged).

In a shutdown loop, you should treat Closed as a shutdown signal. If the sender vanishes, the app is likely crashing anyway, and your task should stop waiting. Lagged means messages were dropped. For shutdown with capacity 1, lag is unlikely unless you send multiple signals. You can ignore lag or treat it as a signal depending on your tolerance.

// Handle errors explicitly in the select branch.
result = self.shutdown_rx.recv() => {
    match result {
        Ok(_) => break,
        Err(broadcast::error::RecvError::Closed) => break,
        Err(broadcast::error::RecvError::Lagged(_)) => continue,
    }
}

Moving the sender

If you try to move shutdown_tx into multiple tasks, the compiler rejects you with E0382 (use of moved value). The sender is not Clone. You must use subscribe() to create receivers for each task. Keep the sender in the main task or a shared scope.

Forgetting to await handles

If you send the signal and exit main without awaiting the handles, the process terminates. The workers get killed mid-operation. This defeats the purpose of graceful shutdown. Always await the handles after sending the signal.

Treat RecvError::Closed as a shutdown signal. If the sender vanishes, the app is likely crashing anyway, and your task should stop waiting.

Decision matrix

Use tokio::sync::broadcast when you have multiple tasks that need to hear the shutdown signal and you want a simple, low-overhead way to fan out the notification. Use tokio::sync::oneshot when you only have a single task to cancel and don't need the overhead of a broadcast channel. Use tokio_util::sync::CancellationToken when you want a reusable token that can be cloned and cancelled, especially if you are passing cancellation context deep into a call stack without threading channels. Use tokio::select! with a timeout when you need to force-kill a task after a deadline, combining graceful shutdown with a hard cutoff. Reach for signal::ctrl_c() combined with a channel for CLI tools and daemons. Reach for an HTTP signal or admin endpoint for long-running servers where you need programmatic control.

Where to go next