Guide contents

Lesson 88 of 94

Async Pitfalls in Production

Async Rust code that compiles is memory-safe and free of data races. It can still hang, stall, lose work, or grow without bound. Those bugs don't show up in unit tests, only under production load.

This lesson collects the failure patterns experienced teams run into, each with a runnable example, how to recognize it, and how to fix it. It builds on Async Rust, Tokio Patterns, and The Runtime & Async Executors.

Pitfall Symptom in production Fix
Blocking call in async code latency spikes for unrelated requests, timeouts under load spawn_blocking, rayon, async APIs
std::sync::Mutex held across .await "future is not Send", or a stalled worker release before .await, or tokio::sync::Mutex
Non-cancel-safe future in select! lost messages or bytes, rarely and randomly create the future once, or use cancel-safe operations
Request future dropped midway half-finished side effects spawn work that must complete; idempotent steps
Unbounded channel or spawning memory grows until OOM kill bounded channels, semaphores, buffer_unordered
Dropped JoinHandle tasks that live forever, silent failures JoinSet, abort on drop, supervise
Future not awaited the work never happens #[must_use] warnings, -D warnings
Huge futures stack overflows, slow moves Box::pin large parts, clippy::large_futures
No timeouts stuck requests and exhausted connection pools tokio::time::timeout on every remote call

Part 1: Blocking the runtime

1. One blocking call stalls every task on that worker

Tokio runs many tasks on a few worker threads and can only switch tasks at .await (The Runtime & Async Executors). A task that calls a blocking function keeps its worker for the whole call, and every other task queued on that worker waits. With one worker, the effect is easy to see:

Rust
use std::time::{Duration, Instant};

async fn handler_blocking(id: u32) -> u32 {
    std::thread::sleep(Duration::from_millis(100)); // blocks the worker thread
    id
}

async fn handler_async(id: u32) -> u32 {
    tokio::time::sleep(Duration::from_millis(100)).await; // yields the worker
    id
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let start = Instant::now();
    let tasks: Vec<_> = (0..5).map(|i| tokio::spawn(handler_blocking(i))).collect();
    for t in tasks {
        t.await.unwrap();
    }
    let blocking = start.elapsed();

    let start = Instant::now();
    let tasks: Vec<_> = (0..5).map(|i| tokio::spawn(handler_async(i))).collect();
    for t in tasks {
        t.await.unwrap();
    }
    let non_blocking = start.elapsed();

    println!("5 blocking handlers took at least 500 ms: {}", blocking >= Duration::from_millis(500));
    println!("5 async handlers took under 200 ms: {}", non_blocking < Duration::from_millis(200));
}
Output
5 blocking handlers took at least 500 ms: true
5 async handlers took under 200 ms: true

On the multi-thread runtime, the effect is diluted but not gone: a service with 8 workers and 8 requests blocked in a slow call serves nobody else.

2. Where blocking hides

Blocking code rarely looks like thread::sleep. These are the usual suspects:

Looks harmless Why it blocks Async alternative
std::fs::read_to_string synchronous file I/O tokio::fs, or spawn_blocking for many operations
"db.internal:5432".to_socket_addrs() synchronous DNS lookup tokio::net::lookup_host, or let the client resolve
reqwest::blocking, postgres (sync), diesel synchronous network drivers the async client, or spawn_blocking
std::sync::Mutex::lock on a contended lock waits for another thread keep critical sections tiny, or tokio::sync::Mutex
serde_json::from_slice on a 50 MB body CPU-bound for tens of milliseconds spawn_blocking or rayon
bcrypt/argon2 password hashing, compression, image resizing CPU-bound by design spawn_blocking or rayon
println! to a slow terminal or pipe, log with a synchronous appender blocking writes non-blocking writer (tracing-appender)
std::sync::mpsc::Receiver::recv, Condvar::wait, thread::join wait for another thread tokio::sync channels, oneshot, JoinHandle

A useful rule of thumb from the Tokio team: code should not go more than 10 to 100 microseconds without reaching an .await.

3. Detecting a blocked runtime with a heartbeat task

In production, the tell-tale sign is scheduling lag: tasks that should run every few milliseconds run late. A heartbeat task that measures its own lateness turns that into a metric you can alert on.

Rust
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};

async fn heartbeat(max_lag_ms: Arc<AtomicU64>) {
    let period = Duration::from_millis(10);
    loop {
        let before = Instant::now();
        tokio::time::sleep(period).await;
        let lag = before.elapsed().saturating_sub(period);
        max_lag_ms.fetch_max(lag.as_millis() as u64, Ordering::Relaxed);
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let max_lag = Arc::new(AtomicU64::new(0));
    let hb = tokio::spawn(heartbeat(Arc::clone(&max_lag)));
    tokio::time::sleep(Duration::from_millis(50)).await;
    println!("lag with a healthy runtime under 20 ms: {}", max_lag.load(Ordering::Relaxed) < 20);

    // A request handler that parses a big payload synchronously:
    tokio::spawn(async {
        std::thread::sleep(Duration::from_millis(150)); // stands in for 150 ms of CPU work
    })
    .await
    .unwrap();
    tokio::time::sleep(Duration::from_millis(30)).await;
    println!("lag after a blocking task at least 100 ms: {}", max_lag.load(Ordering::Relaxed) >= 100);
    hb.abort();
}
Output
lag with a healthy runtime under 20 ms: true
lag after a blocking task at least 100 ms: true

Export the lag as a gauge and alert on it. To find which task blocks, use tokio-console (it flags tasks that were busy for a long time in one poll), or a CPU profile of the worker threads.

4. Moving CPU work off the runtime

For CPU-heavy work, spawn_blocking works but its pool allows up to 512 threads, far more than you have cores. A bounded CPU pool such as rayon, bridged back to async with a oneshot channel, keeps the async workers free and the CPU work at one thread per core.

Rust
use tokio::sync::oneshot;

fn expensive_checksum(data: &[u8]) -> u64 {
    data.iter().fold(0xcbf29ce484222325u64, |h, &b| (h ^ b as u64).wrapping_mul(0x100000001b3))
}

/// Runs `f` on rayon's thread pool and awaits the result without blocking.
async fn run_cpu<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
    let (tx, rx) = oneshot::channel();
    rayon::spawn(move || {
        let _ = tx.send(f());
    });
    rx.await.expect("the rayon task panicked")
}

#[tokio::main]
async fn main() {
    let payloads: Vec<Vec<u8>> = (0..8).map(|i| vec![i as u8; 2_000_000]).collect();
    let handles: Vec<_> = payloads.into_iter().map(|p| tokio::spawn(run_cpu(move || expensive_checksum(&p)))).collect();
    let mut sums = Vec::new();
    for h in handles {
        sums.push(h.await.unwrap());
    }
    println!("{} checksums, all distinct: {}", sums.len(), sums.iter().collect::<std::collections::HashSet<_>>().len() == sums.len());
}
Output
8 checksums, all distinct: true
Work Put it on
waiting for sockets, timers, channels the async runtime
blocking I/O (files, sync drivers) spawn_blocking
CPU-bound computation rayon (or a dedicated, bounded thread pool)
a long-lived blocking loop (a sync consumer) its own std::thread

Part 2: Locks and .await

5. A std::sync::Mutex guard held across .await

Holding a std::sync::MutexGuard across an .await is a bug twice over: the lock stays held while the task is suspended (so other threads block on it), and the guard isn't Send, so the future can't move between workers. tokio::spawn rejects it:

Rust✗ does not compile
use std::sync::{Arc, Mutex};

async fn save(_v: u64) {}

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0u64));
    let c = Arc::clone(&counter);
    tokio::spawn(async move {
        let mut n = c.lock().unwrap();
        *n += 1;
        save(*n).await; // the guard is still alive here
    });
}
Output
error: future cannot be sent between threads safely
   --> src/main.rs:9:5
    |
  9 | /     tokio::spawn(async move {
 10 | |         let mut n = c.lock().unwrap();
 11 | |         *n += 1;
 12 | |         save(*n).await; // the guard is still alive here
 13 | |     });
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@src/main.rs:9:18: 9:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, u64>`
note: future is not `Send` as this value is used across an await
   --> src/main.rs:12:18
    |
 10 |         let mut n = c.lock().unwrap();
    |             ----- has type `std::sync::MutexGuard<'_, u64>` which is not `Send`
 11 |         *n += 1;
 12 |         save(*n).await; // the guard is still alive here
    |                  ^^^^^ await occurs here, with `mut n` maybe used later
note: required by a bound in `tokio::spawn`
   --> ~/.cargo/registry/src/.../tokio-1.53.1/src/task/spawn.rs:176:21
    |
174 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
175 |     where
176 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`

6. Fix: release the lock before .await, or use an async mutex

Most of the time the fix is to copy what you need out of the lock in a small block, and await afterwards. When the lock really must be held across an .await (serializing access to a connection, for example), use tokio::sync::Mutex, whose guard is Send and whose lock waits asynchronously.

Rust
use std::sync::{Arc, Mutex};
use std::time::Duration;

async fn save(v: u64) -> u64 {
    tokio::time::sleep(Duration::from_millis(1)).await;
    v
}

#[tokio::main]
async fn main() {
    // Fix 1: std Mutex, guard dropped before the .await.
    let counter = Arc::new(Mutex::new(0u64));
    let tasks: Vec<_> = (0..10)
        .map(|_| {
            let c = Arc::clone(&counter);
            tokio::spawn(async move {
                let value = {
                    let mut n = c.lock().unwrap();
                    *n += 1;
                    *n
                }; // guard dropped here
                save(value).await
            })
        })
        .collect();
    for t in tasks {
        t.await.unwrap();
    }
    println!("std mutex: {}", counter.lock().unwrap());

    // Fix 2: tokio Mutex, when the critical section itself must await.
    let conn = Arc::new(tokio::sync::Mutex::new(Vec::<u64>::new()));
    let tasks: Vec<_> = (0..10)
        .map(|i| {
            let conn = Arc::clone(&conn);
            tokio::spawn(async move {
                let mut log = conn.lock().await;
                let saved = save(i).await; // allowed: the guard is Send
                log.push(saved);
            })
        })
        .collect();
    for t in tasks {
        t.await.unwrap();
    }
    println!("tokio mutex: {} writes", conn.lock().await.len());
}
Output
std mutex: 10
tokio mutex: 10 writes

Prefer the std mutex for plain data with short critical sections: it's faster, and the compiler stops you from holding it across .await in spawned tasks. Note that the compiler only checks Send; on a current-thread runtime or inside block_on, holding a std guard across .await compiles and can deadlock. Clippy's await_holding_lock lint catches it everywhere.

7. Actors: avoiding shared locks entirely

For state that many tasks update, an actor often beats any mutex: one task owns the state, and others send it messages. There's no lock to hold across .await, and requests are processed in order.

Rust
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};

enum Command {
    Add { user: String, amount: i64 },
    Balance { user: String, reply: oneshot::Sender<i64> },
}

async fn ledger_actor(mut inbox: mpsc::Receiver<Command>) {
    let mut balances: HashMap<String, i64> = HashMap::new(); // owned by this task only
    while let Some(cmd) = inbox.recv().await {
        match cmd {
            Command::Add { user, amount } => *balances.entry(user).or_default() += amount,
            Command::Balance { user, reply } => {
                let _ = reply.send(balances.get(&user).copied().unwrap_or(0));
            }
        }
    }
}

#[tokio::main]
async fn main() {
    let (tx, rx) = mpsc::channel(64);
    let actor = tokio::spawn(ledger_actor(rx));

    let writers: Vec<_> = (0..4)
        .map(|_| {
            let tx = tx.clone();
            tokio::spawn(async move {
                for _ in 0..25 {
                    tx.send(Command::Add { user: "ana".into(), amount: 10 }).await.unwrap();
                }
            })
        })
        .collect();
    for w in writers {
        w.await.unwrap();
    }

    let (reply, answer) = oneshot::channel();
    tx.send(Command::Balance { user: "ana".into(), reply }).await.unwrap();
    println!("ana: {}", answer.await.unwrap());

    drop(tx); // closing the channel stops the actor
    actor.await.unwrap();
}
Output
ana: 1000

Part 3: Cancellation

8. select! drops the losing branches, and their progress

tokio::select! polls several futures and, as soon as one completes, drops the others. If a dropped future had already done part of its work (taken an item from a queue, read some bytes), that work is lost. In a loop, this happens on every iteration where the other branch wins.

Rust
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Duration;

type Queue = Arc<Mutex<VecDeque<u32>>>;

/// NOT cancel-safe: it removes the job first, then awaits.
async fn take_and_process(queue: Queue) -> Option<u32> {
    let job = queue.lock().unwrap().pop_front()?;
    tokio::time::sleep(Duration::from_millis(30)).await; // "process" the job
    Some(job)
}

#[tokio::main]
async fn main() {
    let queue: Queue = Arc::new(Mutex::new((1..=5).collect()));
    let mut ticker = tokio::time::interval(Duration::from_millis(10));
    let mut done = Vec::new();

    loop {
        tokio::select! {
            job = take_and_process(Arc::clone(&queue)) => match job {
                Some(j) => done.push(j),
                None => break,
            },
            _ = ticker.tick() => {} // e.g. report metrics every 10 ms
        }
    }
    println!("completed jobs: {done:?}");
    println!("left in queue: {:?}", queue.lock().unwrap());
}
Output
completed jobs: []
left in queue: []

Five jobs went in and none came out. Processing a job takes 30 ms and the ticker fires every 10 ms, so the ticker always won. Each time, the take_and_process future had already popped a job and was sleeping; select! dropped it and the job vanished. Nothing crashed and nothing was logged. In production this shows up as rare, unexplained data loss.

9. Fixing it: keep one future alive across iterations

There are two fixes. Make the operation cancel-safe (do the irreversible step last, after every .await), or create the future once, outside the loop, and poll the same future in every iteration so it's never dropped halfway.

Rust
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Duration;

type Queue = Arc<Mutex<VecDeque<u32>>>;

async fn take_and_process(queue: Queue) -> Option<u32> {
    let job = queue.lock().unwrap().pop_front()?;
    tokio::time::sleep(Duration::from_millis(30)).await;
    Some(job)
}

#[tokio::main]
async fn main() {
    let queue: Queue = Arc::new(Mutex::new((1..=5).collect()));
    let mut ticker = tokio::time::interval(Duration::from_millis(10));
    let mut done = Vec::new();
    let mut ticks = 0;

    let mut current = Box::pin(take_and_process(Arc::clone(&queue)));
    loop {
        tokio::select! {
            job = &mut current => match job { // poll the same future: it keeps its progress
                Some(j) => {
                    done.push(j);
                    current = Box::pin(take_and_process(Arc::clone(&queue))); // start the next one
                }
                None => break,
            },
            _ = ticker.tick() => ticks += 1,
        }
    }
    println!("completed jobs: {done:?}");
    println!("ticker still ran: {}", ticks > 5);
}
Output
completed jobs: [1, 2, 3, 4, 5]
ticker still ran: true

&mut current polls the future without moving it into select!, so losing the race only pauses it. (tokio::pin! does the same on the stack for a future that never needs to be replaced.)

10. Which operations are cancel-safe?

Tokio documents cancel safety on each method. The ones that come up constantly:

Operation Cancel-safe? If cancelled
mpsc::Receiver::recv, broadcast::Receiver::recv yes no message was taken
mpsc::Sender::send yes, but the message wasn't sent and is dropped
AsyncReadExt::read yes no data was read
AsyncBufReadExt::lines() then next_line yes no line was consumed
AsyncReadExt::read_exact, read_to_end no some bytes may have been read and lost
AsyncBufReadExt::read_line no partial line lost
AsyncWriteExt::write_all no an unknown prefix was written
tokio::time::sleep, Interval::tick yes nothing happens
your own async fn only if you made it so whatever it had done before its current .await

The rule for select! in a loop: every branch must be cancel-safe, or be a future created outside the loop.

11. Cancelled requests leave half-finished work

The same problem exists without select!. When a client disconnects or a timeout fires, the web framework drops the request handler's future. Everything after the current .await never runs, including the second half of a two-step update.

Rust
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::timeout;

#[derive(Default, Debug)]
struct Accounts {
    alice: i64,
    bob: i64,
}

async fn slow_network_call() {
    tokio::time::sleep(Duration::from_millis(100)).await;
}

async fn transfer(accounts: Arc<Mutex<Accounts>>, amount: i64) {
    accounts.lock().unwrap().alice -= amount;
    slow_network_call().await; // e.g. notify a fraud-check service
    accounts.lock().unwrap().bob += amount; // never runs if the future is dropped above
}

#[tokio::main]
async fn main() {
    let accounts = Arc::new(Mutex::new(Accounts { alice: 100, bob: 0 }));

    // The client gives up after 20 ms, so the handler future is dropped.
    let result = timeout(Duration::from_millis(20), transfer(Arc::clone(&accounts), 30)).await;
    println!("timed out: {}", result.is_err());
    println!("after a cancelled request: {:?}", accounts.lock().unwrap());

    // Fix: run work that must complete as its own task; the request only waits for it.
    let accounts = Arc::new(Mutex::new(Accounts { alice: 100, bob: 0 }));
    let job = tokio::spawn(transfer(Arc::clone(&accounts), 30));
    let result = timeout(Duration::from_millis(20), async { job.await.unwrap() }).await;
    println!("timed out: {}", result.is_err());
    tokio::time::sleep(Duration::from_millis(150)).await; // the spawned task finishes on its own
    println!("with a spawned task:        {:?}", accounts.lock().unwrap());
}
Output
timed out: true
after a cancelled request: Accounts { alice: 70, bob: 0 }
timed out: true
with a spawned task:        Accounts { alice: 70, bob: 30 }

A spawned task isn't cancelled when the JoinHandle (or a future awaiting it) is dropped, so the transfer completes. Better still, design multi-step operations so they can be interrupted at any .await: a single database transaction, idempotent steps with a retry, or an outbox table that a background worker completes.

Part 4: Unbounded growth

12. Unbounded channels hide overload until memory runs out

An unbounded channel never makes the sender wait. When the consumer is slower than the producer, the queue grows until the process is killed. A bounded channel applies back-pressure: send waits when the channel is full, which slows the producer to the consumer's pace.

Rust
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    // Unbounded: the producer finishes immediately, everything piles up in memory.
    let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();
    for _ in 0..10_000 {
        tx.send(vec![0u8; 1024]).unwrap();
    }
    println!("unbounded: {} messages ({} KiB) waiting", rx.len(), rx.len());
    while rx.try_recv().is_ok() {}

    // Bounded: at most 100 messages in flight, the producer waits for the consumer.
    let (tx, mut rx) = mpsc::channel::<Vec<u8>>(100);
    let max_seen = Arc::new(AtomicUsize::new(0));
    let seen = Arc::clone(&max_seen);
    let consumer = tokio::spawn(async move {
        let mut n = 0;
        while let Some(_msg) = rx.recv().await {
            seen.fetch_max(rx.len(), Ordering::Relaxed);
            n += 1;
            if n % 1000 == 0 {
                tokio::time::sleep(Duration::from_millis(1)).await; // a slow consumer
            }
        }
        n
    });
    for _ in 0..10_000 {
        tx.send(vec![0u8; 1024]).await.unwrap(); // waits when 100 are queued
    }
    drop(tx);
    println!("bounded: consumed {}, queue never above {}: {}", consumer.await.unwrap(), 100, max_seen.load(Ordering::Relaxed) <= 100);
}
Output
unbounded: 10000 messages (10000 KiB) waiting
bounded: consumed 10000, queue never above 100: true

Use bounded channels by default and choose what happens when they're full: wait (send().await), reject (try_send returns an error you can turn into HTTP 503), or drop the oldest (broadcast). An unbounded channel is only right when the number of messages is bounded by something else.

13. Unbounded concurrency: spawning one task per item

Spawning a task per item, or join_all over a million futures, starts all of them at once. Against a remote service that means a million simultaneous requests: connection pool exhaustion, rate limiting, or a self-inflicted DoS. Limit concurrency with buffer_unordered (or a Semaphore, shown in Tokio Patterns).

Rust
use futures::stream::{self, StreamExt};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

async fn fetch(id: u32, in_flight: Arc<AtomicUsize>, peak: Arc<AtomicUsize>) -> u32 {
    let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
    peak.fetch_max(now, Ordering::SeqCst);
    tokio::time::sleep(Duration::from_millis(5)).await; // a remote call
    in_flight.fetch_sub(1, Ordering::SeqCst);
    id * 2
}

#[tokio::main]
async fn main() {
    let in_flight = Arc::new(AtomicUsize::new(0));
    let peak = Arc::new(AtomicUsize::new(0));

    let results: Vec<u32> = stream::iter(0..200)
        .map(|id| fetch(id, Arc::clone(&in_flight), Arc::clone(&peak)))
        .buffer_unordered(16) // at most 16 requests at a time
        .collect()
        .await;

    println!("fetched {} items", results.len());
    println!("peak concurrency: {}", peak.load(Ordering::SeqCst));
}
Output
fetched 200 items
peak concurrency: 16

buffer_unordered(n) yields results as they complete; buffered(n) keeps the input order. Choose n from what the downstream system can take, not from how fast you'd like to go.

14. A dropped JoinHandle doesn't stop the task

In Tokio, dropping a JoinHandle detaches the task. It runs until it finishes on its own, which for a loop means forever. Use abort(), or a JoinSet, which aborts all its tasks when it's dropped.

Rust
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::task::JoinSet;

async fn poll_forever(counter: Arc<AtomicU32>) {
    loop {
        counter.fetch_add(1, Ordering::Relaxed);
        tokio::time::sleep(Duration::from_millis(5)).await;
    }
}

#[tokio::main]
async fn main() {
    let detached = Arc::new(AtomicU32::new(0));
    {
        let _handle = tokio::spawn(poll_forever(Arc::clone(&detached)));
    } // handle dropped: the task keeps running

    let owned = Arc::new(AtomicU32::new(0));
    {
        let mut set = JoinSet::new();
        set.spawn(poll_forever(Arc::clone(&owned)));
        tokio::time::sleep(Duration::from_millis(20)).await;
    } // JoinSet dropped: its tasks are aborted

    let (d1, o1) = (detached.load(Ordering::Relaxed), owned.load(Ordering::Relaxed));
    tokio::time::sleep(Duration::from_millis(50)).await;
    let (d2, o2) = (detached.load(Ordering::Relaxed), owned.load(Ordering::Relaxed));

    println!("detached task still running: {}", d2 > d1);
    println!("JoinSet task stopped: {}", o2 == o1);
}
Output
detached task still running: true
JoinSet task stopped: true

Every spawned task should have an owner that either awaits it, aborts it, or is itself shut down. tokio_util::task::AbortOnDropHandle wraps a single JoinHandle with the same abort-on-drop behavior.

15. A future that is never awaited never runs

Calling an async fn only creates a future. Forgetting .await means the work silently doesn't happen. The compiler warns (unused_must_use: "futures do nothing unless you .await or poll them"), but let _ = ... silences it:

Rust
use std::sync::atomic::{AtomicU32, Ordering};

static AUDIT_EVENTS: AtomicU32 = AtomicU32::new(0);

async fn audit(_event: &str) {
    AUDIT_EVENTS.fetch_add(1, Ordering::Relaxed);
}

#[tokio::main]
async fn main() {
    let _ = audit("login"); // BUG: creates the future and drops it
    audit("logout").await; // runs
    tokio::spawn(audit("password change")).await.unwrap(); // runs, as its own task

    println!("audit events recorded: {}", AUDIT_EVENTS.load(Ordering::Relaxed));
}
Output
audit events recorded: 2

Build with warnings as errors in CI (RUSTFLAGS="-D warnings" or cargo clippy -- -D warnings), and treat let _ = on a future as a review red flag. Clippy's let_underscore_future lint flags exactly this line.

16. Oversized futures

An async fn's future stores every local variable that lives across an .await. A large buffer on the "stack" of an async function makes the future large, and futures are moved when they're spawned or nested. Very large futures cause slow copies and, when they end up on a thread's real stack, stack overflows.

Rust
use std::mem::size_of_val;

async fn tiny() -> u8 {
    tokio::task::yield_now().await;
    1
}

async fn big_buffer() -> usize {
    let buf = [0u8; 64 * 1024]; // lives across the .await below
    tokio::task::yield_now().await;
    buf.len()
}

async fn boxed_buffer() -> usize {
    let buf = vec![0u8; 64 * 1024]; // only a pointer lives in the future
    tokio::task::yield_now().await;
    buf.len()
}

async fn nested() -> usize {
    big_buffer().await + big_buffer().await // the parent embeds its children's state
}

#[tokio::main]
async fn main() {
    println!("tiny:         {} bytes", size_of_val(&tiny()));
    println!("big_buffer:   {} bytes", size_of_val(&big_buffer()));
    println!("boxed_buffer: {} bytes", size_of_val(&boxed_buffer()));
    println!("nested:       {} bytes", size_of_val(&nested()));
    println!("Box::pin(nested()): {} bytes", size_of_val(&Box::pin(nested())));
}
Output
tiny:         24 bytes
big_buffer:   65560 bytes
boxed_buffer: 48 bytes
nested:       65576 bytes
Box::pin(nested()): 8 bytes

The parent future is as big as its largest child (the two big_buffer calls don't overlap, so their space is shared). Keep large buffers on the heap (Vec, Box), box large child futures with Box::pin, and enable clippy::large_futures to flag futures above a size threshold.

Part 5: Timeouts and shutdown

17. Every remote call needs a timeout

A network call without a timeout can wait forever: a peer that accepted the connection and then stopped responding, a load balancer that silently drops packets. Each stuck call holds a task, a connection from the pool, and whatever memory it allocated. Put a timeout around every remote call, and an overall deadline around the request.

Rust
use std::time::Duration;
use tokio::time::{Instant, timeout, timeout_at};

async fn call(service: &str, latency_ms: u64) -> String {
    tokio::time::sleep(Duration::from_millis(latency_ms)).await;
    format!("{service} ok")
}

#[tokio::main]
async fn main() {
    let deadline = Instant::now() + Duration::from_millis(100); // the whole request's budget

    let user = timeout(Duration::from_millis(50), call("users", 10)).await;
    println!("users: {user:?}");

    let prices = timeout(Duration::from_millis(50), call("prices", 5_000)).await; // a hung dependency
    println!("prices: {}", prices.map_err(|_| "timed out after 50 ms").unwrap_err());

    let rest = timeout_at(deadline, call("recommendations", 80)).await; // only ~40 ms left
    println!("recommendations: {}", rest.map(|_| "ok").unwrap_or("skipped: request deadline reached"));
}
Output
users: Ok("users ok")
prices: timed out after 50 ms
recommendations: skipped: request deadline reached

HTTP and database clients also have their own settings (connect timeout, request timeout, pool acquire timeout). Set all of them: the defaults are often "wait forever".

18. Graceful shutdown with CancellationToken

On SIGTERM, a service should stop accepting work, let in-flight work finish (up to a deadline), flush what needs flushing, and exit. tokio_util::sync::CancellationToken broadcasts the signal to every task, and a JoinSet waits for them.

Rust
use std::time::Duration;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;

async fn worker(id: u32, token: CancellationToken) -> String {
    let mut processed = 0;
    loop {
        tokio::select! {
            _ = token.cancelled() => break, // stop taking new work
            _ = tokio::time::sleep(Duration::from_millis(5)) => processed += 1, // one unit of work
        }
    }
    // Cleanup that must happen: flush buffers, commit offsets, close connections.
    format!("worker {id} stopped cleanly after {} jobs", if processed > 0 { "some" } else { "no" })
}

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();
    let mut workers = JoinSet::new();
    for id in 1..=3 {
        workers.spawn(worker(id, token.child_token()));
    }

    // In a real service: tokio::signal::ctrl_c().await, or a SIGTERM handler.
    tokio::time::sleep(Duration::from_millis(30)).await;
    println!("shutdown requested");
    token.cancel();

    let drained = tokio::time::timeout(Duration::from_secs(5), async {
        let mut reports = Vec::new();
        while let Some(r) = workers.join_next().await {
            reports.push(r.unwrap());
        }
        reports.sort();
        reports
    })
    .await;
    match drained {
        Ok(reports) => reports.iter().for_each(|r| println!("{r}")),
        Err(_) => println!("shutdown deadline hit; remaining tasks are aborted"),
    }
}
Output
shutdown requested
worker 1 stopped cleanly after some jobs
worker 2 stopped cleanly after some jobs
worker 3 stopped cleanly after some jobs

Web frameworks integrate with this: axum's serve(...).with_graceful_shutdown(token.cancelled_owned()) stops accepting connections and waits for in-flight requests. Kubernetes sends SIGTERM and waits terminationGracePeriodSeconds (30 s by default) before SIGKILL, so the drain deadline must be shorter than that.

19. A review checklist for async code

Check Look for
No blocking in async std::fs, std::thread::sleep, sync clients, heavy CPU work, .lock() on contended std mutexes
CPU work offloaded spawn_blocking for blocking I/O, rayon for computation
Locks no std guard across .await (clippy::await_holding_lock); actors or short critical sections
select! loops every branch cancel-safe, or futures created outside the loop
Cancellation multi-step side effects survive the handler being dropped at any .await
Bounded everything channels, spawns, join_all, retries, caches
Task ownership every spawn has a handle that is awaited, aborted, or in a JoinSet
Futures awaited no let _ = some_async_fn(); -D warnings in CI
Future size no large arrays across .await; clippy::large_futures
Timeouts on every remote call, plus a per-request deadline
Shutdown cancellation token, drain with a deadline shorter than the orchestrator's
Observability a scheduling-lag metric, tokio-console in staging, spans per request

Summary

  • The runtime can only switch tasks at .await. Blocking calls and long computations stall every task on the worker: move them to spawn_blocking, rayon, or a dedicated thread, and watch scheduling lag.
  • Don't hold a std::sync::Mutex guard across .await; release it first, use tokio::sync::Mutex, or give the state to an actor.
  • select! drops the losing futures. Use cancel-safe operations, or create the future once and poll it by &mut.
  • A request's future can be dropped at any .await. Spawn work that must complete, and make multi-step updates atomic or idempotent.
  • Bound every queue and every fan-out; unbounded channels and spawns turn overload into OOM kills.
  • Dropping a JoinHandle detaches the task. Give every task an owner (JoinSet, abort on drop, a supervisor).
  • A future that isn't awaited does nothing. Keep warnings fatal in CI.
  • Keep futures small, put timeouts on every remote call, and implement graceful shutdown with a cancellation token and a drain deadline.

Next: Testing, Fuzzing & Benchmarking: property-based tests with proptest, fuzzing with cargo fuzz, benchmarks with criterion, snapshot tests, and mocks.