How to Use tokio

:spawn for Spawning Async Tasks

Use tokio::spawn to run async code in the background and get a handle to retrieve the result later.

The background worker pattern

You are building a web server. A client sends a request to generate a PDF report. Generating the report takes three seconds of CPU work and a database query. If you just write let report = generate_report().await;, your server thread is stuck waiting. During those three seconds, no other client can get a response. Your server feels frozen.

You need to hand that work off to a background worker. The server should accept the request, acknowledge it immediately, and let a worker handle the heavy lifting. When the worker finishes, it can save the report or notify the client.

That is exactly what tokio::spawn does. It takes an async task and schedules it on the runtime to run in the background. Your current code continues immediately. You get back a handle that lets you check on the task later, or you can drop the handle and let the task run on its own.

How tokio::spawn works

Think of tokio::spawn like a deli counter. You are at the counter ordering a sandwich. The clerk takes your order and hands you a ticket with a number on it. You don't stand behind the counter making the sandwich. You walk away and sit down. The kitchen worker makes the sandwich. When it is ready, they call your number.

tokio::spawn is the clerk. The async block you pass is the order. The JoinHandle you get back is the ticket. You can wait for your number to be called by awaiting the handle. Or you can throw the ticket away and never check on the sandwich. The kitchen still makes it.

The key difference from a thread is cost. Threads are heavy. Creating a thread allocates a stack and asks the OS for resources. You can only have a few thousand threads before the system slows down. Tasks are lightweight. A task is just a state machine and some data. The runtime multiplexes thousands of tasks onto a handful of threads. spawn is cheap enough to call inside a hot loop.

use tokio::task::JoinHandle;

#[tokio::main]
async fn main() {
    // Spawn schedules the async block on the runtime.
    // It returns immediately with a handle.
    let handle: JoinHandle<String> = tokio::spawn(async {
        // Simulate async work without blocking the runtime.
        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
        "Work complete".to_string()
    });

    // Do other work while the task runs.
    println!("Task spawned, doing other things...");

    // Await the handle to get the result.
    // This blocks the current task until the spawned task finishes.
    let result = handle.await.unwrap();
    println!("Got result: {result}");
}

The handle is your receipt. Keep it if you care about the result.

What happens under the hood

When you call tokio::spawn, the runtime takes your future and puts it in a work queue. A worker thread picks up the future and starts polling it. Polling means asking the future, "Are you done yet?" If the future is ready, it returns a value. If it needs to wait for I/O or a timer, it yields control back to the runtime. The runtime then polls other futures. This cycle happens millions of times per second.

The spawned task runs independently. It might run on the same thread as your main task, or it might run on a different thread. You don't control that. The runtime decides based on load balancing. This is why you cannot share borrowed data between the main task and the spawned task. The spawned task could run after the main task finishes and destroys the data.

This leads to the most common rule: the closure passed to spawn must be 'static. In practice, this means the closure must own all the data it uses. It cannot borrow variables from the surrounding scope. You almost always need the move keyword to force the closure to take ownership of captured variables.

Real-world usage: Fire and forget

A common pattern is spawning a task for side effects where you don't need the result. Logging, metrics, or cleanup jobs fit here. You spawn the task and let it run. You don't await the handle.

async fn handle_request(id: u32) -> String {
    // Spawn a background task to log the request.
    // Use `move` because the task might outlive this function.
    // The closure must own `id`.
    tokio::spawn(async move {
        log_request(id).await;
    });

    // Return the response immediately.
    // The logging happens independently.
    format!("Request {id} handled")
}

async fn log_request(id: u32) {
    // Simulate writing to a database or file.
    tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
    println!("Logged request {id}");
}

Convention aside: when you spawn a task and intentionally drop the handle, the community often writes let _ = handle; or adds a comment to signal that the drop is deliberate. This prevents readers from thinking you forgot to await the result.

Spawn and forget is a powerful pattern, but remember: the task lives as long as the runtime. If you drop the handle, the task keeps running. There is no automatic cancellation.

Pitfalls and compiler errors

The compiler will stop you from making mistakes, but the errors can be confusing if you don't know what to look for.

The first trap is forgetting move. If you capture a variable without moving it, the closure borrows the variable. The compiler rejects this because the task might outlive the borrow. You get an error like E0373 (closure may outlive the current function, but it borrows data). The fix is to add move to the closure. This forces the closure to take ownership of the data. If the data cannot be moved, you need to clone it or use a reference-counted wrapper like Arc.

// This fails to compile.
// The closure borrows `data`, but the task might run after `data` is dropped.
// Error: closure may outlive the current function, but it borrows `data`
let data = String::from("hello");
tokio::spawn(async {
    println!("{data}");
});

// This compiles.
// `move` forces the closure to take ownership of `data`.
let data = String::from("hello");
tokio::spawn(async move {
    println!("{data}");
});

The second trap is panics. If a spawned task panics, the panic is caught by the runtime. The runtime logs the panic and continues running other tasks. The panic does not crash your program. However, the JoinHandle returns an error. When you await the handle, you get Err(JoinError). You must handle this error. If you unwrap blindly, your main task will panic when the spawned task panics.

let handle = tokio::spawn(async {
    panic!("Something went wrong");
});

// This returns Err(JoinError).
// Handle the error gracefully.
match handle.await {
    Ok(result) => println!("Result: {result}"),
    Err(e) => eprintln!("Task failed: {e}"),
}

The third trap is assuming dropping the handle cancels the task. It does not. Dropping the handle just removes your ability to wait for the result. The task keeps running. If you need cancellation, you must implement it yourself. A common approach is to use a tokio::sync::watch channel to send a cancellation signal, or use tokio_util::sync::CancellationToken.

If you drop the handle, the task keeps running. There is no automatic cancellation.

When to use tokio::spawn

Choosing the right concurrency tool depends on your lifecycle and data flow. Use the parallel structure below to decide.

Use tokio::spawn when you need to run a task in the background and want to retrieve the result later via a JoinHandle. Use tokio::spawn when you want to fire-and-forget a task, allowing it to run independently without blocking the caller. Use .await directly when the current task must wait for the result and parallelism is not required. Use JoinSet when you are spawning a dynamic number of tasks and need to manage them as a collection, such as waiting for all to finish or cancelling the group. Use tokio::task::block_in_place when you have synchronous, CPU-bound code that must not block the async runtime's worker threads.

Pick the tool that matches your lifecycle. spawn gives you flexibility, but flexibility costs mental overhead.

Where to go next