23.4Async pitfalls

Last updated June 13, 2026

This chapter promised honesty about async's rough edges, and here it is, collected in one place. Async Rust is genuinely useful, and also the part of the language where beginners hit the most confusing walls. Knowing the three big ones in advance turns "why is this broken and what is this error even saying" into "oh, that one." We'll treat this as a field guide, not a horror story: each pitfall, what it looks like, and how to step around it.

Pitfall 1: blocking the executor

You met this in lesson 23.3 and it's worth restating as the headline, because it's the most common and the most damaging. Async gets its concurrency from tasks voluntarily yielding at .await points. A blocking operation, one that makes the thread wait without yielding, never gives the thread back, so it stalls every other task scheduled there.

The usual culprits are sneaky because they look innocent: std::thread::sleep, synchronous file reads (std::fs::read_to_string), synchronous network calls, or a long CPU-bound loop with no .await in it. Any of these inside an async task freezes the executor.

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        std::thread::sleep(std::time::Duration::from_secs(5));  // BLOCKS the thread
        println!("done blocking");
    });
    // Every other task on this thread is frozen for 5 seconds.
}

The fixes, in order of preference: use the async equivalent and await it (tokio::time::sleep(...).await, Tokio's async file/network IO instead of the blocking std versions). When the work genuinely must block, an unavoidable synchronous library, or it's CPU-bound work that won't yield, hand it to tokio::task::spawn_blocking, which runs it on a separate pool of threads set aside for exactly this, so it can't freeze the main async tasks. The rule: never let a blocking call sit naked inside an async task.

Pitfall 2: the Send requirement and !Send futures

Tokio runs tasks across multiple threads by default, moving a task from one thread to another as needed. From chapter 22 you know what that requires: anything that crosses a thread boundary must be Send (lesson 22.4). So tokio::spawn requires its future to be Send, and a future captures everything held across its .await points. If you hold a non-Send value across an .await, the whole future becomes non-Send, and the error can be baffling the first time:

use std::rc::Rc;

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        let data = Rc::new(5);          // Rc is !Send (chapter 22)
        some_async_thing().await;       // held across an await...
        println!("{data}");             // ...and used after, so it must survive the await
    });
}
error: future cannot be sent between threads safely
   |
   = help: within this `async` block, the trait `Send` is not implemented for `Rc<i32>`
note: future is not `Send` as this value is used across an await

This is the exact same Send story as chapter 22, now applied to futures. The Rc (not Send, lesson 21.5) is alive across the .await, so the task can't be safely moved between threads, and Tokio refuses to spawn it. The fixes are the chapter-22 fixes: use a Send type instead (Arc for Rc), or arrange the code so the non-Send value is dropped before the .await (so it isn't held across the suspension point). The error message's "used across an await" note is the key, it tells you exactly which value and which await are at fault.

Why this error is actually familiar

"Future cannot be sent between threads safely" looks like a brand-new async problem, but it's Send/Sync from chapter 22 wearing an async costume. A spawned future is just work that moves between threads, so its captured state must be Send, and a value held across an .await is captured state. Every async Send error reduces to "a non-Send value (usually Rc, RefCell, or a MutexGuard) is alive across an await point." Once you read it that way, the fix is the one you already know: swap for the thread-safe type, or shorten the value's life so it doesn't straddle the await.

Pitfall 3: cancellation at await points

Here's one with no exact parallel in synchronous code. An async task can be cancelled: if you stop awaiting a future (it loses a race in tokio::select!, its handle is dropped, a timeout fires), the future simply stops being polled, and it stops at its last .await point. It does not get to run to the end. Whatever came after that await never happens.

This matters when the code after an await was supposed to clean up or finish something:

// Conceptually:
async fn risky() {
    let lock = acquire().await;     // got a resource
    do_work().await;                // <-- if cancelled here, the line below never runs
    release(lock).await;            // cleanup that may be skipped on cancellation
}

If risky is cancelled while awaiting do_work, the release never runs. The defenses are to lean on Drop (chapter 21) for cleanup that must always happen, because a value's drop does run even when a future is cancelled and dropped, so RAII-style cleanup (a lock guard that unlocks on drop) survives cancellation where an explicit cleanup-after-await does not. The takeaway: in async code, don't assume the lines after an .await will run; put must-happen cleanup in a Drop, not in code positioned after an await.

The honest summary

Async's seams are real: a runtime you must add yourself (lesson 23.2), function coloring that spreads async up the call chain, blocking calls that silently wreck concurrency, Send errors on futures, and cancellation that can skip your cleanup. None of these are dealbreakers, mature async services run the world's largest systems, but they're why this chapter has insisted that async is a specialized tool. When you have many concurrent IO-bound waits, async is worth every one of these costs. When you don't, the simpler tools, threads, rayon, plain sequential code, spare you all of them. Choosing async well is mostly choosing it only when the problem calls for it.

Best practice

Reach for async only for many-concurrent-IO workloads (network servers and clients, chiefly). When you do, keep three rules in front of you: never block inside a task (use async equivalents or spawn_blocking); watch for non-Send values held across .await (swap for Arc/thread-safe types, or drop them before the await); and put must-run cleanup in Drop rather than after an await, since tasks can be cancelled at await points. For everything else, prefer the simpler concurrency tools from chapter 22, or no concurrency at all.

Quiz time

Question #1

What does "blocking the executor" mean, and why is it so damaging?

Show solution

It means running a blocking operation (std::thread::sleep, a synchronous file/network call, a long CPU loop with no .await) inside an async task. Async concurrency depends on tasks yielding the thread at .await points; a blocking call never yields, so it holds the thread and freezes every other task scheduled on it. One blocking call can stall a whole server. Fix it by using the async equivalent (and awaiting it), or offloading unavoidable blocking work to tokio::task::spawn_blocking.

Question #2

Why does holding an Rc across an .await cause a "future cannot be sent between threads safely" error?

Show solution

tokio::spawn runs tasks across threads, so the future must be Send (chapter 22). A future captures any value held across its .await points. Rc is not Send (its count is non-atomic, lesson 21.5/22.4), so a future that keeps an Rc alive across an await is itself not Send, and can't be spawned. It's the same Send rule as chapter 22, applied to futures. Fix: use Arc instead, or drop the Rc before the .await so it isn't held across it.

Question #3

An async task can be cancelled at an .await point. What's the practical danger, and how do you guard against it?

Show solution

If a future is cancelled (dropped) while suspended at an .await, it stops there and the code after that await never runs, so any cleanup or finishing step written after the await may be skipped. Guard against it by putting must-happen cleanup in a Drop implementation (RAII, chapter 21): a value's drop runs even when the future is cancelled and dropped, whereas explicit cleanup-after-await does not. Don't assume the lines after an .await will execute.

That's the honest tour of async. The summary and quiz (23.x) consolidate the model, the syntax, the runtime, and the pitfalls. After it, chapter 24 turns to a different kind of power: macros, the code that writes code, finally explaining the ! you've typed after println since lesson 1.1.