Skip to main content

Error Handling

Core rule:

Async Rust uses the same error handling model as sync Rust.

That means:

  • Result<T, E>
  • ? operator
  • Custom error types
  • From / Into

Async does not add exceptions, promises, or magic propagation.

The only difference:

Errors travel through futures.

Async functions and Result

An async function returning a result:

async fn fetch_data() -> Result<String, std::io::Error> {
Ok("data".to_string())
}

This actually returns:

impl Future<Output = Result<String, std::io::Error>>

So .await gives you a Result.

Using ? inside async functions

Nothing special here.

use tokio::fs;

async fn read_file() -> Result<String, std::io::Error> {
let content = fs::read_to_string("data.txt").await?;
Ok(content)
}
  • If read_to_string fails
  • The error is returned immediately
  • The future resolves to Err(...)

Handling errors at .await sites

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let content = read_file().await?;
println!("{content}");
Ok(())
}

Clean. Linear. No nesting.

Error handling across .await boundaries

Each .await is a potential suspension point.

But:

  • Errors do not get lost
  • Stack traces remain logical (not real call stacks)
async fn step1() -> Result<(), &'static str> {
Err("step1 failed")
}

async fn step2() -> Result<(), &'static str> {
step1().await?;
Ok(())
}

If step1 fails:

  • step2 stops immediately
  • Error propagates normally

Custom error types in async code

Same as sync Rust.

Using thiserror

use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),

#[error("Invalid input")]
InvalidInput,
}

Async function using it

use tokio::fs;

async fn load_config() -> Result<String, AppError> {
let content = fs::read_to_string("config.toml").await?;
if content.is_empty() {
return Err(AppError::InvalidInput);
}
Ok(content)
}

Error handling with spawned tasks (very important)

The JoinHandle problem

let handle = tokio::spawn(async {
do_work().await
});

The type is:

JoinHandle<Result<T, E>>

So you get two layers of error.

async fn do_work() -> Result<(), &'static str> {
Err("work failed")
}

#[tokio::main]
async fn main() {
let handle = tokio::spawn(do_work());

match handle.await {
Ok(Ok(())) => println!("success"),
Ok(Err(e)) => println!("task error: {e}"),
Err(e) => println!("task panicked: {e}"),
}
}
  • Err(JoinError) → task panicked or was cancelled
  • Err(E) → task ran but returned error

Cancelling tasks and errors

Dropping a task handle cancels the task.

let handle = tokio::spawn(async {
loop {
println!("working...");
tokio::time::sleep(Duration::from_secs(1)).await;
}
});

drop(handle); // task is cancelled
  • No error is returned
  • Task simply stops
  • Use select! if you need cleanup

Error handling with select!

use tokio::select;

async fn might_fail() -> Result<(), &'static str> {
Err("oops")
}

#[tokio::main]
async fn main() {
let result = select! {
res = might_fail() => res,
_ = tokio::time::sleep(Duration::from_secs(1)) => Ok(()),
};

if let Err(e) = result {
println!("error: {e}");
}
}

Error propagation works naturally.

Streams and error handling

Stream<Item = Result<T, E>> pattern

Very common.

use futures::StreamExt;

while let Some(item) = stream.next().await {
match item {
Ok(value) => println!("value: {value}"),
Err(e) => {
println!("error: {e}");
break;
}
}
}

Alternative: fail-fast

let values: Result<Vec<_>, _> = stream.collect().await;

Timeouts and errors

Timeouts convert slow futures into errors.

use tokio::time::{timeout, Duration};

let result = timeout(Duration::from_secs(1), async {
do_work().await
}).await;

match result {
Ok(Ok(())) => println!("success"),
Ok(Err(e)) => println!("task error: {e}"),
Err(_) => println!("timed out"),
}

Panic vs Result in async

Panics

  • Kill the task
  • Do not kill the runtime
  • Propagate as JoinError

Prefer Result for:

  • IO failures
  • Business logic errors
  • Recoverable conditions

Common async error-handling mistakes

  • Ignoring JoinHandle
tokio::spawn(do_work()); // error silently dropped
  • Using unwrap() in async tasks: Panics get harder to trace.
  • Mixing error types across layers: Use From conversions or error enums.

Mental model

Think of async error handling as:

“Same rules, delayed delivery.”

Errors:

  • Are values
  • Travel through futures
  • Surface at .await