Rust Async/Await
Imagine a restaurant. On the Concurrency page, you learned about threads. That’s like hiring one waiter per table. It works, but waiters are expensive, and most of the time they just stand there waiting for the kitchen.
Async is like having one waiter who takes many orders. The waiter takes an order from table 1, gives it to the kitchen, and while the food cooks, goes to table 2. Nobody stands around doing nothing. That’s the idea behind async and .await in Rust: while one task is waiting (for the network, a file, a timer), another task gets to run.
Async shines when your program spends a lot of time waiting — web servers, API clients, chat apps, bots. This page uses tokio, the most popular async runtime for Rust.
Setting up tokio
Section titled “Setting up tokio”Rust’s standard library gives you the async and .await keywords, but not the “engine” that actually runs async code. That engine is called a runtime, and tokio is the one most projects use. Add it to a new project:
cargo new async_democd async_democargo add tokio --features fullThat adds this line to your Cargo.toml:
[dependencies]tokio = { version = "1", features = ["full"] }The full feature turns on everything (timers, tasks, networking, and more). It’s the easiest choice while you learn. The “Try it Yourself” buttons on this page work too — the Rust Playground has tokio built in.
Your first async program
Section titled “Your first async program”use std::time::Duration;
async fn make_coffee() -> String { tokio::time::sleep(Duration::from_millis(20)).await; String::from("coffee")}
#[tokio::main]async fn main() { println!("Ordering..."); let drink = make_coffee().await; println!("Here is your {}", drink);}Output:
Ordering...Here is your coffeeThree new things here:
async fnmakes a function that can pause in the middle and continue later..awaitmeans “wait for this to finish — and while we wait, let other tasks run”. You can only use.awaitinside anasyncfunction.#[tokio::main]starts the tokio runtime and runs yourasync fn mainon it. Without it,maincan’t beasync.
tokio::time::sleep is the async version of “wait a bit”. It pauses only this task, not the whole thread.
Futures are lazy
Section titled “Futures are lazy”Here is the part that surprises most people. Calling an async fn does not run it. It gives you back a future — a value that means “work that will happen later”. Nothing happens until you .await it:
async fn say_hello() { println!("Hello from inside the future!");}
#[tokio::main]async fn main() { let future = say_hello(); // nothing runs yet println!("Future created, but not started");
future.await; // now it runs println!("Done");}Output:
Future created, but not startedHello from inside the future!DoneThink of a future like a written order slip. Writing the slip doesn’t cook the food. Handing it to the kitchen (.await) does.
Running tasks at the same time with join!
Section titled “Running tasks at the same time with join!”So far, we waited for one thing at a time. The real power comes from waiting for several things at once. tokio::join! runs several futures together and waits until all of them are done. It gives you their results as a tuple:
use std::time::Duration;use tokio::time::sleep;
async fn fetch_user() -> String { sleep(Duration::from_millis(30)).await; // pretend this is a network call String::from("Ana")}
async fn fetch_order_count() -> u32 { sleep(Duration::from_millis(20)).await; 3}
#[tokio::main]async fn main() { let (user, orders) = tokio::join!(fetch_user(), fetch_order_count()); println!("{} has {} orders", user, orders);}Output:
Ana has 3 ordersIf you awaited them one after the other, the total wait would be about 30 + 20 = 50ms. With join!, both waits happen at the same time, so it takes about 30ms — the time of the slowest one. With real network calls, that difference adds up fast.
Spawning tasks with tokio::spawn
Section titled “Spawning tasks with tokio::spawn”join! is great when you have a fixed number of futures. When you have a list of jobs, use tokio::spawn. It hands a future to the runtime as a separate task, starts it right away, and gives you back a JoinHandle. Later, you .await the handle to get the result:
use std::time::Duration;use tokio::time::sleep;
async fn download(id: u64) -> String { sleep(Duration::from_millis(50 - id * 10)).await; format!("file {} downloaded", id)}
#[tokio::main]async fn main() { let mut handles = Vec::new();
for id in 1..=3 { let handle = tokio::spawn(download(id)); // starts running now handles.push(handle); }
for handle in handles { let result = handle.await.unwrap(); println!("{}", result); }}Output:
file 1 downloadedfile 2 downloadedfile 3 downloadedLook closely: file 3 has the shortest sleep, so it actually finishes first. But we print the results in the order we await the handles, so the output is always 1, 2, 3. All three downloads still ran at the same time.
Why the .unwrap()? Awaiting a JoinHandle gives a Result, because the task might have panicked. If it did, you get an Err instead of crashing your whole program. This looks a lot like thread::spawn and .join() from the Concurrency page — on purpose.
Common mistakes
Section titled “Common mistakes”Forgetting .await. Since futures are lazy, forgetting .await means the work never happens. If you use the value, the compiler stops you:
async fn get_number() -> u32 { 5}
#[tokio::main]async fn main() { let n = get_number(); // forgot .await println!("{}", n + 1);}error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>` --> src/main.rs:8:22 |8 | println!("{}", n + 1); | - ^ - {integer} | | | impl Future<Output = u32>The message says n is a future of a u32, not a u32. The fix is let n = get_number().await;. If the function returns nothing and you just call it, like save_file();, you only get a warning — so read your warnings:
warning: unused implementer of `Future` that must be used |7 | save_file(); | ^^^^^^^^^^^ | = note: futures do nothing unless you `.await` or poll themUsing std::thread::sleep inside async code. This one compiles fine, which makes it sneaky:
async fn slow_task() { std::thread::sleep(std::time::Duration::from_secs(1)); // blocks the whole thread!}std::thread::sleep freezes the entire thread. The runtime can’t switch to other tasks, so everything on that thread stops — like a waiter who falls asleep standing at one table. Inside async code, always use tokio::time::sleep(...).await instead. The same goes for other slow, blocking work like reading huge files with std::fs: tokio has async versions (tokio::fs), or you can use tokio::task::spawn_blocking.
Expecting async to make CPU work faster. Async helps when tasks are waiting. It doesn’t help when tasks are busy, like resizing images or crunching numbers. One waiter can’t cook faster by taking more orders. For heavy CPU work, use threads (or a crate like rayon).
Async or threads?
Section titled “Async or threads?”| Use async when… | Use threads when… |
|---|---|
| Most of the time is spent waiting (network, database, timers) | The work is heavy calculation |
| You need thousands of tasks at once (like a web server) | You only need a few things running in parallel |
| You’re using async crates like reqwest or axum | You want to avoid adding a runtime dependency |
You don’t have to pick only one. Many real programs use async for networking and threads for heavy work.
Need tasks to pass messages to each other? Tokio has its own channel, tokio::sync::mpsc. It works like the standard one on the Channels page, except you write rx.recv().await, so waiting for a message doesn’t block the thread.
What people build with it
Section titled “What people build with it”Most of the async Rust ecosystem is built on tokio. Two crates you will see everywhere:
- reqwest — an HTTP client for calling web APIs. You write
reqwest::get(url).awaitto fetch a page. - axum — a web framework for building servers and APIs, made by the tokio team.
Both pair nicely with serde_json for reading and writing JSON.
Practice
Section titled “Practice”1. Make breakfast all at once
Section titled “1. Make breakfast all at once”Write three async functions: make_toast (sleeps 30ms), fry_eggs (sleeps 20ms) and brew_coffee (sleeps 10ms). Each returns a String with the food’s name. Run all three at the same time and print what’s ready. Hint: tokio::join! takes several futures and gives back a tuple of their results.
Show solution
use std::time::Duration;use tokio::time::sleep;
async fn make_toast() -> String { sleep(Duration::from_millis(30)).await; String::from("toast")}
async fn fry_eggs() -> String { sleep(Duration::from_millis(20)).await; String::from("eggs")}
async fn brew_coffee() -> String { sleep(Duration::from_millis(10)).await; String::from("coffee")}
#[tokio::main]async fn main() { let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee()); println!("Breakfast: {}, {} and {}", toast, eggs, coffee);}Output:
Breakfast: toast, eggs and coffeeCoffee is ready first, but the tuple always comes back in the order you listed the futures. The whole breakfast takes about 30ms (the slowest dish), not 60ms.
2. Square numbers in the background
Section titled “2. Square numbers in the background”Write async fn square_later(n: u64) -> u64 that sleeps for 10 * n milliseconds and then returns n * n. In main, spawn one task for each number from 1 to 4, then await them all and print the total. Hint: push each tokio::spawn(...) handle into a Vec, then loop over the handles and add up handle.await.unwrap().
Show solution
use std::time::Duration;use tokio::time::sleep;
async fn square_later(n: u64) -> u64 { sleep(Duration::from_millis(10 * n)).await; n * n}
#[tokio::main]async fn main() { let mut handles = Vec::new(); for n in 1..=4 { handles.push(tokio::spawn(square_later(n))); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("total = {total}");}Output:
total = 30All four tasks start right away and sleep at the same time, so this takes about 40ms, not 10 + 20 + 30 + 40 = 100ms.
- Async lets one thread handle many tasks by switching between them while they wait.
- Rust needs a runtime to run async code. Add tokio with
cargo add tokio --features fulland markmainwith#[tokio::main]. - An
async fnreturns a future. Futures are lazy — nothing runs until you.await. - Use
tokio::join!to wait on several futures at once, andtokio::spawnto start tasks and get aJoinHandle. - Never use
std::thread::sleepin async code — usetokio::time::sleep(...).await. - Async is for waiting, not for heavy CPU work. Use threads for that.
Quick check
Section titled “Quick check”Quick check
1. What happens when you call an async fn without .await?
2. What does #[tokio::main] do?
3. Two futures sleep for 30ms and 20ms. About how long does tokio::join! on both take?
4. Why is std::thread::sleep a problem inside async code?
5. Which job is async the best fit for?
Score: 0 / 5
Read more
Section titled “Read more”- Fundamentals of Asynchronous Programming — The Rust Book: the official chapter on futures,
async,.awaitand streams. - The tokio tutorial: a hands-on guide that builds a small Redis-like server, covering tasks, shared state and channels.
- tokio on docs.rs: the API reference for
tokio::spawn,tokio::time,tokio::syncand the rest. - Asynchronous Programming in Rust (the async book): a deeper look at how futures and executors work under the hood.