Async Rust: How It Actually Works

The complete guide to async Rust: futures, the executor, tokio tasks, channels, and the pitfalls that take down production services. Built layer by layer from first principles.

Max Wells

My name is Max and I'm the founder of Rustify.

I've been writing Rust professionally for 2 years and I've helped dozens of engineers make the switch to Rust professionally.

If you want to go further, here's how I can help:

  • Fullstack Bootcamp: structured program with real projects, async support, and private community access.
  • Blockchain Bootcamp: structured program with real projects, async support, and private community access.
  • 1:1 Mentorship: personalized sessions to get you hired faster, with projects tailored to you and mock interviews.

And if you want more Rust content, I post regularly on my YouTube channel:

No spam. Unsubscribe any time.


If you're reading this, you write async Rust. Or you're about to.

And somewhere between your first #[tokio::main] and today, you realized something: you understand what the code does, but you don't fully understand why it works. Or why, sometimes, it doesn't.

That gap is fine. Until something breaks in production. A task that never completes. A latency spike with no obvious cause. A deadlock that only appears under load. In that moment, "I've been using tokio and it mostly works" is not a mental model you can debug from.

Developers who can read a profiler output or a hung task and know exactly where to look are a different kind of engineer.

This guide is built to make you one of them.

Every client I work with who comes to Rust from Node or Go hits the same three walls in async. They are all in here.

Big Picture

ASYNC RUST

├── RUST LANGUAGE (std)           ← always present, runtime-independent
│   ├── Future trait              ← the contract: poll() → Poll<T>
│   ├── async / .await            ← syntax sugar → state machine
│   ├── Waker + Context           ← notification mechanism
│   └── Pin                       ← memory guarantee for self-ref structs

├── EXECUTORS                     ← who drives the futures
│   ├── tokio                     ← server, general purpose, production
│   ├── async-std                 ← mirrors std API
│   ├── smol                      ← tiny, embeddable
│   └── embassy                   ← embedded, no_std

└── TOKIO ECOSYSTEM               ← built on top of the executor
    ├── Tasks       spawn, JoinHandle, Send bound
    ├── Concurrency join!, select!, FuturesUnordered
    ├── I/O         AsyncRead/Write, fs, net, Streams
    ├── Sync        Mutex, RwLock, Semaphore, Notify
    └── Channels    mpsc, oneshot, broadcast, watch

Rule: std layer → executor → ecosystem. Each layer depends on the previous one.

Key insight: your async/await code doesn't care which executor runs it. Swap tokio for smol and the same code runs on a different engine underneath. The contract is in std.


1. Why Async Exists

Imagine a waiter at a restaurant. The old way: one waiter per table, doing nothing but standing and waiting while the kitchen prepares the food. 10 tables, 10 waiters. 10,000 tables? You're bankrupt.

That's exactly what threads are. Every time your server gets a new connection, it spawns a thread. That thread sits there, blocked, doing nothing, while it waits for a database query to return, a file to be read, a network packet to arrive. Waiting is cheap for humans. For a thread, it costs roughly 8MB of stack memory and puts pressure on the OS scheduler.

This works fine at small scale. It breaks catastrophically at 10,000 concurrent connections: the C10K problem that shook the web in the early 2000s.

Every client I work with who comes from a thread-per-request background asks me the same first question: why can't I just use threads? The answer is always the same math. Ten thousand threads is eighty gigabytes of stack memory before you do any actual work.

Async is the smarter waiter. One waiter, many tables. Takes the order, goes to the kitchen, doesn't stand there staring at the stove. Goes back to serve another table. When the kitchen rings the bell, comes back and picks up the dish. One thread, thousands of concurrent tasks, no idle waiting.

The cost model

ThreadsAsync tasks
Memory per unit~8MB stack~a few KB
10k connections~80GB RAM~tens of MB
Context switchOS kernel (expensive)user-space (cheap)
Best forCPU-bound workI/O-bound work

When async wins, when it doesn't

Async shines when your bottleneck is waiting: network requests, database queries, file reads, timers. The CPU is free while you wait, so you can run thousands of tasks on a single thread.

Async loses when your bottleneck is computation: image processing, cryptography, number crunching. You're not waiting for anything, you're burning CPU. Async adds overhead with no benefit. Use threads or rayon instead.

USE TOKIO FOR:                        DON'T USE TOKIO FOR:
  HTTP servers (axum, actix)            CPU-heavy computation → rayon
  WebSockets                            Simple scripts → std::fs is fine
  Database queries (sqlx, sea-orm)      No async needed → just use threads
  Multiple API calls in parallel
  File read/write at scale
  Timers, timeouts, intervals

Simple rule: program spends time waiting on external things → tokio. Program spends time computing → threads/rayon.


LAYER 1: RUST LANGUAGE

2. Future Trait

Key idea: a Future is just a value that knows how to make progress when asked. It does nothing on its own.

Every async operation in Rust, whether it's a network request, a file read, or a timer, is a Future. Not a callback, not a promise, not a thread. Just a value. A struct sitting in memory, waiting to be driven.

Here's the entire trait:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

That's it. One method. poll().

And Poll is simply:

pub enum Poll<T> {
    Ready(T),   // done, here's the result
    Pending,    // not done yet, come back later
}

The mental model

Think of a Future like a vending machine. You press the button (call poll()). Two outcomes: either the snack drops down (Ready(value)) or the machine says "loading..." (Pending). You don't stand there hammering the button, you walk away and come back when it's ready. That "come back when it's ready" mechanism is the Waker (section 4).

Futures are lazy

This is the part that surprises everyone at first.

async fn fetch_data() -> String {
    // ... some network call
}
 
let future = fetch_data(); // nothing happens here

That line creates a future, but zero code inside fetch_data runs. Not one instruction. The future is just a struct holding the potential to do work.

let result = fetch_data().await; // NOW it runs

Only when you .await it (or hand it to an executor) does anything actually execute. This is fundamentally different from JavaScript promises, which start executing immediately on creation.

Why lazy? Because it gives you full control. You can create futures, store them, combine them, cancel them before they ever run. The executor decides when and how to drive them.


3. async / .await

Key idea: async fn is just syntactic sugar. The compiler rewrites it into a state machine that implements Future. .await is the suspension point where that machine can pause.

async fn: what the compiler actually does

When you write this:

async fn read_config() -> String {
    let contents = read_file("config.txt").await;
    contents
}

The compiler silently rewrites it into something like this:

fn read_config() -> impl Future<Output = String> {
    ReadConfigFuture { state: State::Start }
}

Where ReadConfigFuture is a generated struct that holds all the local variables the function needs to resume from where it left off. The function body becomes a state machine with one state per .await point.

You never see this struct. But it exists, it lives on the stack (or heap if you box it), and it's what gets poll()-ed by the executor.

.await: the suspension point

.await is not magic. It's a loop that calls poll() on the inner future until it gets Ready:

let result = some_future.await;
 
// roughly equivalent to:
loop {
    match some_future.poll(cx) {
        Poll::Ready(val) => break val,
        Poll::Pending => { /* suspend, yield back to executor */ }
    }
}

When the future returns Pending, the current task suspends entirely. The executor moves on to run other tasks. When the future is ready to make progress again, the Waker fires, the executor re-schedules this task, and execution resumes right after the .await.

This is the key difference from blocking: the thread is not held. It's free to run other work.

The state machine in practice

async fn example() {
    let a = step_one().await;   // state 0 → state 1
    let b = step_two(a).await;  // state 1 → state 2
    println!("{b}");             // state 2 → done
}

The compiler generates roughly:

State 0: Start
  - call step_one(), poll it
  - if Pending → save state, return Pending
  - if Ready(a) → store a, move to State 1
 
State 1: AfterStepOne { a }
  - call step_two(a), poll it
  - if Pending → save state, return Pending
  - if Ready(b) → store b, move to State 2
 
State 2: AfterStepTwo { b }
  - println!("{b}")
  - return Ready(())

Each state holds exactly the variables needed to resume from that point. Nothing more. This is why async Rust has zero overhead per suspension: no heap allocation, no dynamic dispatch, just an enum stored wherever the future lives.

For a function with two .await points:

enum ExampleState {
    Start,
    WaitingForStepOne,           // parked at first .await - nothing to store yet
    WaitingForStepTwo { a },     // parked at second .await - must hold `a`
    Done,
}

Rule: N .await points = N+1 states (Start + one per suspension + Done).


4. Waker & Context

Key idea: when a future returns Pending, it must guarantee that someone will call wake() later. Without it, the executor never polls again and the task stalls forever.

The problem

The executor calls poll(). The future says Pending. Now what?

The executor can't just keep calling poll() in a loop. That would be a busy-wait, burning 100% CPU doing nothing useful. It needs to park the task and only wake it up when there's actually something to do.

That's exactly what Waker is for.

The metaphor: restaurant buzzer

When you wait for a table, the host hands you a buzzer. You don't go back to the front desk every 30 seconds asking "is it ready?". You sit down, talk, do other things. When the table is ready, the buzzer vibrates.

host hands you the buzzer  =  waker.clone() stored inside the future
you go sit down            =  executor parks the task, runs other tasks
buzzer vibrates            =  waker.wake() called
you walk back              =  executor re-polls the task

Busy-wait is you walking back to the front desk every 30 seconds. Waker is the buzzer.

How it works

Every call to poll() receives a Context that contains a Waker:

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>
//                                   ^^^ holds a Waker

When the future returns Pending, it must clone the Waker and store it somewhere before returning. This is a contract. If you return Pending without storing the waker, nobody will ever call wake(), the executor will never re-poll, and the task stalls silently forever.

Then, when the external event fires (data arrived on the socket, timer elapsed, channel got a message), whoever triggers that event calls waker.wake(). That call tells the executor: this task is ready to make progress, schedule it.

future.poll(cx) → Pending

future stores cx.waker().clone()

... time passes, OS event fires ...

waker.wake() called

executor re-queues the task

future.poll(cx) called again → Ready(val)

Who calls wake()?

Not you. In practice, tokio's I/O primitives handle this. When you .await on a TcpStream read, tokio registers the socket with epoll/kqueue, stores the waker, and calls wake() when data arrives. You never touch the waker directly.

You only care about Waker when implementing a custom Future by hand, which is rare. But understanding it explains why async Rust doesn't burn CPU while waiting: the thread is parked, no polling, no loops. It wakes up exactly when needed.

Context exists for future extensibility, but today it only carries one thing: the Waker. Think of them as the same thing.


5. Pin

Key idea: Pin prevents a value from moving in memory. It exists because async state machines can contain pointers to themselves, and moving them would create dangling references.

The problem: self-referential structs

Look at this async function:

async fn example() {
    let data = String::from("hello");
    let reference = &data;          // points into `data`
    some_io().await;                // suspension point
    println!("{reference}");
}

The compiler generates a state machine struct that must hold both data and reference at the same time (between the .await and the println!). But reference is a pointer into data. Both live inside the same struct.

ExampleState {
    data: String,       // the actual string
    reference: *str,    // points INTO `data` above
}

Now, if Rust moves this struct to a new memory address (which it does all the time, for example when returning from a function or reallocating a Vec), reference still points to the old address. Dangling pointer. Undefined behavior.

The solution: pin it in place

Pin<P> is a wrapper that says: the value behind this pointer is not allowed to move. Once pinned, the address is stable for its entire lifetime.

Pin<Box<T>>   // T is heap-allocated and pinned there
Pin<&mut T>   // T is borrowed and the caller guarantees it won't move

The tent analogy: you can reach inside the tent, use everything in it, modify it. But you can't pick up the tent and move it while the stakes are in the ground. Pin is the stakes.

Unpin: opting out

Most types in Rust are Unpin, meaning they don't have self-references and are safe to move. For those types, Pin does nothing special.

Async state machines generated by the compiler are NOT Unpin when they contain self-references. That's the only case where Pin actually enforces anything.

In practice: you rarely touch Pin directly

The compiler handles pinning for you when you use async/await. You only encounter Pin when:

  • Implementing Future by hand
  • Storing dyn Future as a trait object: Box<dyn Future<Output = T>>
  • Using the pin!() macro to pin stack values

For day-to-day async code, understanding that Pin exists and why is enough.


LAYER 2: THE EXECUTOR

6. The Executor

Key idea: futures do nothing on their own. The executor is the engine that drives them by calling poll() in a loop, parking tasks when they return Pending and waking them when they're ready.

What it does

The executor is a scheduler. Its job is simple:

loop {
    take a task that is ready to run
    call future.poll(cx)
    if Ready(val)  → task is done
    if Pending     → park the task, move to next
    when wake() fires → put the task back in the queue
}

Every .await in your code is a point where the executor can switch to a different task. This is cooperative multitasking: tasks yield control voluntarily at .await points, unlike OS threads which are preempted by the kernel.

The runtimes

The Rust standard library defines the Future trait but ships no executor. You pick one:

RuntimeUse case
tokioProduction servers, general purpose, massive ecosystem
async-stdMirrors the std API, easier migration from sync code
smolTiny and embeddable, ~1000 lines of code
embassyEmbedded systems, no_std, no heap allocation

In practice: use tokio. It's the default for 95% of Rust async projects. The rest of this guide assumes tokio.

#[tokio::main]: what it actually does

When you write:

#[tokio::main]
async fn main() {
    do_stuff().await;
}

The macro expands to:

fn main() {
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(async {
            do_stuff().await;
        });
}

block_on is the bridge between sync and async: it starts the executor, runs the future to completion, and blocks the current thread until it's done. This is the only place in your program where the async world starts.

Single-threaded vs multi-threaded runtime

Tokio ships two flavors:

// Multi-threaded (default) — thread pool with N threads (N = CPU cores)
#[tokio::main]
async fn main() { ... }
 
// Single-threaded — one thread, no parallelism
#[tokio::main(flavor = "current_thread")]
async fn main() { ... }

Multi-threaded: tasks can run in parallel on different threads. Faster for high-throughput servers. Requires spawned futures to be Send (they may be moved between threads).

Single-threaded: all tasks run on one thread, interleaved. No Send requirement. Useful for WASM (browsers have no threads) or when you use non-Send types like Rc.


LAYER 3: TOKIO ECOSYSTEM

Why tokio exists

Rust std gives you the contract. Nothing else.

The three real reasons to use tokio, in order of importance:

1. The executor: without it, nothing runs. Rust std defines Future but ships no executor. Without tokio (or another runtime), your futures never execute. This is the prerequisite.

2. Non-blocking async I/O: the real killer feature. std::net::TcpStream blocks the thread while reading. tokio::net::TcpStream returns Pending and frees the thread to do other work. Tokio wraps epoll/kqueue so the OS notifies when data arrives instead of holding the thread hostage.

std I/O    → thread blocked during the read
tokio I/O  → thread free, resumes when data arrives

One thread handling 10k simultaneous connections. That's what non-blocking I/O unlocks.

3. Lightweight tasks: a consequence of async, not unique to tokio. Tasks cost a few KB vs ~8MB per OS thread. True, and useful. But this comes from the async model itself, not from tokio specifically. Any runtime gives you this.

WITHOUT TOKIO
┌─────────────────────────────────────────┐
│  Future trait    → the contract         │
│  async/await     → syntax sugar         │
│  Waker + Pin     → primitives           │
│                                         │
│  no executor   → nothing drives futures │
│  no async I/O  → no sockets, no files  │
│  no timers     → no sleep, no timeout  │
│  no channels   → no task communication │
│  no thread pool                         │
└─────────────────────────────────────────┘
 
WITH TOKIO
┌──────────────────────────────────────────────────┐
│                                                  │
│  ┌─────────────┐  ┌─────────────┐  ┌──────────┐ │
│  │  Executor   │  │  Async I/O  │  │  Timers  │ │
│  │  thread     │  │  epoll /    │  │  sleep   │ │
│  │  pool       │  │  kqueue     │  │  timeout │ │
│  └─────────────┘  └─────────────┘  └──────────┘ │
│                                                  │
│  ┌─────────────┐  ┌─────────────┐  ┌──────────┐ │
│  │    Tasks    │  │  Channels   │  │   Sync   │ │
│  │  spawn      │  │  mpsc       │  │  Mutex   │ │
│  │  JoinSet    │  │  oneshot    │  │  RwLock  │ │
│  └─────────────┘  └─────────────┘  └──────────┘ │
│                                                  │
└──────────────────────────────────────────────────┘
        built on top of Rust std Future + Waker

7. Tasks & Spawning

Key idea: tokio::spawn creates an independent task that runs concurrently with the rest of your program. Think of it as a lightweight thread managed by the executor.

Tasks vs threads

tokio::spawn creates a task, not an OS thread.

tokio::spawn(async { ... })    → async task, ~KB, managed by executor
std::thread::spawn(|| { ... }) → OS thread, ~8MB stack

Tokio maintains a thread pool (default: N threads = number of CPU cores). Tasks run on top of those threads. Many tasks share the same thread: the executor switches between them at every .await point.

TOKIO RUNTIME
┌─────────────────────────────────────────────────────┐
│                   Thread Pool                       │
│                                                     │
│  Thread 1          Thread 2          Thread 3       │
│  ┌──────────┐      ┌──────────┐      ┌──────────┐  │
│  │ task A   │      │ task D   │      │ task G   │  │
│  │  ...     │      │  ...     │      │  ...     │  │
│  │  .await  │      │  .await  │      │  .await  │  │
│  │ task B   │      │ task E   │      │ task H   │  │
│  │  ...     │      │  ...     │      │  ...     │  │
│  │  .await  │      │  .await  │      │  .await  │  │
│  │ task C   │      │ task F   │      │ task A   │  │ ← task A moved!
│  └──────────┘      └──────────┘      └──────────┘  │
│                                                     │
│            Task Queue (ready to run)                │
│         [ B, E, C, G, F, H, A, D, ... ]            │
└─────────────────────────────────────────────────────┘

Thousands of tasks, handful of threads. That's the model.

Sequential vs concurrent vs parallel

SEQUENTIAL — one thing at a time
 
Core 1: [████████ A ████████][████████ B ████████]
Core 2: ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  wasted
 
total time = A + B
 
CONCURRENT — interleaved on one thread (join!)
time → ██░░██░░██░░░░██░░██░░██
       [A ][B][A ][B ][A ][B ]
       total time ≈ max(A, B)   ← faster!
 
PARALLEL — truly simultaneous (spawn)
time → ████████████
       [  task A  ]   ← Thread 1
       ████████████
       [  task B  ]   ← Thread 2
       total time ≈ max(A, B)   ← faster!

tokio::spawn

// Sequential — waits for fetch_a before starting fetch_b
let a = fetch_a().await;
let b = fetch_b().await;
 
// Concurrent — both run at the same time
let handle_a = tokio::spawn(fetch_a());
let handle_b = tokio::spawn(fetch_b());
let a = handle_a.await.unwrap();
let b = handle_b.await.unwrap();

tokio::spawn takes a future, wraps it in a task, hands it to the executor, and immediately returns a JoinHandle. The task starts running without you waiting for it.

JoinHandle: collecting the result

let handle: JoinHandle<String> = tokio::spawn(async {
    "result".to_string()
});
 
let result: String = handle.await.unwrap();

If you drop the handle without awaiting it, the task keeps running in the background. To cancel it, call handle.abort().

The Send bound

On the multi-threaded runtime, spawned tasks can move between threads. This means the future must be Send: all values it holds across .await points must be safe to send to another thread.

// ❌ Rc is not Send
tokio::spawn(async {
    let x = Rc::new(1);
    some_io().await;  // future may move to another thread here
    println!("{x}");
});
 
// ✅ Arc is Send
tokio::spawn(async {
    let x = Arc::new(1);
    some_io().await;
    println!("{x}");
});

The compiler will tell you clearly if something isn't Send. The fix is almost always: replace Rc with Arc, Cell with Mutex, or drop the non-Send value before the .await.

JoinSet: managing many tasks

When you need to spawn a dynamic number of tasks and collect their results:

let mut set = JoinSet::new();
 
for url in urls {
    set.spawn(fetch(url));
}
 
while let Some(result) = set.join_next().await {
    println!("{:?}", result);
}
// results arrive in completion order, not spawn order

8. Concurrency Patterns

Key idea: join! runs futures concurrently and waits for all. select! races them and takes the first. Both run inside a single task.

join!: run all, wait for all

WITHOUT join!  (sequential)
time → [  fetch A  ][  fetch B  ][ fetch C ]
       total: A + B + C
 
WITH join!  (concurrent)
time → [  fetch A  ]
          [ B ]
              [  fetch C  ]
       total: max(A, B, C)   ← much faster
let (users, posts, comments) = tokio::join!(
    fetch_users(),
    fetch_posts(),
    fetch_comments(),
);
// all three run concurrently, waits for ALL to finish

All futures run on the same task. If one fails, the others keep running until completion.

select!: race, take the first

tokio::select! {
    result = fetch_from_server_a() => { /* use result */ }
    result = fetch_from_server_b() => { /* use result */ }
    _ = tokio::time::sleep(Duration::from_secs(5)) => {
        println!("timeout!");
    }
}
// first to finish wins, all others are cancelled (dropped)

Warning: losers are dropped immediately. If a future was mid-operation (writing to DB, holding a lock), it's interrupted at the next .await. Design your futures to be cancellation-safe.

FuturesUnordered: dynamic set, results as they arrive

join! needs a fixed number of futures at compile time. When the list comes from runtime data, use FuturesUnordered:

use futures::stream::{FuturesUnordered, StreamExt};
 
let mut tasks = FuturesUnordered::new();
for url in urls {
    tasks.push(fetch(url));
}
 
while let Some(result) = tasks.next().await {
    println!("{:?}", result);
}

When to use what

join!(a, b, c)         fixed set, need all results
select!(a, b, timeout) race, take first, cancel rest
FuturesUnordered       dynamic set, process results one by one
JoinSet                dynamic set + task management (abort, cancel)

9. Shared State & Channels

Key idea: when multiple tasks need to share data, use shared memory with a lock (Arc<Mutex<T>>), or message passing via channels. Prefer channels when possible.

Why tasks can't just share data

Each spawned task is independent. You can't share a &mut T reference across tasks: the borrow can outlive the scope, and spawn requires 'static. Two tasks that both want to increment a counter need coordination.

Arc<Mutex<T>> solves this:

counter lives as long as ANY Arc points to it
 
main()   Arc ──────────────────► counter (ref count: 2)
task A   Arc ────────────────────────────────────────►
                                              ref count drops to 0
                                              only when BOTH are done

Arc<Mutex<T>>: shared memory

Arc = shared ownership across threads. Mutex = only one task accesses at a time.

#[derive(Clone)]
struct AppState {
    counter: Arc<Mutex<u64>>,
}
 
async fn increment(state: AppState) {
    let mut count = state.counter.lock().await;  // waits for lock
    *count += 1;
}   // lock released here automatically

Use tokio::sync::Mutex, not std::sync::Mutex. The std version blocks the thread while waiting for the lock. The tokio version yields to the executor: other tasks can run while this one waits.

std::Mutex   → thread blocked waiting for lock  ❌
tokio::Mutex → task suspended, thread free      ✅

Channels: message passing

Tasks send values through a channel instead of sharing memory. Cleaner, no lock contention, naturally expresses data flow.

MPSC — multiple producers, single consumer (most common)
task A ──┐
task B ──┤──► channel ──► consumer task
task C ──┘
 
ONESHOT — single value, one time
task A ──────────────────────────► task B
       "here's the result"
 
BROADCAST — one sender, many receivers
              ┌──► task A
sender ───────┼──► task B
              └──► task C
// mpsc — worker pool pattern
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
 
tokio::spawn(async move {
    tx.send("job 1").await.unwrap();
    tx.send("job 2").await.unwrap();
});
 
while let Some(job) = rx.recv().await {
    println!("processing: {job}");
}
 
// oneshot — request/response pattern
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
    let result = do_work().await;
    tx.send(result).unwrap();
});
let result = rx.await.unwrap();

When to use what

Arc<Mutex<T>>   shared config, caches, counters
mpsc channel    task pipeline, worker queue, event stream
oneshot         single response (request/reply pattern)
broadcast       fan-out to many listeners
watch           latest value only (config updates, current status)
RwLock          many readers, rare writes
Semaphore       limit concurrency (rate limiting, DB pool)

10. Async I/O

Key idea: tokio provides async versions of everything in std. Drop-in replacements, but non-blocking. The thread is free while waiting for disk or network.

tokio::fs vs std::fs

// std: blocks the thread while reading
let contents = std::fs::read_to_string("file.txt")?;
 
// tokio: yields to executor while reading
let contents = tokio::fs::read_to_string("file.txt").await?;

Use tokio::fs inside async functions. Using std::fs inside async code is the most common mistake my clients make in their first week. It silently blocks the executor thread. Everything on that thread freezes until the file read completes. At low volume you never notice. At scale it takes down your service.

tokio::net

use tokio::net::TcpListener;
 
let listener = TcpListener::bind("0.0.0.0:8080").await?;
 
loop {
    let (socket, addr) = listener.accept().await?;
    tokio::spawn(async move {
        handle_connection(socket).await;
    });
}

Each connection runs in its own task. The executor switches between them at every .await. One thread can handle thousands of simultaneous connections.

AsyncRead / AsyncWrite

The async equivalents of std::io::Read and Write. Implemented by TcpStream, File, Stdin, and most tokio I/O types.

use tokio::io::{AsyncReadExt, AsyncWriteExt};
 
async fn echo(mut socket: TcpStream) {
    let mut buf = vec![0; 1024];
    loop {
        let n = socket.read(&mut buf).await.unwrap();
        if n == 0 { break; }
        socket.write_all(&buf[..n]).await.unwrap();
    }
}

tokio::io::copy is the most common utility: copy bytes from a reader to a writer, non-blocking.

Streams: async iterators

A Stream is an async Iterator: items arrive over time instead of all at once. Database result sets, incoming WebSocket frames, file lines, channel receivers.

use tokio_stream::StreamExt;
 
// process database rows as they arrive
while let Some(row) = stream.next().await {
    process(row).await;
}

11. Error Handling in Async

Key idea: async error handling is mostly identical to sync Rust. Three places where it gets interesting: ? in async fn, JoinHandle<Result<T>>, and errors that cross task boundaries.

? in async fn

Works exactly the same as in sync code:

async fn fetch_user(id: Uuid) -> Result<User, AppError> {
    let row = db.query_one(id).await?;   // propagates error
    let parsed = parse_user(row)?;        // also works
    Ok(parsed)
}

The function must return Result (or Option) for ? to work. Nothing special about async here.

JoinHandle<Result<T>>: double-wrapped

When a spawned task returns a Result, the JoinHandle wraps it in another Result:

let handle: JoinHandle<Result<String, MyError>> = tokio::spawn(async {
    do_work().await
});
 
// handle.await is Result<Result<String, MyError>, JoinError>
match handle.await {
    Ok(Ok(value))   => println!("success: {value}"),
    Ok(Err(e))      => println!("task failed: {e}"),
    Err(join_err)   => println!("task panicked: {join_err}"),
}

The outer Result<_, JoinError> is the tokio wrapper: Err means the task panicked or was aborted. The inner Result is your own error type. In practice, most code flattens this with ? twice:

let result = handle.await??;

Errors across tasks

You cannot send a non-Send error across task boundaries. If your error type contains Rc or other non-Send types, the compiler will refuse. Use Box<dyn Error + Send + Sync> or anyhow::Error for errors that need to cross tasks.

// ❌ MyError must be Send to cross task boundary
let handle = tokio::spawn(async { Err::<(), MyError>(e) });
 
// ✅ anyhow::Error is Send
let handle = tokio::spawn(async { anyhow::bail!("something failed") });

Timeouts

Wrap any future with tokio::time::timeout:

use tokio::time::{timeout, Duration};
 
match timeout(Duration::from_secs(5), slow_operation()).await {
    Ok(result) => handle(result),
    Err(_)     => println!("timed out after 5 seconds"),
}

Every external call in production code should have a timeout. An API that never times out is a task that holds a connection forever. My clients who skip this always regret it during their first traffic spike.


12. Common Pitfalls

The mistakes that appear in production code reviews. They are predictable. They are avoidable. Several of them I have seen cause outages.

Pitfall 1: Blocking inside async (the big one)

// ❌ DO NOT do this
async fn handler() {
    let result = std::fs::read_to_string("file.txt").unwrap(); // blocks thread
    let hash = expensive_crypto_operation(&result);             // burns CPU
}

Every thread in the tokio thread pool is blocked until that call returns. If you have 8 threads and 8 requests each block for 100ms, your server is completely frozen for 100ms. At scale, this is a latency cliff.

Blocking inside async threads is the single most common production issue I see in code reviews with my clients. It shows up in profilers as mysterious latency spikes with no obvious cause. The fix:

// ✅ blocking I/O
let result = tokio::fs::read_to_string("file.txt").await?;
 
// ✅ CPU-heavy work
let hash = tokio::task::spawn_blocking(|| {
    expensive_crypto_operation(&data)
}).await?;

spawn_blocking moves the work to a separate thread pool that tokio maintains for exactly this purpose. That pool is allowed to block. The async thread pool is not.

Pitfall 2: async in traits

This does not compile:

// ❌ not stable (in older Rust)
trait MyTrait {
    async fn do_work(&self) -> Result<()>;
}

Solutions:

// ✅ Rust 1.75+: use the `async fn` in traits RFC
trait MyTrait {
    async fn do_work(&self) -> Result<()>;
}
 
// ✅ older Rust: async-trait crate (adds Box allocation)
#[async_trait]
trait MyTrait {
    async fn do_work(&self) -> Result<()>;
}
 
// ✅ explicit return type (always works, more verbose)
trait MyTrait {
    fn do_work(&self) -> impl Future<Output = Result<()>> + Send + '_;
}

Native async traits landed in Rust 1.75. If you are on 1.75+, just use them. If you are on an older version for some reason, use async-trait.

Pitfall 3: Cancellation (drop = cancel)

When you drop a future, it stops running. No cleanup code runs inside it unless you implement Drop. This catches people off guard with select!:

tokio::select! {
    _ = write_to_db(data) => {}  // might be cancelled mid-write
    _ = timeout(5s) => { println!("cancelled!"); }
}

If the timeout fires while write_to_db is mid-operation, the write is interrupted. The database might have a partial write. Design cancellation-sensitive code to be either atomic or explicitly cleanup-aware.

// ✅ use tokio::select! with cancellation tokens for graceful shutdown
use tokio_util::sync::CancellationToken;
 
let token = CancellationToken::new();
tokio::select! {
    _ = operation() => {}
    _ = token.cancelled() => { cleanup().await; }
}

Pitfall 4: Mutex deadlock

// ❌ deadlock: lock held across .await
async fn bad() {
    let lock = mutex.lock().await;
    some_io().await;   // another task might try to acquire the same lock here
    drop(lock);
}

If two tasks both hold different locks and try to acquire each other's lock across an .await, you have a deadlock. The fix: hold locks for the shortest time possible, and never hold a lock across an .await unless you understand exactly why it's safe.

// ✅ release lock before awaiting
async fn good() {
    let result = {
        let lock = mutex.lock().await;
        compute_from_lock(&lock)
    };  // lock released here
    some_io(result).await;
}

Pitfall 5: Non-Send types across .await

// ❌ Rc is not Send, and it's held across .await
async fn bad() {
    let data = Rc::new(vec![1, 2, 3]);
    some_io().await;   // compiler error: future is not Send
    println!("{data:?}");
}

The multi-threaded tokio runtime requires spawned futures to be Send. Anything held across a .await point must also be Send. Rc, RefCell, *mut T, MutexGuard<T> (sometimes): all non-Send. The fix is almost always Arc instead of Rc, or restructuring so the non-Send value is dropped before the .await.

// ✅ drop before awaiting, or use Arc
async fn good() {
    let data = Arc::new(vec![1, 2, 3]);
    some_io().await;   // Arc is Send
    println!("{data:?}");
}

The async pitfalls that take down services are not exotic. They're blocking I/O in async context, mutex deadlocks, and cancellation during half-written operations. If you want me to audit your codebase for these patterns before they become incidents, that's what 1:1 mentorship sessions are for. Book a session →


Resources

ResourceLinkCovers
Tokio Tutorialhttps://tokio.rs/tokio/tutorialLayers 2-3, hands-on
Async Rust Bookhttps://rust-lang.github.io/async-bookLayer 1, Future, Waker, Pin
Jon Gjengset - Crust of Rusthttps://www.youtube.com/@jonhoo~2h, builds executor from scratch

There are developers who read a guide like this, understand it intellectually, and still write the same async mistakes next week.

And there are developers who internalize the model. Who, the next time something breaks in production, know exactly where to look. Who can sit in a code review and catch the mutex guard held across an .await before it ships.

The difference is not talent. It is whether you have shipped enough real async code with enough feedback to make the model stick.

If you just thought of a std::fs call inside one of your async handlers, go fix it. That is the most common thing I find in first sessions. After that, the missing timeout. After that, the deadlock.

If you want to close that gap faster — real code review, real projects, someone who has seen these bugs before they become incidents — that is what the mentorship is for.

Book a call →


If you want to go from understanding async to shipping production Rust services, with code review, real projects, and personalized feedback:

  • Fullstack Bootcamp: structured program with real projects, async support, and private community access.
  • Blockchain Bootcamp: structured program with real projects, async support, and private community access.
  • 1:1 Mentorship: personalized sessions to get you hired faster, with projects tailored to you and mock interviews.

No spam. Unsubscribe any time.

Student Success Stories

Hear from engineers who built real Rust projects with Rustify

Arik Dutta

Technical Lead · Low-code & Python → Rust

Tiago Afonso

Fullstack Developer

Ugo Tiberto

Rust Engineer · Fullstack Developer

Your Future Awaits

Join our 9-week self-paced bootcamp and go from one language to production Rust. Learn through hands-on projects and daily async support.

Book a Call