Skip to main content

Rust Async/Await: Asynchronous Programming Guide (2026)

Asynchronous programming in Rust allows a single thread to handle thousands of concurrent tasks efficiently, making it ideal for I/O-bound applications like web servers and network clients. Unlike threads (which are preempted by the OS and require ~2 MB of stack each), async tasks are lightweight and cooperatively scheduled on an executor. Rust's async/await syntax makes asynchronous code look almost synchronous, dramatically improving readability compared to callback-heavy approaches in other languages.

Key Takeaways​

  • Async enables high concurrency: Thousands of tasks on a few OS threads; much lower overhead than threads
  • async fn returns a Future: The function does not execute until awaited
  • .await pauses execution: Suspends until the Future completes, allowing the executor to run other tasks
  • Async runtimes required: tokio, async-std, or smol provide the executor; Rust's core library does not
  • Futures are lazy: Calling async_function() does not start the work; only .await does

What Is Asynchronous Programming?​

Asynchronous programming is a concurrency model where a single thread executes multiple logical tasks by switching between them as they become ready. When an async task waits for I/O (network request, file read, etc.), the thread can switch to another task instead of blocking.

Key characteristics:

  • Non-blocking: I/O operations do not freeze the thread.
  • Cooperative scheduling: Tasks yield control (via .await) rather than being preempted.
  • Lightweight: A single thread can manage thousands of async tasks.

Compare this to threads, which are preemptively scheduled by the OS and each requires 1–2 MB of stack memory. A single machine can run thousands of async tasks but only dozens of threads efficiently.

How Do async fn and .await Work?​

An async fn is syntax sugar for a function that returns a Future. A Future is a type that represents a value that will be available at some point in the future.

async fn hello() {
println!("Hello, world!");
}

fn main() {
// Calling async fn returns a Future immediately (no work happens yet)
let future = hello(); // future is not awaited, so hello() does not print yet
// To actually run the code, we need an async runtime
}

The Future is inert until awaited. You use the .await operator to pause the current async function and let the executor run the awaited Future:

async fn hello() {
println!("Hello from hello!");
}

async fn main_logic() {
println!("Starting...");
hello().await; // Pauses here until hello() completes
println!("Done!");
}

If you just call hello() without .await, the function body never executes, and the Future is dropped.

How Do You Set Up and Run an Async Runtime?​

Rust's standard library provides the Future trait and async syntax, but not an executor. You need an async runtime. The most popular is tokio.

Using tokio with the #[tokio::main] Macro​

Add tokio to Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }

Then use the #[tokio::main] attribute:

use tokio;

async fn fetch_data() {
println!("Fetching data...");
}

#[tokio::main]
async fn main() {
fetch_data().await;
println!("Done!");
}

What #[tokio::main] does:

  1. Transforms main into an async function.
  2. Sets up the tokio runtime (spawning a thread pool by default).
  3. Blocks on the future returned by main, running all .await points until completion.

Under the hood, it expands to:

fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { /* your async code */ })
}

How Do You Spawn Concurrent Tasks with tokio::spawn?​

To run multiple async tasks concurrently, use tokio::spawn:

use tokio;

async fn task(id: u32) {
println!("Task {} started", id);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("Task {} completed", id);
}

#[tokio::main]
async fn main() {
let handle1 = tokio::spawn(task(1));
let handle2 = tokio::spawn(task(2));

handle1.await.unwrap();
handle2.await.unwrap();
}

tokio::spawn returns a JoinHandle immediately. Awaiting the handle waits for the task to complete. Both tasks run concurrently (the output will be interleaved).

What Is the Difference Between Async and Threads in Rust?​

AspectAsyncThreads
Memory per task~64 bytes~2 MB
Tasks per core10,000s10s–100s
Context switchCooperative (at .await points)Preemptive (OS controlled)
OverheadVery lowModerate to high
Ideal forI/O-bound work (web servers, APIs)CPU-bound work, or tasks needing true parallelism
Syntaxasync/.awaitClosures, move capturing

Use async for I/O-bound tasks where you have many concurrent operations (e.g., 1,000 simultaneous HTTP requests). Use threads for CPU-bound tasks or when you need true parallelism across multiple cores.

How Do You Handle Multiple Concurrent Futures?​

To wait for multiple futures to complete, use tokio::join! or futures::join_all:

Using tokio::join!​

use tokio;

async fn fetch_user() -> String {
"Alice".to_string()
}

async fn fetch_posts() -> Vec<String> {
vec!["Post 1".to_string()]
}

#[tokio::main]
async fn main() {
let (user, posts) = tokio::join!(fetch_user(), fetch_posts());
println!("User: {}, Posts: {:?}", user, posts);
}

tokio::join! waits for all futures in parallel and returns a tuple of results.

Using futures::join_all for Dynamic Collections​

use futures::future;

async fn task(id: u32) -> u32 {
id * 2
}

#[tokio::main]
async fn main() {
let tasks = vec![
task(1),
task(2),
task(3),
];
let results = future::join_all(tasks).await;
println!("{:?}", results); // [2, 4, 6]
}

What Are Common Async Patterns?​

Timeout​

Use tokio::time::timeout to add a time limit:

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

async fn slow_operation() {
tokio::time::sleep(Duration::from_secs(5)).await;
}

#[tokio::main]
async fn main() {
match timeout(Duration::from_secs(2), slow_operation()).await {
Ok(_) => println!("Completed"),
Err(_) => println!("Timed out"),
}
}

Select Multiple Futures​

Use tokio::select! to run multiple futures and respond to whichever completes first:

use tokio;

#[tokio::main]
async fn main() {
tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => {
println!("Sleep finished first");
}
_ = some_async_task() => {
println!("Task finished first");
}
}
}

async fn some_async_task() {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}

What Pitfalls Should You Avoid?​

  1. Forgetting .await: Calling an async function without .await does nothing.
  2. Blocking the executor: Do not call blocking operations (e.g., std::fs::read_file) inside async functions. Use tokio::fs or tokio::task::spawn_blocking.
  3. Not handling panics in spawn: A panicked task does not crash the entire program; always handle the Result from JoinHandle::await.
  4. Holding locks across .await points: Mutex locks can deadlock if held while awaiting. Use message passing or lock-free structures instead.

Frequently Asked Questions​

Can you use .await in non-async functions?​

No. .await can only appear inside an async fn or async block. If you need to call async code from synchronous code, wrap it in tokio::runtime::Runtime::block_on().

What happens if an async task panics?​

The panic is caught and stored in the JoinHandle. Awaiting the handle will return Err(JoinError). The entire runtime does not crash. Always check the result:

match handle.await {
Ok(result) => println!("Task succeeded: {:?}", result),
Err(e) => println!("Task panicked or was cancelled: {:?}", e),
}

How do you cancel an async task?​

Dropping a JoinHandle does not cancel the task; it just stops awaiting it. To actually cancel, check for cancellation signals (e.g., tokio::sync::broadcast or CancellationToken from the tokio_util crate).

Is async/await faster than threads?​

For I/O-bound workloads with high concurrency, yes—async is much faster (lower memory, less overhead). For CPU-bound work, threads (or rayon for parallelism) are better because they can run on multiple cores.

Which async runtime should I use?​

tokio is the most popular and mature. async-std is simpler and closer to Rust's standard library API. smol is minimal and composable. Most production code uses tokio.

Further Reading​