How to handle graceful shutdown in Rust web server

Web
Implement graceful shutdown in Rust by using a shared flag to stop the server loop and join threads after receiving an interrupt signal.

The Ctrl+C that breaks everything

You hit Ctrl+C to stop your server. A client is halfway through uploading a large file. The connection drops mid-stream. The file on disk is corrupted. A database transaction commits half its changes and then the process vanishes. The user sees a 502 error and retries, doubling the load. This happens when you kill a server abruptly. Graceful shutdown stops new work, finishes in-flight requests, and cleans up resources before the process exits. It turns a chaotic crash into a controlled stop.

The restaurant closing analogy

Think of a restaurant closing for the night. The host stops putting new names on the waiting list. The waiters finish serving the tables currently eating. The kitchen finishes cooking the orders already on the pass. No new work enters the system. Existing work completes. Then the lights go out and the staff leaves.

In Rust, the host is your signal handler. The waiters are your worker threads or async tasks. The kitchen is your database or file I/O. Graceful shutdown coordinates these roles so nothing gets left hanging. You need a way to tell the host to stop seating, a way to tell the waiters to finish their current table, and a way to wait until everyone is done before you lock the door.

Minimal signal handling

The core mechanism is simple. You listen for an OS signal, set a flag, and check that flag in your main loop. The ctrlc crate handles the cross-platform signal listening. AtomicBool provides a thread-safe flag without the overhead of a mutex.

use std::net::TcpListener;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    // Bind to a local address. unwrap() is fine for a demo; production code handles errors.
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    
    // AtomicBool avoids the overhead of a Mutex for a simple flag.
    // Arc allows sharing this flag across threads without cloning the data.
    let shutdown = Arc::new(AtomicBool::new(false));
    let shutdown_clone = shutdown.clone();
    
    // Spawn a thread to wait for Ctrl+C.
    // The closure takes ownership of shutdown_clone so it can move into the handler.
    thread::spawn(move || {
        ctrlc::set_handler(move || {
            // Signal the main loop to stop.
            // SeqCst ensures the write is visible to other threads immediately.
            shutdown_clone.store(true, Ordering::SeqCst);
        }).unwrap();
    });
    
    // Accept connections in a loop.
    for stream in listener.incoming() {
        // Check the flag before accepting.
        // If true, break the loop and exit main.
        if shutdown.load(Ordering::SeqCst) {
            break;
        }
        
        match stream {
            Ok(_stream) => println!("Connection established!"),
            Err(e) => eprintln!("Error: {}", e),
        }
    }
    
    println!("Shutting down gracefully...");
}

How the pieces fit together

The Arc wraps the flag so multiple threads can read it. Arc stands for Atomic Reference Counted. It keeps a count of how many Arc instances point to the data. When the count drops to zero, the data is freed. This is essential because the signal handler runs in a different thread and needs access to the same flag.

AtomicBool provides thread-safe reads and writes without locking. A Mutex<bool> would work, but it requires acquiring a lock every time you check the flag. Atomic operations are faster and avoid the risk of deadlocks. The Ordering::SeqCst parameter guarantees sequential consistency. This means the write in the signal handler is visible to the main loop immediately, without CPU reordering surprises.

The ctrlc::set_handler function installs a callback for SIGINT. When you press Ctrl+C, the callback runs in a separate context. The callback sets the flag to true. The main loop checks the flag on every iteration. Once the flag is true, the loop breaks. main returns, and the process exits.

The thread::spawn call is critical. The ctrlc crate often spawns its own thread to wait for signals. If you drop the thread handle immediately, the signal handling might stop working depending on the platform. Keeping the thread alive ensures the handler stays registered.

Convention aside: The community prefers Arc::clone(&flag) over flag.clone() for reference counted types. Both compile and both work. The explicit form signals to readers that you are cloning the pointer, not the underlying data. It prevents confusion with deep clones.

Don't use a Mutex for a boolean flag. Atomics are faster and safer here.

Realistic server with worker draining

The minimal example breaks the accept loop, but it doesn't wait for active connections to finish. If a client is currently being served, that connection gets dropped the moment main returns. A realistic server needs to stop accepting new connections and wait for existing handlers to complete.

use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

/// Handle a single client connection.
/// In a real server, this would read requests and write responses.
fn handle_connection(stream: TcpStream) {
    // Simulate work that takes time.
    // A real handler would loop until the client disconnects.
    thread::sleep(Duration::from_millis(100));
    println!("Handled connection.");
}

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let shutdown = Arc::new(AtomicBool::new(false));
    let shutdown_clone = shutdown.clone();

    // Install the signal handler.
    ctrlc::set_handler(move || {
        shutdown_clone.store(true, Ordering::SeqCst);
    }).unwrap();

    // Collect thread handles to wait for them later.
    let mut handles = vec![];

    // Accept connections in a loop.
    for stream in listener.incoming() {
        // Check the flag before accepting.
        if shutdown.load(Ordering::SeqCst) {
            break;
        }
        
        match stream {
            Ok(stream) => {
                // Spawn a handler for this connection.
                // The stream is moved into the thread.
                handles.push(thread::spawn(move || {
                    handle_connection(stream);
                }));
            }
            Err(e) => eprintln!("Error: {}", e),
        }
    }

    // Wait for in-flight connections to finish.
    println!("Stopping new connections. Waiting for active handlers...");
    for handle in handles {
        // join() blocks until the thread finishes.
        // unwrap() propagates panics from worker threads.
        handle.join().unwrap();
    }
    
    println!("All handlers finished. Goodbye.");
}

This pattern separates the accept loop from the worker threads. The accept loop checks the shutdown flag and breaks when it's set. The worker threads run independently. After the loop breaks, the code iterates over the collected handles and calls join. join blocks the main thread until the worker thread completes. This ensures all in-flight requests finish before the process exits.

Convention aside: When calling handle.join(), the community usually calls .unwrap() on the result. If a worker thread panics, join returns an error. Unwrapping propagates that panic to the main thread, making debugging easier. Discarding the result with let _ = handle.join() swallows the panic and hides bugs.

Join your worker threads before exiting. If you skip this, your workers get killed mid-request and the client sees a broken pipe.

Pitfalls and compiler errors

Signal handling introduces subtle traps. The most common is a deadlock in the signal handler. Signal handlers run asynchronously. If your handler tries to acquire a lock that the main thread holds, the program hangs forever. The handler waits for the lock. The main thread waits for the handler to finish. Neither can proceed.

Use AtomicBool or channels to avoid this. Never lock a Mutex inside a signal handler. The risk of deadlock is too high.

Another trap is move errors. If you try to capture a non-Clone value in the signal handler closure, the compiler rejects you with E0382 (use of moved value). The closure must own its captured variables or share them via Arc. If you see E0382, wrap the shared state in Arc and clone the Arc into the closure.

Signal safety is another concern. On Unix, signal handlers run in a restricted environment. You can only call async-signal-safe functions. ctrlc handles this by spawning a thread and using safe primitives, but if you write your own handler, you must be careful. Printing to stdout inside a signal handler can corrupt output or deadlock. Keep the handler tiny. Set a flag and return.

Signal handlers are async traps. Keep them tiny. Set a flag and get out.

When to use what

Use ctrlc for synchronous servers when you need a lightweight, cross-platform signal handler without an async runtime. Use tokio::signal::ctrl_c when your server runs on Tokio; it yields an async future that cancels cleanly without blocking the event loop. Use AtomicBool for the shutdown flag when you need thread-safe communication with minimal overhead and zero deadlock risk. Use mpsc::channel when you must coordinate complex shutdown sequences where workers need to acknowledge the signal before proceeding. Reach for std::process::exit only when you need an immediate kill; graceful shutdown is almost always better for data integrity.

Where to go next