Async/Await
Asynchronous programming lets your program handle multiple tasks concurrently without blocking on slow operations like network requests, file I/O, or timers. Rust's async/await syntax makes this feel as natural as writing sequential code while giving you full control over performance and memory.
In this lesson you will learn how Rust's Future trait works under the hood, how to write and compose async functions, how to run tasks concurrently, and how to handle errors in async contexts.
What Is a Future?
In Rust, an async operation is represented by a Future — a value that will produce a result at some point. A Future is lazy: it does nothing until something drives it to completion. That "something" is an async runtime.
When you write async fn, the compiler transforms your function body into a state machine that implements the Future trait. The .await keyword suspends the current task and yields control back to the runtime until the future is ready, rather than blocking the entire thread.
async fn compute_answer() -> u32 {
// This async function returns a Future<Output = u32>
// It can be awaited by the caller
42
}
#[tokio::main]
async fn main() {
// .await drives the future to completion
let answer = compute_answer().await;
println!("The answer is: {}", answer);
}
Writing Async Functions
Any function can be made asynchronous by adding the async keyword before fn. The return type becomes a Future implicitly, and you use .await inside async functions to pause execution until another future resolves.
use tokio::time::{sleep, Duration};
async fn fetch_user(id: u32) -> String {
// Simulate a network round-trip without blocking the thread
sleep(Duration::from_millis(50)).await;
format!("User #{id}")
}
async fn fetch_profile(user: &str) -> String {
sleep(Duration::from_millis(30)).await;
format!("Profile for {user}: senior rustacean")
}
#[tokio::main]
async fn main() {
let user = fetch_user(7).await;
println!("Fetched: {user}");
let profile = fetch_profile(&user).await;
println!("Fetched: {profile}");
}
The two calls above are sequential — each waits for the previous one before starting. This is correct when the second call depends on the first, but unnecessary when tasks are independent.
Running Tasks Concurrently with tokio::join!
When multiple futures are independent, you can run them concurrently using tokio::join!. This starts all futures at the same time and waits until all of them complete, which is faster than awaiting each one in sequence.
use tokio::time::{sleep, Duration};
async fn fetch_posts() -> Vec<String> {
sleep(Duration::from_millis(100)).await;
vec!["Post A".to_string(), "Post B".to_string()]
}
async fn fetch_comments() -> Vec<String> {
sleep(Duration::from_millis(80)).await;
vec!["Comment 1".to_string(), "Comment 2".to_string()]
}
async fn fetch_likes() -> u32 {
sleep(Duration::from_millis(60)).await;
42
}
#[tokio::main]
async fn main() {
// All three run concurrently — total wait is ~100ms, not 240ms
let (posts, comments, likes) = tokio::join!(
fetch_posts(),
fetch_comments(),
fetch_likes(),
);
println!("Posts: {:?}", posts);
println!("Comments: {:?}", comments);
println!("Likes: {}", likes);
}
For cases where you want to spawn truly independent background tasks that run on the thread pool, use tokio::spawn. Unlike join!, spawned tasks can run even if you do not immediately await them.
Error Handling in Async Code
Async functions work naturally with Result. You can use the ? operator inside async functions exactly as you would in synchronous code, and propagate errors up the call chain.
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(u32),
Unauthorized,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(id) => write!(f, "Resource {id} not found"),
AppError::Unauthorized => write!(f, "Access denied"),
}
}
}
async fn load_resource(id: u32, token: &str) -> Result<String, AppError> {
if token.is_empty() {
return Err(AppError::Unauthorized);
}
if id == 0 {
return Err(AppError::NotFound(id));
}
Ok(format!("Resource #{id}"))
}
async fn handle_request(id: u32, token: &str) -> Result<(), AppError> {
// The ? operator works inside async functions
let resource = load_resource(id, token).await?;
println!("Loaded: {resource}");
Ok(())
}
#[tokio::main]
async fn main() {
let cases = vec![
(1, "valid-token"),
(0, "valid-token"),
(5, ""),
];
for (id, token) in cases {
match handle_request(id, token).await {
Ok(_) => {}
Err(e) => println!("Error: {e}"),
}
}
}
Spawning Background Tasks
tokio::spawn lets you fire off a task and continue without waiting for it. The spawned task runs concurrently and returns a JoinHandle you can await later to collect the result or detect panics.
use tokio::time::{sleep, Duration};
async fn background_sync(label: &'static str) -> String {
sleep(Duration::from_millis(50)).await;
format!("{label}: sync complete")
}
#[tokio::main]
async fn main() {
// Spawn two tasks that run in parallel
let handle_a = tokio::spawn(background_sync("cache"));
let handle_b = tokio::spawn(background_sync("index"));
// Do other work here while the tasks run...
println!("Tasks are running in the background");
// Collect results when needed
let result_a = handle_a.await.expect("Task A panicked");
let result_b = handle_b.await.expect("Task B panicked");
println!("{result_a}");
println!("{result_b}");
}
Try It Yourself
Build a small async pipeline that fetches multiple data sources concurrently, filters the results, and reports a summary. Modify it to introduce an error path and handle it gracefully.
use tokio::time::{sleep, Duration};
#[derive(Debug)]
struct Record {
id: u32,
value: i64,
}
async fn fetch_batch(start: u32, count: u32) -> Vec<Record> {
sleep(Duration::from_millis(40)).await;
(start..start + count)
.map(|id| Record { id, value: (id as i64) * 10 - 50 })
.collect()
}
async fn process(records: Vec<Record>) -> (usize, i64) {
let positive: Vec<_> = records.into_iter().filter(|r| r.value > 0).collect();
let total: i64 = positive.iter().map(|r| r.value).sum();
(positive.len(), total)
}
#[tokio::main]
async fn main() {
// Fetch three batches concurrently
let (batch_a, batch_b, batch_c) = tokio::join!(
fetch_batch(0, 5),
fetch_batch(5, 5),
fetch_batch(10, 5),
);
let all_records: Vec<Record> = batch_a
.into_iter()
.chain(batch_b)
.chain(batch_c)
.collect();
println!("Fetched {} records total", all_records.len());
let (count, total) = process(all_records).await;
println!("Positive records: {count}, sum: {total}");
}
Try extending this by: wrapping fetch_batch to return Result<Vec<Record>, String> and simulating a failure for one of the batches, then collecting only successful batches.
Key Takeaways
async fntransforms a function into one that returns aFuture, which is lazy until driven by a runtime.awaitsuspends the current task and yields to the runtime without blocking the OS thread- Use
tokio::join!to run independent futures concurrently and collect all results - Use
tokio::spawnto fire-and-forget background tasks that return aJoinHandle - The
?operator works inside async functions, making error propagation idiomatic - Async code in Rust is zero-cost: you pay only for the concurrency you actually use
- An async runtime (like Tokio) is required; the standard library provides the
Futuretrait but no executor
Pro Tip: Avoid holding
std::sync::MutexGuard(or any non-Send type) across an.awaitpoint. The compiler will catch this, but the error message can be cryptic. Usetokio::sync::Mutexwhen you need a lock that spans await points — it releases the lock correctly when the task is suspended, preventing deadlocks.
Next Steps
Now that you understand async programming, we'll learn about serde — Rust's powerful serialization framework for working with JSON, TOML, and other data formats.
Next lesson
Serde and JSON
Learn how to serialize and deserialize data structures to and from JSON using the serde and serde_json crates
25 min