How to use tokio spawn

Use tokio::task::spawn to run async functions concurrently and get a JoinHandle to await their results.

When sequential code hits a wall

You are building a CLI tool that needs to download three configuration files from different servers. You write three reqwest::get calls in a row. The program waits for the first to finish, then the second, then the third. The total time is the sum of all three network delays. Your terminal sits idle while the CPU does nothing but wait for packets.

Rust gives you a way to run those three requests at the same time without creating three operating system threads. You hand each request to the Tokio runtime, which schedules them across a small pool of worker threads. The runtime switches between them the moment one hits a network wait. You get concurrent progress without the overhead of OS threads.

The function that makes this possible is tokio::spawn. It takes an async block, hands it to the runtime, and immediately returns a handle you can use to track the result. You do not create threads. You create tasks. The runtime decides when and where they run.

How task spawning actually works

Think of the Tokio runtime as a restaurant kitchen. The kitchen has a fixed number of chefs (worker threads). Each chef can only cook one dish at a time, but they can pause a dish to let it simmer, then pick up another. tokio::spawn is like handing a recipe card to the head expeditor. The expeditor puts the card on a board. When a chef finishes a step or a dish is simmering, the expeditor hands the next card to a free chef.

The recipe card is your async block. The board is the runtime's task queue. The chefs are your OS threads. Spawning does not hire a new chef. It just adds work to the queue. The runtime polls tasks, advances them until they hit an .await, and then moves to the next ready task. This cooperative scheduling is what lets Tokio handle thousands of concurrent connections on just a handful of threads.

You do not control which thread runs your task. You do not control when it runs. You only control what it does and how you wait for it. Trust the scheduler. It is designed to keep CPU cores busy while I/O waits happen.

The minimal setup

Every call to tokio::spawn requires a running Tokio runtime. The #[tokio::main] macro sets one up for you. The function returns a JoinHandle<T>, where T is the type your async block returns. You await the handle to get the result back.

use tokio::task::JoinHandle;

#[tokio::main]
async fn main() {
    // Hand the async block to the runtime.
    // The runtime boxes it and queues it for execution.
    let handle: JoinHandle<String> = tokio::spawn(async {
        // Simulate work that yields to the scheduler.
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        "done".to_string()
    });

    // Wait for the spawned task to finish.
    // unwrap() panics if the task panicked or was cancelled.
    let result = handle.await.unwrap();
    println!("Task returned: {result}");
}

The JoinHandle is your receipt. It proves the task exists. It carries the future result. It also carries the cancellation signal. If you drop the handle without awaiting it, the runtime cancels the task. If you await it, you block the current task until the spawned one finishes.

Keep the spawn call close to where you need the result. Do not scatter handles across modules unless you have a clear ownership chain. Treat the handle as a direct line to the work you just dispatched.

What happens under the hood

When you call tokio::spawn, the compiler wraps your async block in a Box<dyn Future<Output = T> + Send + 'static>. The Send bound guarantees the task can move across threads. The 'static bound guarantees the task does not borrow anything that might be dropped before the task finishes. Tokio pushes this boxed future onto its internal queue.

A worker thread picks up the future and calls .poll(). The future runs until it hits an .await point. At that point, it returns Poll::Pending and registers a waker. The waker tells the runtime which thread should wake the task when the awaited operation completes. The worker thread immediately moves to the next ready task.

When the awaited operation finishes (a network packet arrives, a timer expires, a channel receives a message), the runtime pushes the task back onto a ready queue. A worker thread picks it up, polls it again, and it continues from where it left off. This cycle repeats until the future returns Poll::Ready(value). The runtime stores value inside the JoinHandle. The next time you await the handle, you get the result.

The Send + 'static requirement is not a suggestion. It is a hard boundary. If your async block captures a reference to a local variable, the compiler rejects it with E0373 (closure may outlive the current function) or E0597 (borrowed value does not live long enough). The task could outlive the scope that created it. The runtime could move it to another thread. Borrowed references cannot safely cross those boundaries.

Move data into the task. Clone what you need. Use Arc for shared ownership. Do not try to smuggle references into spawned tasks. The borrow checker is protecting you from dangling pointers and data races.

A realistic concurrent workflow

Real code rarely spawns a single task and awaits it immediately. You usually spawn multiple tasks, collect their handles, and process the results as they arrive. You also need to handle errors gracefully. Network requests fail. Tasks panic. The runtime cancels work when you drop handles.

use tokio::task::JoinHandle;
use std::time::Duration;

/// Fetches a simulated resource and returns a result.
async fn fetch_resource(id: u32) -> Result<String, String> {
    // Simulate network delay with a yield point.
    tokio::time::sleep(Duration::from_millis(50)).await;
    
    // Simulate occasional failure for demonstration.
    if id % 3 == 0 {
        return Err(format!("server {id} is down"));
    }
    Ok(format!("data from {id}"))
}

#[tokio::main]
async fn main() {
    // Collect handles for all concurrent fetches.
    let mut handles = Vec::new();
    
    for id in 0..5 {
        // Spawn each fetch independently.
        // The runtime schedules them across worker threads.
        let handle = tokio::spawn(fetch_resource(id));
        handles.push(handle);
    }

    // Process results as they complete.
    for handle in handles {
        // join() returns Result<T, JoinError>.
        // JoinError captures panics and cancellation.
        match handle.await {
            Ok(Ok(data)) => println!("Success: {data}"),
            Ok(Err(e)) => println!("App error: {e}"),
            Err(join_err) => {
                // The task panicked or was cancelled.
                // join_err.is_cancelled() tells you which.
                println!("Task failed: {join_err}");
            }
        }
    }
}

The nested Result is intentional. handle.await returns Result<T, JoinError>. The inner Result comes from your own fetch_resource function. You must distinguish between a task that crashed (runtime level) and a task that returned an error (application level). JoinError gives you is_cancelled() and is_panic(). Use them to log appropriately.

Do not ignore JoinError. Swallowing it hides panics and makes debugging impossible. Log the error, decide whether to retry, and move on. The runtime does not propagate panics to the parent task. You must handle them explicitly.

Where things go wrong

Spawning tasks introduces failure modes that sequential code does not have. The compiler catches some. The runtime surfaces others. You need to know what to expect.

Forgetting to await the handle cancels the task. If you call tokio::spawn(async { ... }) and never store or await the handle, the handle drops immediately. Tokio cancels the task before it even runs. You will see no output, no errors, just silent disappearance. Store the handle. Await it. Or explicitly drop it when you want cancellation.

Borrowing from the spawning scope breaks the 'static bound. The compiler rejects this with E0373 or E0597. The fix is to move owned data into the task. Clone strings. Copy integers. Wrap shared state in Arc. Do not fight the lifetime checker. It is preventing use-after-free bugs that would crash your program at runtime.

Panic propagation requires explicit handling. If a spawned task panics, the panic does not bubble up to the parent. The JoinHandle returns Err(JoinError). Calling .unwrap() on the handle will panic the parent task. This is often desirable for development. In production, use .await.unwrap_or_else(|e| ...) or match on the result. Decide how a single task failure should affect the whole program.

Cancellation is immediate but not graceful. Dropping a JoinHandle cancels the task at the next .await point. The task does not stop mid-computation. It finishes the current synchronous block, then checks for cancellation. If you need cleanup, use tokio::select! or register a cancellation token. Do not assume dropping a handle runs your Drop implementations. It does not.

Treat every spawned task as a fire-and-forget operation until you await the handle. Plan for failure. Plan for cancellation. Plan for panics. The runtime will not save you from bad error handling.

Choosing the right concurrency tool

Use tokio::spawn when you need to run independent async work concurrently and want to track each result individually. Use tokio::task::JoinSet when you are spawning many similar tasks and want to collect results as they finish without managing a vector of handles manually. Use futures::future::join! or tokio::join! when you have a fixed, small number of tasks and want to wait for all of them to complete before proceeding. Reach for tokio::sync::mpsc or tokio::sync::watch when you need to coordinate work between tasks or stream results back to a single consumer.

The runtime is a scheduler, not a magic concurrency solver. Pick the primitive that matches your data flow. Keep spawn calls explicit. Handle errors at the boundary. Let the runtime do what it does best: keep threads busy while I/O waits.

Where to go next