XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksRustAsync/Await & Async Traits

Async/Await & Async Traits

asyncawaitfuturestraitshandbook

TL;DR

  • Rust async is zero-cost: no runtime overhead for unused futures.
  • async fn returns a Future — it does NOTHING until .awaited or polled.
  • Async traits are now stable (Rust 1.75+) — no more async-trait crate 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

  1. 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.
  2. What changed with async traits?

    • Since Rust 1.75, you can write async fn directly in trait definitions without the async-trait crate. Limitation: dynamic dispatch (dyn Trait) still needs workarounds.
  3. When would you use tokio::spawn vs tokio::join!?

    • join! for related concurrent work within the same task. spawn for independent work that should run regardless of the parent task's fate (requires Send).
  4. What's spawn_blocking for?

    • Running synchronous/CPU-heavy code without blocking the async runtime's thread pool. It executes on a dedicated blocking thread pool.
Traits & GenericsLifetimes Deep Dive

On this page

  • TL;DR
  • Step 1: How Async Works in Rust
  • Futures Are Lazy
  • Under the Hood
  • Step 2: Basic Async Patterns
  • Sequential vs Concurrent
  • Spawning Tasks (True Parallelism)
  • tokio::join! vs tokio::spawn
  • Step 3: Async Traits (Stable!)
  • Before (Required async-trait crate)
  • Now (Native — Rust 1.75+)
  • Limitation: dyn Trait with async methods
  • Step 4: Error Handling in Async
  • Timeout Pattern
  • Retry Pattern
  • Step 5: Streams (Async Iterators)
  • Step 6: Common Async Pitfalls
  • Pitfall 1: Holding locks across .await
  • Pitfall 2: Forgetting Send bounds for spawned tasks
  • Pitfall 3: Blocking the async runtime
  • Interview Questions