The double result trap
You are building a background worker that fetches user profiles from a remote API. You spawn the task, go back to processing other requests, and then wait for the result. The network drops. The task fails. When you check the result, you don't see the network error. You see a JoinError. Or worse, you see Ok(Err(...)) and you have to unwrap two layers just to find out what went wrong. Async error handling feels like peeling an onion where every layer is a Result, and the layers mean completely different things.
The core issue is that async code introduces a second dimension of failure. A normal function fails if the code inside it hits a problem. An async task can fail in two ways. The code inside the task can fail, just like a normal function. Or the task itself can fail to run. The task might panic, get cancelled, or the runtime might crash. You need to distinguish between "the code returned an error" and "the task died."
The courier analogy
Think of an async task like sending a package via a courier service. You hand the package (the future) to the courier (the runtime). The courier gives you a tracking number (the JoinHandle). You don't get the package back immediately. You wait for the tracking number to update.
When you check the tracking number, you first find out if the courier delivered the package or if the courier crashed their bike. If the courier crashed, you get a report about the crash. That is the outer result. It tells you about the delivery process.
If the courier delivered the package, you get the box. You open the box to see if the contents are good. The box might contain a damaged item. That is the inner result. It tells you about the package itself.
In Rust, the courier is the async runtime. The tracking number is the JoinHandle. The crash report is the JoinError. The box is the Result returned by your async function. You always check the tracking number first. If the courier crashed, it doesn't matter what was in the box.
Minimal example
The standard pattern for handling errors in spawned tasks involves matching on a Result<Result<T, E>, JoinError>. The outer Result comes from the JoinHandle. The inner Result comes from your async function.
use tokio::task::JoinHandle;
/// Fetches data from a URL and returns a Result.
/// The function returns Ok(String) on success or Err on failure.
async fn fetch_data() -> Result<String, Box<dyn std::error::Error>> {
// The ? operator propagates the error and stops the async function.
// If get() fails, the function returns early with the error.
let response = reqwest::get("https://api.example.com")
.await?
.text()
.await?;
Ok(response)
}
#[tokio::main]
async fn main() {
// spawn takes the future and returns a JoinHandle.
// The JoinHandle wraps the function's return type in another Result.
// The type is JoinHandle<Result<String, Box<dyn Error>>>.
let handle: JoinHandle<Result<String, Box<dyn std::error::Error>>> =
tokio::spawn(fetch_data());
// Await the handle to get the task result.
// This returns Result<Result<String, Box<dyn Error>>, JoinError>.
match handle.await {
// Task completed successfully, and the function returned Ok.
Ok(Ok(data)) => println!("Got data: {}", data),
// Task completed successfully, but the function returned Err.
Ok(Err(e)) => eprintln!("Task returned an error: {}", e),
// Task panicked or was cancelled.
Err(e) => eprintln!("Task execution failed: {}", e),
}
}
The outer Result is the runtime's verdict. The inner Result is your code's verdict. Check the outer one first.
Anatomy of the JoinHandle
When you call tokio::spawn, you hand a future to the runtime. The runtime schedules the future to run on a worker thread. The runtime returns a JoinHandle. The JoinHandle is a future itself. When you await the handle, you get a Result back.
The type of the JoinHandle depends on the return type of the async function. If your function returns T, the handle is JoinHandle<T>. If your function returns Result<T, E>, the handle is JoinHandle<Result<T, E>>.
When you await the handle, the output type is Result<HandleType, JoinError>. So if the handle is JoinHandle<Result<T, E>>, the output is Result<Result<T, E>, JoinError>.
The JoinError occurs when the task panics or is cancelled. A panic inside an async task does not propagate to the caller. The runtime catches the panic and wraps it in a JoinError. This keeps the runtime stable. If every panic killed the runtime, a single bug would take down the whole server.
The ? operator works inside async functions just like it works in sync functions. It propagates errors up the call stack. In async code, the call stack is the future state machine. When you use ?, the future transitions to a completed state with the error. The caller sees the error when they await the future.
Convention aside: The community often uses Box<dyn std::error::Error> for quick prototypes because it accepts any error type. Real projects usually define a custom error enum or use anyhow::Error for better ergonomics. Box<dyn Error> loses type information, which makes it hard to handle specific errors later.
Realistic error handling
In production code, you rarely use Box<dyn Error>. You define specific error types so you can handle them differently. You also need to convert errors from dependencies into your error type. The ? operator uses the From trait to convert errors automatically.
use std::time::Duration;
use tokio::task::JoinHandle;
/// Application-specific errors.
#[derive(Debug)]
enum AppError {
Network(String),
Parse(String),
Timeout,
}
/// Convert reqwest errors into AppError.
/// This allows the ? operator to work seamlessly.
impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
AppError::Network(err.to_string())
}
}
/// Fetches configuration from a remote source.
/// Returns Result<String, AppError>.
async fn fetch_config() -> Result<String, AppError> {
// The ? operator calls From::from to convert reqwest::Error to AppError.
// If the request fails, the function returns Err(AppError::Network(...)).
let body = reqwest::get("https://config.example.com")
.timeout(Duration::from_secs(5))
.await?
.text()
.await?;
Ok(body)
}
/// Spawns the fetch task and handles all failure modes.
async fn run_fetcher() {
let handle: JoinHandle<Result<String, AppError>> = tokio::spawn(fetch_config());
// Await the handle to get the task result.
match handle.await {
// Task completed successfully, and the function returned Ok.
Ok(Ok(config)) => println!("Config loaded: {}", config),
// Task completed successfully, but the function returned Err.
Ok(Err(AppError::Network(e))) => eprintln!("Network failed: {}", e),
Ok(Err(AppError::Parse(e))) => eprintln!("Parse failed: {}", e),
Ok(Err(AppError::Timeout)) => eprintln!("Request timed out"),
// Task panicked or was cancelled.
Err(join_err) => eprintln!("Task execution failed: {}", join_err),
}
}
The From implementation is the key to clean error handling. It lets you use ? without manual mapping. The compiler inserts the conversion automatically. This keeps the logic focused on the happy path.
Convention aside: Keep unsafe blocks small and isolated. If you are implementing a custom async runtime or a low-level abstraction, you might use unsafe to optimize task scheduling. The community calls this the "minimum unsafe surface" rule. Wrap the unsafe logic in a safe API so callers don't have to worry about invariants.
Never unwrap a JoinHandle in production. A panic in a spawned task is a signal that something broke, not a bug in your matching logic.
Pitfalls and compiler traps
Async error handling has several traps that catch developers off guard. The most common is ignoring the outer result. If you write handle.await.unwrap(), you assume the task never panics. If the task panics, your program crashes. This is silent failure waiting to happen. The task dies, but your code keeps running. You might notice missing data hours later.
Another trap is the Send bound. tokio::spawn requires the future to be Send. This means the future can be moved between threads. If you use Rc or non-Send types inside the async function, the compiler rejects you with E0277 (the trait bound Send is not satisfied). Rc is not Send because it uses reference counting without atomic operations. Use Arc instead, which is thread-safe.
If you try to move a value out of a borrowed context, you get E0507 (cannot move out of borrowed content). This happens when you capture variables in the async block incorrectly. Make sure you own the data you pass to the task.
The ? operator requires the function to return Result or Option. If you write an async function that returns T, you cannot use ?. The compiler rejects this with a type mismatch error. You must change the return type to Result<T, E> to use ?.
Convention aside: Use let _ = ... to discard a result when you intentionally ignore it. This signals to readers that you considered the value and chose to drop it. It suppresses the unused variable warning and documents your intent.
A panicked task is a zombie. If you don't check the JoinHandle, the zombie walks around your process until you notice missing data.
Decision matrix
Use the ? operator inside async functions to propagate errors immediately without nesting if let Err. Use ? when the function signature returns Result or Option and you want to stop execution on failure.
Use tokio::spawn when you need to run a task concurrently and don't need the result immediately, or when you want to isolate a task so a panic doesn't kill the whole runtime. Use spawn when you must handle the JoinHandle result to distinguish between task panics and application errors.
Use handle.await with a match statement when you spawn a task and need to process the result. Use match to handle the outer Result for task execution status and the inner Result for the function's return value.
Use tokio::task::block_on when you are in a synchronous context and need to wait for a single future, but avoid it in async code to prevent blocking the runtime thread.
Use anyhow::Result or a custom error enum when you want ergonomic error handling or type-safe errors, rather than Box<dyn std::error::Error> which is convenient but hides specific error types.
Use Arc<T> instead of Rc<T> when sharing data across async tasks, because Arc is Send and safe for concurrent access.
Trust the borrow checker. It usually has a point. If the compiler complains about Send, check for Rc or raw pointers.