How to Cancel Async Tasks in Rust

Drop the JoinHandle returned by tokio::task::spawn to immediately cancel the async task.

The search bar problem

You're building a search bar. The user types "R". You fire off a network request to fetch results. Before the response arrives, the user types "Ru". The first request is now garbage. It's holding a connection open, wasting CPU, and might overwrite the fresh result when it finally returns. You need to kill that first task instantly.

In Rust, you don't send a signal. You don't call a cancel() method. You don't manage a shared boolean flag. You just drop the handle.

Cancellation by drop

Rust treats async tasks like resources. When you spawn a task, you get a JoinHandle. This handle is the only way to interact with the task's result. It's also the task's lifeline.

Think of the JoinHandle as the power cord for the task. As long as the cord is plugged in, the task runs. Unplug the cord, and the task loses power instantly. The moment the handle goes out of scope, the task is aborted. No cleanup callbacks needed. No race conditions. The runtime guarantees the task stops the instant the handle dies.

This is called "cancellation by drop." It turns cancellation into a memory management problem. If you can manage the lifetime of the handle, you manage the lifetime of the task.

Minimal example

Here is the pattern in its simplest form. You spawn a task, get the handle, and drop it to cancel.

use tokio::task::JoinHandle;

/// Spawn a long-running task and cancel it by dropping the handle.
#[tokio::main]
async fn main() {
    // Spawn a task that loops forever.
    // The handle is the only link to this task.
    let handle: JoinHandle<()> = tokio::spawn(async {
        loop {
            // Simulate work with an await point.
            // The task can be cancelled here.
            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
            println!("Task is still alive");
        }
    });

    // Wait a bit so the task prints once.
    tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;

    // Drop the handle. The task is aborted immediately.
    // The runtime will clean up the task's resources.
    drop(handle);

    // The task won't print again.
    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    println!("Handle dropped. Task is dead.");
}

Drop the handle. The task dies. That's the whole API.

What happens when you drop

When drop(handle) runs, the JoinHandle destructor fires. Tokio marks the task as cancelled. The task doesn't stop instantly if it's in the middle of a synchronous computation. Cancellation is cooperative at await points.

The next time the task tries to .await something, the runtime checks the cancellation flag. It sees the flag is set and returns Cancelled instead of resuming. The task's future is dropped. Any Drop implementations on local variables inside the task run. The memory is reclaimed.

This cooperation is a feature. It means your task gets a chance to clean up. If you hold a lock, a file handle, or a database connection, the Drop implementation for those types runs when the future is dropped. You don't leak resources.

If the task is currently running synchronous code between awaits, it keeps running until the next await point. This is important. Cancellation doesn't interrupt the CPU mid-instruction. It waits for the task to yield. Design your tasks with frequent await points if you need responsive cancellation.

Realistic patterns

In real code, you rarely call drop(handle) manually. You use higher-level primitives that manage the handle for you.

Timeouts

The most common cancellation pattern is a timeout. You want to wait for a response, but give up after a deadline. tokio::time::timeout wraps your future and cancels it if the time expires.

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

/// Fetch data with a deadline.
async fn fetch_data() -> String {
    // Simulate a slow network request.
    // If this takes too long, the task will be cancelled.
    tokio::time::sleep(Duration::from_secs(10)).await;
    "Data arrived".to_string()
}

#[tokio::main]
async fn main() {
    // Set a 2-second deadline.
    // If fetch_data doesn't finish in time, it gets cancelled.
    let result = timeout(Duration::from_secs(2), fetch_data()).await;

    match result {
        Ok(data) => println!("Got data: {}", data),
        Err(_) => println!("Timed out. The task was cancelled."),
    }
}

timeout spawns the future internally. If the duration expires, it drops the handle to the inner future. fetch_data gets cancelled at the sleep. The Err branch runs. You get a clean result without managing handles yourself.

Racing tasks

Sometimes you have multiple concurrent tasks and want to cancel the rest as soon as one finishes. tokio::select! does this. It races the branches and drops the losers.

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

/// Race two tasks and cancel the slower one.
#[tokio::main]
async fn main() {
    tokio::select! {
        // Branch 1: Fast task.
        _ = sleep(Duration::from_millis(100)) => {
            println!("Fast task won.");
        }
        // Branch 2: Slow task.
        // This branch is cancelled immediately when Branch 1 finishes.
        _ = sleep(Duration::from_millis(1000)) => {
            println!("Slow task won.");
        }
    }
}

select! evaluates all branches concurrently. As soon as one branch completes, the macro drops the futures for the other branches. The slow task is cancelled. You get the winner's result and the losers vanish.

Convention: Use select! for simple races. If you need to cancel tasks from a different part of the code, reach for a CancellationToken from the tokio-util crate. It lets you broadcast a cancel signal without managing handles directly.

Pitfalls and errors

Cancellation is simple, but a few traps exist.

Joining a cancelled task

If you drop the handle, you can't join the task. If you keep the handle and the task is cancelled by something else, join() returns an error.

use tokio::task::JoinHandle;

#[tokio::main]
async fn main() {
    let handle: JoinHandle<String> = tokio::spawn(async {
        tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
        "Result".to_string()
    });

    // Cancel the task by dropping the handle.
    drop(handle);

    // You can't join here. The handle is gone.
    // If you had cloned the handle, join would return Err(JoinError).
}

If you clone the handle and cancel via the original, join() on the clone returns Err(JoinError). Always handle the Result from join(). A cancelled task is an error condition.

E0277: Send bound

tokio::spawn requires the future to be Send. This means the task can move between threads. If your task captures a non-Send type, the compiler rejects you with E0277 (trait bound not satisfied).

use std::rc::Rc;

#[tokio::main]
async fn main() {
    let data = Rc::new("Not Send");

    // E0277: Rc is not Send.
    // tokio::spawn requires the future to be Send.
    let _handle = tokio::spawn(async move {
        println!("{}", data);
    });
}

Use Arc instead of Rc for shared data in async tasks. Arc is thread-safe. If you truly need a non-Send task, use tokio::task::spawn_local, but only in single-threaded contexts.

E0382: Moved handle

The JoinHandle is the lifeline. If you move the handle, you can't drop it in the original scope. The compiler rejects this with E0382 (use of moved value).

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async { /* ... */ });

    // Move the handle to a function.
    monitor_task(handle);

    // E0382: handle was moved.
    // You can't drop it here.
    drop(handle);
}

fn monitor_task(handle: tokio::task::JoinHandle<()>) {
    // Handle is owned here.
    // If you drop it here, the task cancels.
    drop(handle);
}

If you need to cancel the task later, keep the handle in scope. If you need to pass the handle around, consider cloning it with handle.clone(). Dropping one clone cancels the task, and all other clones become invalid.

Convention: handle.abort() exists. It explicitly cancels the task without dropping the handle. Use drop(handle) for scope-based cancellation. Use abort() when you need to cancel but the handle must live longer for some reason. In practice, drop is preferred. It's idiomatic and leverages Rust's ownership system.

Cancellation safety

Your task must be safe to cancel at any await point. If you acquire a resource before an await and release it after, cancellation might leave the resource in a bad state.

use tokio::sync::Mutex;

async fn unsafe_pattern(mutex: &Mutex<i32>) {
    // Lock the mutex.
    let mut guard = mutex.lock().await;

    // Do some work.
    // If cancelled here, guard is dropped and mutex is released.
    // This is usually fine.
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;

    *guard += 1;
    // Guard drops here, releasing the mutex.
}

In this example, the MutexGuard is dropped when the future is cancelled. The mutex is released. This is safe. The Drop implementation handles cleanup.

However, if you have a custom struct that needs cleanup, ensure its Drop implementation works correctly. If you hold a file handle, the file is closed. If you hold a database transaction, the transaction is rolled back. Trust the drop mechanism. Design your types so that dropping them is safe.

Rust trades the ability to kill a thread for the guarantee that cleanup always happens. Embrace that guarantee.

Decision matrix

Use drop(handle) when you want to cancel a task by letting the handle go out of scope or explicitly discarding it. This is the primitive. It works for any spawned task.

Use tokio::time::timeout when you need to enforce a deadline on a single operation. It wraps the future and cancels it automatically if the time expires.

Use tokio::select! when you have multiple concurrent tasks and want to cancel the rest as soon as one finishes. It races the branches and drops the losers.

Use a CancellationToken from the tokio-util crate when you need to signal cancellation from a different part of the code that doesn't hold the JoinHandle. This lets you broadcast a cancel signal without managing handles directly.

Trust the borrow checker. It usually has a point. If the compiler stops you from dropping the handle, you probably moved it somewhere else. Find the move and decide where the cancellation should happen.

Where to go next