---
title: How to Implement Cooperative Cancellation in Async Rust
summary: Implement cooperative cancellation in async Rust using tokio::select! to run concurrent tasks and stop them immediately upon completion or timeout.
lang: en
slug: how-to-implement-cooperative-cancellation-in-async-rust
published: 2026-04-17T14:12:14+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-implement-cooperative-cancellation-in-async-rust/
---

# How to Implement Cooperative Cancellation in Async Rust



## The flickering search result

You're building a search bar. The user types "rust". Your code fires off a network request to fetch results. The user backspaces and types "rustacean". A second request goes out. The first request takes a bit longer. It returns. Your UI updates with results for "rust". A millisecond later, the second request returns. The UI updates again with results for "rustacean". The user sees the wrong results flash on screen before the correct ones appear.

This is the classic race condition in async UIs. You need to kill the first request the moment the second one starts. You can't just ignore the result; you need to stop the work entirely to save bandwidth, CPU, and database load. In Rust, you don't kill threads. You drop futures.

## Cancellation is dropping

Rust's async model is built on futures. A future is a state machine that produces a value eventually. The runtime polls futures to make progress. If a future is dropped, the state machine is destroyed. Execution stops.

Cancellation in Rust is cooperative. The runtime doesn't have a magic kill switch that yanks a task out of existence. Instead, cancellation happens when the future representing the task is dropped. The runtime drops the future, the `Drop` implementation runs, and the task halts.

Think of a restaurant kitchen. A customer cancels an order. The waiter shouts "Cancel the salmon!" The chef hears it, stops chopping, and discards the salmon. The chef doesn't vanish. The chef just stops working on that dish. In Rust, the chef is the async task. The cancel signal is dropping the future. When the future is dropped, the task stops.

This design keeps Rust safe. You can't accidentally cancel a task in the middle of a critical section without running cleanup code. The `Drop` implementation always runs. If you hold a lock, `Drop` releases it. If you hold a file handle, `Drop` closes it. Cancellation safety depends on writing correct `Drop` implementations.

## Racing futures with select!

The most common way to implement cancellation is `tokio::select!`. This macro races multiple futures. It waits for the first one to complete. When one completes, `select!` drops the futures in all other branches. Those futures are cancelled.

```rust
use tokio::time::{sleep, Duration};

/// Races two tasks. The faster one wins.
/// The slower one is cancelled immediately.
async fn race_tasks() {
    // Create a fast task that finishes quickly.
    let fast_task = async {
        sleep(Duration::from_millis(100)).await;
        println!("Fast task completed");
    };

    // Create a slow task that takes longer.
    let slow_task = async {
        sleep(Duration::from_millis(500)).await;
        println!("Slow task completed");
    };

    // select! polls both tasks.
    // When fast_task finishes, select! returns.
    // slow_task is dropped and never prints.
    tokio::select! {
        _ = fast_task => println!("Fast won"),
        _ = slow_task => println!("Slow won"),
    }
}

#[tokio::main]
async fn main() {
    race_tasks().await;
}
```

The output is:
```text
Fast task completed
Fast won
```

The slow task never prints. It was dropped. The `sleep` inside the slow task was cancelled. The future for `slow_task` was destroyed, so the `await` never resumed.

## Walkthrough

When `select!` runs, it polls every branch. Each branch yields a future. The runtime checks if any future is ready. If a future is ready, `select!` executes that branch and returns. The futures in the other branches are dropped immediately.

Dropping a future runs its `Drop` implementation. For `sleep`, the `Drop` implementation removes the timer from the reactor. The timer stops firing. The task stops.

This works for any future. If you have a loop, dropping the future stops the loop. If you have a network request, dropping the future closes the connection. The cleanup is automatic because Rust guarantees `Drop` runs.

Convention aside: `select!` is biased by default. It checks branches in order. If multiple branches are ready at the same time, the first one wins. This bias improves performance by avoiding extra checks. If you need fairness, use `biased(false)`.

```rust
tokio::select! {
    biased(false); // Disable bias for fair scheduling.
    _ = task_a => println!("A"),
    _ = task_b => println!("B"),
}
```

With `biased(false)`, `select!` shuffles the branches or uses a random strategy to avoid starvation. Use this when you have many tasks and don't want the first branch to starve the others.

## Real-world patterns

Timeouts are a frequent use case. You want to run a task, but cancel it if it takes too long. `tokio::time::timeout` wraps a future and cancels it if the duration expires.

```rust
use tokio::time::{timeout, Duration};

/// Fetches data with a timeout.
/// Cancels the fetch if it takes longer than 2 seconds.
async fn fetch_with_timeout() {
    let data_future = async {
        // Simulate a slow network request.
        sleep(Duration::from_secs(10)).await;
        "Data".to_string()
    };

    // Wrap the future in a timeout.
    // If data_future doesn't complete in 2 seconds,
    // it is cancelled and timeout returns an error.
    let result = timeout(Duration::from_secs(2), data_future).await;

    match result {
        Ok(data) => println!("Got data: {}", data),
        Err(_) => println!("Timed out"),
    }
}
```

The `timeout` function returns a `Result<T, Elapsed>`. If the future completes in time, you get `Ok(value)`. If the timeout expires, the future is dropped, and you get `Err(Elapsed)`.

Convention aside: `timeout` is just syntactic sugar for `select!`. It races your future against a sleep. If the sleep wins, your future is dropped. You can implement the same pattern manually with `select!` if you need custom logic.

For spawned tasks, use `JoinHandle::abort`. When you spawn a task, you get a handle. Calling `abort` on the handle signals the task to be cancelled. The task is dropped at the next yield point.

```rust
use tokio::time::{sleep, Duration};

/// Spawns a task and aborts it after 3 seconds.
async fn abort_spawned_task() {
    // Spawn a loop that runs forever.
    let handle = tokio::spawn(async {
        loop {
            println!("Working...");
            sleep(Duration::from_secs(1)).await;
        }
    });

    // Let the task run for a bit.
    sleep(Duration::from_secs(3)).await;

    // Request cancellation.
    // The task will be dropped at the next await point.
    handle.abort();

    // Wait for the task to finish dropping.
    // This ensures resources are cleaned up.
    let _ = handle.await;
}
```

Calling `abort` doesn't stop the task instantly. The task continues until it hits an `await`. At that point, the runtime checks the abort flag and drops the future. This is still cooperative. The task can't be killed mid-computation.

Convention aside: Always await the handle after aborting. `let _ = handle.await;` ensures the task has fully dropped. If you don't await, the task might still be running in the background, holding resources. The underscore discards the result, which is usually an error because the task was aborted.

## Pitfalls and compiler errors

Cancellation safety is the biggest pitfall. If your async function holds a resource and gets cancelled, the resource must be released. If `Drop` doesn't release it, you leak.

```rust
use tokio::sync::Mutex;

/// BAD: Leaks the mutex lock on cancellation.
async fn leaky_task(mutex: &Mutex<i32>) {
    // Lock the mutex.
    let mut guard = mutex.lock().await;

    // If cancelled here, guard is dropped.
    // MutexGuard::Drop releases the lock.
    // This is safe because MutexGuard handles cleanup.
    
    // However, if you use a raw resource without Drop,
    // you leak.
    do_work().await;
}
```

Most standard types like `MutexGuard` implement `Drop` correctly. They release resources when dropped. Custom types need careful `Drop` implementations. If you write a custom async resource, test cancellation. Drop the future mid-operation and check if resources are freed.

Convention aside: Use the `scopeguard` crate for complex cleanup. It runs a closure when a guard is dropped, even if the task is cancelled. This is safer than manual cleanup in `Drop`.

```rust
use scopeguard::guard;

async fn safe_task() {
    // Acquire a resource.
    let resource = acquire_resource();
    
    // Create a guard that releases the resource on drop.
    let _guard = guard(resource, |r| release_resource(r));

    // Work can be cancelled safely.
    // The guard runs release_resource in Drop.
    do_work().await;
}
```

Another pitfall is moving values into `select!`. If you move a value into a branch, you can't use it after `select!`. The compiler rejects this with E0382 (use of moved value).

```rust
let data = String::from("hello");

tokio::select! {
    _ = async {
        // data is moved into this future.
        println!("{}", data);
    } => {}
}

// Error: E0382 use of moved value `data`.
// println!("{}", data);
```

The future owns the value. If the future is dropped, the value is gone. If you need to use the value after `select!`, clone it or borrow it.

Bias can cause starvation. If you have many branches and the first one is always ready, the others never run. This happens with channels. If a channel has messages, the receive branch is always ready. Other branches starve.

```rust
tokio::select! {
    // If this channel always has messages,
    // other branches never run.
    msg = rx.recv() => { ... },
    _ = other_task => { ... },
}
```

Fix this with `biased(false)` or by restructuring the logic. If you need to prioritize one branch, bias is correct. If you need fairness, disable bias.

## Decision matrix

Use `tokio::select!` when you have multiple local futures and want to race them, cancelling the losers automatically. Use `tokio::time::timeout` when you need to cancel a single future if it takes too long. Use `JoinHandle::abort` when you spawned a task and need to cancel it from a different task or scope. Use a cancellation channel when you need to propagate a cancellation signal deep into a hierarchy of tasks without dropping the top-level future.

Trust the borrow checker. It ensures values are moved correctly into futures. Trust `Drop`. It ensures cleanup runs on cancellation. Treat cancellation as a normal control flow path. Write code that handles being dropped gracefully.

## Where to go next

- [How to use channels with async](/en/how-to-use-channels-with-async/)
- [How Self-Referential Structs Relate to Async in Rust](/en/how-self-referential-structs-relate-to-async-in-rust/)
- [How to Bridge Sync and Async Code with block_on and spawn_blocking](/en/how-to-bridge-sync-and-async-code-with-blockon-and-spawnblocking/)