---
title: "23.4 Async pitfalls"
description: "A field guide to where async bites: blocking the executor, the Send requirement on spawned tasks, and cancellation at await points."
url: "https://learnrust.net/chapter-23/async-pitfalls/"
last_updated: "2026-06-13"
---

# 23.4 Async pitfalls

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](https://learnrust.net/chapter-23/using-tokio/) 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.

```rust
#[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](https://learnrust.net/chapter-22/send-and-sync/)). 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:

```rust
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](https://learnrust.net/chapter-21/rc/)) 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:

```rust
// 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](https://learnrust.net/chapter-23/async-await-and-futures/)), 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?

<details class="solution">
<summary>Show solution</summary>

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`.

</details>

**Question #2**

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

<details class="solution">
<summary>Show solution</summary>

`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.

</details>

**Question #3**

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

<details class="solution">
<summary>Show solution</summary>

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.

</details>

That's the honest tour of async. The summary and quiz ([23.x](https://learnrust.net/chapter-23/chapter-23-summary-and-quiz/)) 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.

## Sitemap

See the full [sitemap](https://learnrust.net/sitemap.md) for all pages.
