TL;DR
- Rust async is zero-cost: no runtime overhead for unused futures.
async fnreturns aFuture— it does NOTHING until.awaited or polled.- Async traits are now stable (Rust 1.75+) — no more
async-traitcrate for most cases. - Runtime needed to drive futures: Tokio (most popular) or async-std.
Step 1: How Async Works in Rust
Rust's async model is fundamentally different from JavaScript's or Python's: futures are lazy (nothing happens until polled), there's no built-in runtime (you choose tokio, async-std, or smol), and the compiler transforms async functions into state machines at compile time with zero heap allocation. This design exists because Rust targets systems programming where you can't afford garbage collection pauses or hidden allocations. The result is async code that's as fast as hand-written state machines but readable like sequential code.
Futures Are Lazy
// This does NOT start any work — just creates a Future
async fn fetch_data() -> String {
// ... network call ...
"data".to_string()
}
#[tokio::main]
async fn main() {
let future = fetch_data(); // Nothing happens yet!
let result = future.await; // NOW it executes
println!("{result}");
}
Under the Hood
Every async fn is transformed by the compiler into a state machine:
// async fn is sugar for:
fn fetch_data() -> impl Future<Output = String> {
// Returns a state machine that can be polled
}
The runtime (Tokio) repeatedly polls the future until it's ready:
Future::poll() → Pending (not ready, will be woken later)
→ Ready(value) (done!)
Step 2: Basic Async Patterns
These patterns cover the two fundamental ways to run async operations: sequentially (one after another, total time = sum of all) and concurrently (all at once, total time = slowest one). join! runs futures concurrently on the same task, tokio::spawn runs them on separate tasks (true parallelism across threads). Knowing when to use which determines whether your I/O-bound application is fast or needlessly serialized. Most web servers need concurrent request handling, while ordered operations (fetch then process) stay sequential.
Sequential vs Concurrent
use tokio::time::{sleep, Duration};
// Sequential — takes 2 seconds total
async fn sequential() {
let a = fetch_user(1).await; // 1 second
let b = fetch_user(2).await; // 1 second
println!("{a}, {b}");
}
// Concurrent — takes 1 second total
async fn concurrent() {
let (a, b) = tokio::join!(
fetch_user(1),
fetch_user(2),
);
println!("{a}, {b}");
}
// Race — first to complete wins
async fn race() {
tokio::select! {
result = fetch_from_cache() => println!("Cache: {result}"),
result = fetch_from_db() => println!("DB: {result}"),
}
}
Spawning Tasks (True Parallelism)
// Spawn independent tasks on the runtime
async fn parallel_work() {
let handle1 = tokio::spawn(async {
heavy_computation_1().await
});
let handle2 = tokio::spawn(async {
heavy_computation_2().await
});
// Wait for both
let (r1, r2) = tokio::join!(handle1, handle2);
let result1 = r1.unwrap();
let result2 = r2.unwrap();
}
tokio::join! vs tokio::spawn
join! |
spawn |
|
|---|---|---|
| Runs on | Same task | Separate tasks (may be different threads) |
| Cancellation | If one panics, others cancel | Independent — one panic doesn't affect others |
| Use case | Related concurrent work | Independent background work |
Send bound |
Not required | Required (task may move between threads) |
Step 3: Async Traits (Stable!)
Async traits were Rust's most-awaited feature (pun intended) because without them, you couldn't define trait methods that return futures — forcing workarounds like the async-trait crate that added heap allocation and dynamic dispatch. Stable async traits (Rust 1.75+) let you write async fn directly in trait definitions, enabling zero-cost async interfaces for the first time. This unlocks clean async service abstractions, repository patterns, and middleware chains without the performance penalty of boxing futures.
Before (Required async-trait crate)
// Old way — needed proc macro
use async_trait::async_trait;
#[async_trait]
trait Database {
async fn get(&self, id: &str) -> Option<Record>;
}
#[async_trait]
impl Database for PostgresDb {
async fn get(&self, id: &str) -> Option<Record> {
// ...
}
}
Now (Native — Rust 1.75+)
// No crate needed! Native async traits
trait Database {
async fn get(&self, id: &str) -> Option<Record>;
async fn save(&self, record: &Record) -> Result<(), DbError>;
}
impl Database for PostgresDb {
async fn get(&self, id: &str) -> Option<Record> {
sqlx::query_as("SELECT * FROM records WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
.ok()
.flatten()
}
async fn save(&self, record: &Record) -> Result<(), DbError> {
sqlx::query("INSERT INTO records (id, data) VALUES ($1, $2)")
.bind(&record.id)
.bind(&record.data)
.execute(&self.pool)
.await?;
Ok(())
}
}
Limitation: dyn Trait with async methods
Native async traits don't support dyn Trait directly. For dynamic dispatch, still use async-trait or the trait_variant crate:
// If you need Box<dyn Database>, use:
use trait_variant::make;
#[make(SendDatabase: Send)]
trait Database {
async fn get(&self, id: &str) -> Option<Record>;
}
// Now you can use: Box<dyn SendDatabase>
Step 4: Error Handling in Async
Async error handling in Rust combines the ? operator with Result types, but adds complexity because errors must be Send + 'static when crossing task boundaries (spawned tasks run on any thread). The pattern of using anyhow for applications and thiserror for libraries applies equally to async code. Understanding how to propagate errors through join!, handle partial failures in try_join!, and deal with JoinError from spawned tasks is essential for robust async applications.
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("Database error: {0}")]
Db(#[from] sqlx::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Not found: {0}")]
NotFound(String),
}
// Async function with proper error handling
async fn get_user_with_posts(id: &str) -> Result<UserWithPosts, AppError> {
let user = db.get_user(id).await?.ok_or(AppError::NotFound(id.to_string()))?;
let posts = db.get_posts(id).await?;
Ok(UserWithPosts { user, posts })
}
Timeout Pattern
use tokio::time::{timeout, Duration};
async fn fetch_with_timeout() -> Result<String, AppError> {
let result = timeout(Duration::from_secs(5), fetch_data())
.await
.map_err(|_| AppError::Timeout)?;
result
}
Retry Pattern
async fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, AppError> {
let mut attempts = 0;
loop {
match reqwest::get(url).await {
Ok(response) => return Ok(response.text().await?),
Err(e) if attempts < max_retries => {
attempts += 1;
let delay = Duration::from_millis(100 * 2u64.pow(attempts)); // Exponential backoff
tokio::time::sleep(delay).await;
}
Err(e) => return Err(AppError::Http(e)),
}
}
}
Step 5: Streams (Async Iterators)
Streams are the async equivalent of iterators — they yield values over time rather than all at once. They exist because many data sources are naturally streaming: WebSocket messages, database query results, file chunks, and server-sent events. Without streams, you'd either buffer everything in memory (wasteful for large datasets) or write complex manual polling loops. The StreamExt trait provides familiar combinators (map, filter, take) that work on async sequences just like iterator adapters work on sync ones.
use tokio_stream::{StreamExt, wrappers::ReceiverStream};
use tokio::sync::mpsc;
// Create a stream from a channel
async fn process_events() {
let (tx, rx) = mpsc::channel(100);
let mut stream = ReceiverStream::new(rx);
// Producer
tokio::spawn(async move {
for i in 0..10 {
tx.send(i).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
}
});
// Consumer — process items as they arrive
while let Some(item) = stream.next().await {
println!("Got: {item}");
}
}
// Stream processing with combinators
async fn filtered_stream() {
let stream = tokio_stream::iter(0..100)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.take(10);
tokio::pin!(stream);
while let Some(val) = stream.next().await {
println!("{val}");
}
}
Step 6: Common Async Pitfalls
Async Rust has unique footguns that don't exist in garbage-collected languages. Holding a MutexGuard across an .await causes deadlocks because the task might resume on a different thread. Accidentally making futures !Send (by holding non-Send types across await points) prevents spawning them on multi-threaded runtimes. Blocking the runtime with synchronous I/O starves other tasks. These pitfalls are well-known but still catch experienced developers — understanding them upfront saves hours of debugging.
Pitfall 1: Holding locks across .await
// ❌ BAD — Mutex guard held across await point (can deadlock)
async fn bad(data: Arc<Mutex<Vec<String>>>) {
let mut guard = data.lock().unwrap();
let result = fetch_data().await; // guard is still held!
guard.push(result);
}
// ✅ GOOD — Drop the lock before awaiting
async fn good(data: Arc<Mutex<Vec<String>>>) {
let result = fetch_data().await;
let mut guard = data.lock().unwrap();
guard.push(result);
// Or use tokio::sync::Mutex for async-aware locking
}
Pitfall 2: Forgetting Send bounds for spawned tasks
// ❌ Won't compile — Rc is not Send
async fn not_send() {
let data = Rc::new(vec![1, 2, 3]);
tokio::spawn(async move {
println!("{:?}", data); // Error: Rc is not Send
});
}
// ✅ Use Arc instead
async fn is_send() {
let data = Arc::new(vec![1, 2, 3]);
tokio::spawn(async move {
println!("{:?}", data); // ✅ Arc is Send
});
}
Pitfall 3: Blocking the async runtime
// ❌ BAD — blocks the runtime thread
async fn bad() {
std::thread::sleep(Duration::from_secs(5)); // Blocks!
std::fs::read_to_string("big_file.txt"); // Blocks!
}
// ✅ GOOD — use async alternatives or spawn_blocking
async fn good() {
tokio::time::sleep(Duration::from_secs(5)).await;
tokio::fs::read_to_string("big_file.txt").await;
// For CPU-heavy sync code:
let result = tokio::task::spawn_blocking(|| {
heavy_computation() // Runs on dedicated blocking thread pool
}).await.unwrap();
}
Interview Questions
-
How does async/await work in Rust vs JavaScript?
- Both use async/await syntax. But Rust futures are lazy (do nothing until polled), zero-cost (no heap allocation by default), and need an explicit runtime. JS promises are eager (start immediately) and have a built-in event loop.
-
What changed with async traits?
- Since Rust 1.75, you can write
async fndirectly in trait definitions without theasync-traitcrate. Limitation: dynamic dispatch (dyn Trait) still needs workarounds.
- Since Rust 1.75, you can write
-
When would you use
tokio::spawnvstokio::join!?join!for related concurrent work within the same task.spawnfor independent work that should run regardless of the parent task's fate (requiresSend).
-
What's
spawn_blockingfor?- Running synchronous/CPU-heavy code without blocking the async runtime's thread pool. It executes on a dedicated blocking thread pool.