How to Handle Errors in Async Functions in Rust

Handle async errors in Rust by returning Result types and using the ? operator to propagate failures gracefully.

When the network drops the ball

You are building a service that fetches a user profile, checks their subscription status, and pulls their recent activity. All three calls happen over the network. The profile request succeeds. The subscription check times out. The activity request is still waiting. In Python or JavaScript, an unhandled timeout often crashes the entire event loop or leaves a dangling promise that silently fails. In Rust, the compiler refuses to compile until you explicitly decide what happens to that timeout.

Async error handling in Rust does not introduce a new system. It layers standard Result propagation over the async execution model. The ? operator and .await keyword work together to pause your function, wait for the network response, and immediately short-circuit if the response is an error. You get the same safety guarantees as synchronous code, just with time travel built in.

The envelope and the pause button

Think of an async function as a factory that ships envelopes. Each envelope contains either a successful value or an error report. The .await keyword is the pause button. It tells the runtime: stop doing other work, wait for this envelope to arrive, and hand it to me. Once the envelope arrives, the ? operator opens it. If it contains an error, the operator immediately stops the factory line and hands the error report to whoever called the function. If it contains a success, the operator extracts the value and the factory continues.

The key insight is that await and ? are independent tools that compose cleanly. await handles time. ? handles failure. You chain them because network calls take time and they frequently fail. The compiler enforces that you acknowledge both dimensions. You cannot ignore the time dimension by dropping .await. You cannot ignore the failure dimension by dropping ?.

Minimal example

Here is the baseline pattern. The function signature declares that it returns a Result. The body chains .await and ? together.

use std::error::Error;

/// Fetches a page title from a URL, returning an error if the request fails.
async fn page_title(url: &str) -> Result<Option<String>, Box<dyn Error>> {
    // Start the HTTP request. The future resolves to a Result.
    let response = http_client::get(url).await?;
    
    // Read the response body. Another potential failure point.
    let body = response.text().await?;
    
    // Parse the HTML and extract the title. This is synchronous.
    let title = html_parser::parse(&body).select_first("title");
    
    // Map the optional title node to its inner HTML string.
    Ok(title.map(|node| node.inner_html()))
}

The ? operator sits directly after .await. This is the standard idiom. The compiler sees http_client::get(url).await returning a Future that yields Result<Response, Error>. The ? operator unwraps the Ready variant, checks the Result, and either returns the Response or propagates the error upward.

Trust the chain. If you separate .await and ?, you will fight the type system.

What actually happens under the hood

Async functions do not run to completion like regular functions. They return a Future immediately. The runtime polls that future repeatedly until it reports Poll::Ready. When you write let response = http_client::get(url).await?;, the compiler desugars it into a state machine.

The state machine starts by initiating the network request. It returns Poll::Pending to the runtime, which schedules other tasks. When the network stack delivers the response, the runtime polls the future again. The state machine advances, receives the Result, and applies the ? operator. If the result is Ok, the state machine stores the response and moves to the next step. If the result is Err, the state machine immediately transitions to a final state that yields Poll::Ready(Err(e)). The runtime sees the error and propagates it to the caller.

This mechanical view explains why async error handling feels synchronous. The state machine pauses at each await point, but the control flow logic remains identical to a regular function. The ? operator does not care whether the value was computed instantly or arrived after a three hundred millisecond network hop. It only cares about the Result variant.

Convention aside: the community treats async fn as a direct replacement for fn in most APIs. You rarely need to manually implement Future or Poll. The compiler generates the state machine for you. Stick to async fn unless you are writing a custom executor or a low-level I/O driver.

Realistic example

Production code rarely uses Box<dyn Error> for everything. It defines specific error types so callers can react to timeouts differently than authentication failures. Here is how that looks in a realistic service layer.

use std::fmt;

/// Custom error type for the HTTP layer.
#[derive(Debug)]
enum HttpError {
    Timeout,
    NetworkFailure(String),
    ParseFailure(String),
}

impl fmt::Display for HttpError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HttpError::Timeout => write!(f, "Request timed out"),
            HttpError::NetworkFailure(msg) => write!(f, "Network error: {}", msg),
            HttpError::ParseFailure(msg) => write!(f, "Parse error: {}", msg),
        }
    }
}

impl std::error::Error for HttpError {}

/// Converts the underlying client error into our custom error enum.
impl From<reqwest::Error> for HttpError {
    fn from(err: reqwest::Error) -> Self {
        if err.is_timeout() {
            HttpError::Timeout
        } else {
            HttpError::NetworkFailure(err.to_string())
        }
    }
}

/// Fetches and validates a JSON payload, mapping client errors to HttpError.
async fn fetch_config(url: &str) -> Result<serde_json::Value, HttpError> {
    // Send the request. The ? operator uses From<reqwest::Error> automatically.
    let response = reqwest::get(url).await?;
    
    // Read the body. Another From conversion happens here if it fails.
    let text = response.text().await?;
    
    // Parse JSON. We manually map the serde error to our enum.
    let config = serde_json::from_str(&text).map_err(|e| HttpError::ParseFailure(e.to_string()))?;
    
    Ok(config)
}

Notice the From implementation. The ? operator automatically calls From::from when the error types do not match exactly. This is why reqwest::Error converts to HttpError without explicit casting. The map_err call on the JSON parse step is necessary because serde_json::Error does not implement From for HttpError by default. You bridge the gap manually.

Convention aside: keep error conversion close to the boundary where external crates enter your code. Do not scatter map_err calls throughout your business logic. Define the conversion once, then let ? do the heavy lifting.

Pitfalls and compiler signals

Async error handling trips up developers who treat futures like regular values. The compiler catches these mistakes early, but the error messages require a specific mental model.

Forgetting ? after .await creates nested results. If you write let response = reqwest::get(url).await; without the question mark, the variable type becomes Result<Response, reqwest::Error>. If you later try to call .json() on it, the compiler rejects you with E0599 (no method named json found for enum Result). The fix is always to attach ? immediately after .await.

Mixing incompatible error types triggers E0277 (trait bound not satisfied). If your function returns Result<T, HttpError> but you await a function that returns Result<T, std::io::Error>, the compiler stops you. It cannot convert std::io::Error to HttpError automatically. You must either implement From<std::io::Error> for HttpError or use map_err to translate the type.

Using unwrap() inside a spawned task causes a panic that kills only that task, not the entire program. This is a runtime trap, not a compile error. The tokio::spawn function returns a JoinHandle. If the task panics, calling .await on the handle yields Err(JoinError). You lose the error context and get a generic panic report. Replace unwrap() with ? or explicit match arms. You will thank yourself when debugging production timeouts.

Convention aside: the community treats unwrap() in async code as a code smell. Even in tests, prefer expect("descriptive reason") so the panic message explains which network call failed. Treat the JoinHandle error as a first-class citizen, not an afterthought.

Choosing your error strategy

Pick the right pattern for your codebase. Each approach trades off convenience, type safety, and runtime overhead.

Use Result<T, Box<dyn std::error::Error>> when you are writing quick scripts, prototypes, or CLI tools where compile time matters more than precise error categorization. Use a custom error enum with thiserror or manual From implementations when you are building a library or production service that needs to distinguish between timeouts, authentication failures, and data corruption. Use tokio::spawn with explicit JoinHandle error handling when you need to run independent tasks that should not block each other on failure. Use ? directly on .await when the error should immediately abort the current logical unit and propagate to the caller. Reach for match or if let when you need to retry a failed request or log a specific error code before returning.

Treat the error type as part of your public API. Changing it later breaks callers who pattern match on your variants.

Where to go next