Skip to content

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.

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:

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

That 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.

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 coffee
▶ Try it Yourself

Three new things here:

  • async fn makes a function that can pause in the middle and continue later.
  • .await means “wait for this to finish — and while we wait, let other tasks run”. You can only use .await inside an async function.
  • #[tokio::main] starts the tokio runtime and runs your async fn main on it. Without it, main can’t be async.

tokio::time::sleep is the async version of “wait a bit”. It pauses only this task, not the whole thread.

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 started
Hello from inside the future!
Done
▶ Try it Yourself

Think of a future like a written order slip. Writing the slip doesn’t cook the food. Handing it to the kitchen (.await) does.

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 orders
▶ Try it Yourself

If 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.

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 downloaded
file 2 downloaded
file 3 downloaded
▶ Try it Yourself

Look 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.

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 them

Using 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).

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.

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).await to 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.

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 coffee
▶ Try it Yourself

Coffee 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.

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 = 30
▶ Try it Yourself

All 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 full and mark main with #[tokio::main].
  • An async fn returns a future. Futures are lazy — nothing runs until you .await.
  • Use tokio::join! to wait on several futures at once, and tokio::spawn to start tasks and get a JoinHandle.
  • Never use std::thread::sleep in async code — use tokio::time::sleep(...).await.
  • Async is for waiting, not for heavy CPU work. Use threads for that.

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