How to Implement Structured Concurrency in Rust

Implement structured concurrency in Rust by using the tokio crate's JoinSet to manage and clean up child tasks automatically.

The zombie task problem

You are building a search API. A user types "rust". Your code spawns three tasks: one hits GitHub, one hits Crates.io, one hits Reddit. The user deletes "rust" and types "python" before the results arrive. The old tasks are still running. They finish, return results for "rust", and your code tries to update the UI with stale data. The app crashes. Or worse, the tasks hold database connections that never get released. Memory creeps up. The server dies hours later.

This is the zombie task problem. Tasks outlive the context that created them. They consume resources for work that no longer matters. Structured concurrency solves this by tying every task to a parent scope. When the parent scope ends, all children are cancelled. No zombies. No leaks. The structure of your code matches the lifecycle of your work.

Rust does not enforce structured concurrency at the language level. The compiler won't stop you from spawning a detached task. The ecosystem, however, has converged on patterns that make structure the default. You get safety by choosing the right tools.

Structure as a tree

Think of structured concurrency like a family tree. Every child is born to a parent. The parent is responsible for the child. If the parent leaves the building, the child leaves too. You cannot have a child wandering around without a parent.

In code, this means every async task is spawned inside a scope. That scope is the parent. When the scope ends, the parent is gone. All tasks spawned inside that scope must finish or be cancelled. The scope waits for its children. It does not let them escape.

This rule changes how you write async code. You stop thinking about tasks as independent entities. You start thinking about them as parts of a larger operation. The operation owns the tasks. The operation's lifetime bounds the tasks' lifetimes.

If the parent goes, the children go. No exceptions.

JoinSet: the parent scope

The standard tool for structured concurrency in Rust is tokio::task::JoinSet. A JoinSet is a collection of tasks that share a lifecycle. You spawn tasks into the set. The set tracks them. When you drop the set, all tasks are cancelled. When you wait on the set, you wait for all tasks to finish.

use tokio::task::JoinSet;

#[tokio::main]
async fn main() {
    // JoinSet creates a parent scope.
    // Tasks spawned here are children of this set.
    let mut set = JoinSet::new();

    // Spawn tasks. They run concurrently.
    // The closure captures nothing, so no move is needed.
    set.spawn(async { println!("Task A"); });
    set.spawn(async { println!("Task B"); });

    // Wait for tasks to complete one by one.
    // join_next returns None when all tasks are done.
    while let Some(res) = set.join_next().await {
        // res is Result<Result<T, E>, JoinError>.
        // The outer Result is for the JoinSet itself.
        // The inner Result is the task's return value.
        match res {
            Ok(Ok(())) => println!("Task finished successfully"),
            Ok(Err(e)) => eprintln!("Task returned error: {}", e),
            Err(join_err) => eprintln!("Task panicked or cancelled: {}", join_err),
        }
    }
}

Drop the set, drop the tasks. That is the contract.

How JoinSet works

JoinSet::new() creates an empty set. set.spawn(async { ... }) adds a task to the set and starts it immediately. The task runs on the Tokio runtime. It can yield, sleep, or do work. The set holds a handle to the task.

set.join_next().await waits for the next task to finish. It returns Some(result) if a task finished, or None if the set is empty. You loop until None. This loop is the structured part. The scope does not end until all children are accounted for.

If you drop the JoinSet before the loop finishes, the set cancels all remaining tasks. Cancellation is cooperative. Tasks must reach a cancellation point to stop. An await is a cancellation point. If a task is stuck in a CPU loop, it won't cancel until it yields.

The set also handles panics. If a task panics, join_next returns an Err(JoinError). You can inspect the error to see if the task panicked or was cancelled. You must handle this. Ignoring panics in tasks is a bug waiting to happen.

Real world: fetching with structure

A realistic use case is fetching data from multiple sources and combining the results. You want to spawn parallel requests, wait for all of them, and handle errors. If one request fails, you might want to cancel the others. Or you might want to collect partial results. JoinSet gives you the control.

use tokio::task::JoinSet;

async fn fetch_profile(user_id: u32) -> Result<String, Box<dyn std::error::Error>> {
    // Create a JoinSet to manage the fetch tasks.
    let mut set = JoinSet::new();

    // Spawn a task to fetch the username.
    // move captures user_id into the closure.
    set.spawn(async move {
        // Simulate network delay.
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        Ok::<_, Box<dyn std::error::Error + Send + Sync>>("Alice".to_string())
    });

    // Spawn a task to fetch the avatar.
    set.spawn(async move {
        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
        Ok("avatar.png".to_string())
    });

    // Spawn a task to fetch stats.
    set.spawn(async move {
        tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
        Ok("Level 42".to_string())
    });

    let mut results = Vec::new();

    // Collect results as they arrive.
    while let Some(res) = set.join_next().await {
        match res {
            // Task completed successfully.
            Ok(Ok(data)) => results.push(data),
            // Task returned an error.
            Ok(Err(e)) => return Err(e),
            // Task panicked or was cancelled.
            Err(join_err) => {
                if join_err.is_cancelled() {
                    return Err("Task was cancelled".into());
                }
                return Err(format!("Task panicked: {}", join_err).into());
            }
        }
    }

    Ok(format!("Profile: {:?}", results))
}

Handle the JoinError. Panics in tasks are real. Catch them or crash.

Cancellation is cooperative

Structured concurrency relies on cancellation. When a scope ends, tasks are cancelled. Cancellation is not magic. It is a signal. The task must check the signal and stop.

In Tokio, an await is a cancellation point. When you drop a JoinSet, the runtime marks the tasks as cancelled. The next time a task hits an await, it checks the flag. If cancelled, the future returns an error or completes early.

If a task has no await, it cannot be cancelled. A CPU-bound loop will run until it finishes. This is a common pitfall. Long-running computations must yield periodically. Use tokio::task::yield_now().await to insert a cancellation point. Or break the work into chunks and await between chunks.

Convention aside: The community expects tasks to be responsive to cancellation. If your task ignores cancellation, you break the contract of structured concurrency. Review your code for tight loops. Add yields.

Pitfalls and compiler errors

Structured concurrency introduces specific pitfalls. The compiler helps, but you need to know what to look for.

If you try to spawn a task that borrows a local variable, the compiler rejects you. Tasks are closures that may outlive the current function. The compiler requires move to capture data by value. If you forget move, you get E0373 (closure may outlive the current function). Add move to the closure. Or restructure the code to avoid borrowing.

let data = String::from("hello");
// This fails with E0373.
// set.spawn(async { println!("{}", data); });
// This works.
set.spawn(async move { println!("{}", data); });

If you try to return a reference from a spawned task, the compiler rejects you. Tasks run on different threads. They cannot return borrows of the stack frame that spawned them. You get E0515 (cannot return value referencing local data). Return owned data. Or use Arc to share data across tasks.

If you spawn a task that contains a !Send type, the compiler rejects you. JoinSet requires tasks to be Send. This means the task can move across threads. If you use Rc or a raw pointer, you get E0277 (the trait bound Send is not satisfied). Use Arc instead of Rc. Or use tokio::task::spawn_local for single-threaded runtimes. Note that spawn_local breaks structured concurrency across threads. Use it only when necessary.

Convention aside: JoinSet is the workhorse for dynamic task counts. tokio::task::scope is the ergonomic wrapper for fixed sets. scope auto-waits on drop. It is often preferred for simple scopes because it requires less boilerplate.

Choosing your tool

Rust offers multiple ways to manage async tasks. Pick the right tool for the job.

Use JoinSet when you need to spawn a dynamic number of tasks and collect their results in a loop. Use JoinSet when you want explicit control over cancellation by dropping the set early. Use tokio::task::scope when you have a fixed set of tasks and want the compiler to guarantee they all finish before the scope ends. Use tokio::spawn when you need a detached task that must survive the caller, like a background heartbeat or a server listener. Avoid tokio::spawn for request handling. Detached tasks leak resources when the request drops.

Detached tasks are a leak waiting to happen. Stick to scopes unless you have a reason not to.

Where to go next