Most async Rust performance problems come from a small set of mistakes. None of them are subtle – they all follow directly from how the scheduler works. This post covers each one, explains why it breaks performance, and shows the correct pattern.
Pitfall 1: Blocking in an Async Context
This is the most damaging mistake. Tokio’s worker threads run async tasks. If a task blocks a thread – by calling a synchronous blocking function – that thread cannot run any other tasks. On a runtime with four worker threads, four simultaneous blocking calls stall the entire runtime.
// WRONG: blocks the worker thread
async fn bad_handler() -> String {
let result = std::fs::read_to_string("/etc/hosts").unwrap(); // BLOCKS
result
}
// WRONG: CPU-heavy work with no await points
async fn bad_compute(data: Vec) -> u64 {
data.iter().map(|&x| expensive_calculation(x) as u64).sum() // may take seconds
}
// CORRECT: use spawn_blocking for blocking I/O and CPU-bound work
async fn good_handler() -> String {
tokio::task::spawn_blocking(|| {
std::fs::read_to_string("/etc/hosts").unwrap()
})
.await
.unwrap()
}
async fn good_compute(data: Vec) -> u64 {
tokio::task::spawn_blocking(move || {
data.iter().map(|&x| expensive_calculation(x) as u64).sum()
})
.await
.unwrap()
}
// CORRECT: use tokio::fs for async file operations
async fn async_file_read() -> String {
tokio::fs::read_to_string("/etc/hosts").await.unwrap()
// Note: tokio::fs internally uses spawn_blocking on most platforms
// It is not truly async I/O, but it does not block the worker thread
} Tokio will warn you about blocking operations that take too long. Enable the tokio_unstable feature and use tokio-console to detect blocking tasks. Any synchronous operation that takes more than a few milliseconds should move to spawn_blocking.
Pitfall 2: Holding a Mutex Across an Await Point
use std::sync::Mutex;
// WRONG: holding std::sync::Mutex across .await
// This future will not compile on multi-threaded runtime because
// MutexGuard is not Send - and even on single-threaded, it deadlocks
async fn bad_mutex_use(data: Arc>>) {
let mut lock = data.lock().unwrap(); // std::sync::MutexGuard
fetch_more_data().await; // HELD across await - deadlock risk
lock.push(String::from("new item"));
}
// CORRECT option 1: use tokio::sync::Mutex (async-aware)
use tokio::sync::Mutex as AsyncMutex;
async fn good_tokio_mutex(data: Arc>>) {
let mut lock = data.lock().await; // async lock - suspends, does not block thread
// Do NOT hold across expensive awaits - just for the critical section
lock.push(String::from("new item"));
// lock dropped here
// Now do the async work without holding the lock
fetch_more_data().await;
}
// CORRECT option 2: minimize lock scope with std::sync::Mutex
async fn good_std_mutex(data: Arc>>) {
// Read what we need under the lock, then release before any await
let snapshot = {
let lock = data.lock().unwrap();
lock.clone() // take a snapshot
}; // lock dropped here
let new_data = fetch_more_data().await; // await outside lock
{
let mut lock = data.lock().unwrap();
lock.push(new_data);
} // lock dropped here
}
// Rule of thumb: prefer std::sync::Mutex with short critical sections
// Use tokio::sync::Mutex only when the critical section itself needs to await Pitfall 3: Spawning Without Backpressure
// WRONG: spawns one task per item with no limit
async fn unbounded_spawn(items: Vec) {
for item in items {
tokio::spawn(process(item)); // 1,000,000 items = 1,000,000 tasks
}
// Memory exhaustion, task scheduler overload
}
// CORRECT: limit concurrency with a semaphore
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn bounded_spawn(items: Vec) {
let sem = Arc::new(Semaphore::new(100)); // max 100 concurrent tasks
let mut handles = vec![];
for item in items {
let permit = sem.clone().acquire_owned().await.unwrap();
handles.push(tokio::spawn(async move {
let _permit = permit; // released when task finishes
process(item).await
}));
}
for h in handles { let _ = h.await; }
}
// ALSO CORRECT: use JoinSet with a concurrency limit
use tokio::task::JoinSet;
async fn joinset_bounded(items: Vec) {
let mut set = JoinSet::new();
for item in items {
// Wait if at capacity before spawning more
if set.len() >= 100 {
set.join_next().await;
}
set.spawn(process(item));
}
while let Some(_) = set.join_next().await {}
} Pitfall 4: Forgetting That .await Does Not Mean Concurrent
// WRONG: sequential, not concurrent - takes 300ms
async fn sequential() {
fetch_a().await; // 100ms
fetch_b().await; // 100ms
fetch_c().await; // 100ms
}
// CORRECT: concurrent - takes ~100ms
async fn concurrent() {
tokio::join!(
fetch_a(),
fetch_b(),
fetch_c(),
);
}
// For dynamic number of futures:
async fn concurrent_dynamic(urls: Vec) {
let futures: Vec<_> = urls.iter().map(|url| fetch(url)).collect();
let results = futures::future::join_all(futures).await;
// Or use JoinSet for better error handling
} Pitfall 5: CPU-Bound Work Starving the Scheduler
flowchart TD
A[Worker Thread] --> B[Poll Task A]
B --> C{Task A has\nawait point?}
C -->|Yes| D[Task A suspends\nThread available]
D --> E[Poll Task B]
C -->|No - CPU loop| F[Thread blocked\nfor seconds]
F --> G[Task B, C, D starved\nLatency spikes]
// WRONG: long CPU loop in async context
async fn bad_cpu_work(data: &[u32]) -> u64 {
// This holds the thread for the entire duration
data.iter().map(|&x| heavy_math(x) as u64).sum()
}
// CORRECT option 1: spawn_blocking
async fn good_spawn_blocking(data: Vec) -> u64 {
tokio::task::spawn_blocking(move || {
data.iter().map(|&x| heavy_math(x) as u64).sum()
})
.await
.unwrap()
}
// CORRECT option 2: yield periodically for moderate work
async fn good_yield(data: &[u32]) -> u64 {
let mut sum = 0u64;
for (i, &x) in data.iter().enumerate() {
sum += heavy_math(x) as u64;
if i % 1000 == 0 {
tokio::task::yield_now().await; // give other tasks a turn
}
}
sum
}
// CORRECT option 3: rayon for data-parallel CPU work
async fn good_rayon(data: Vec) -> u64 {
tokio::task::spawn_blocking(move || {
use rayon::prelude::*;
data.par_iter().map(|&x| heavy_math(x) as u64).sum()
})
.await
.unwrap()
} Pitfall 6: Not Handling Slow Receivers in Channels
// WRONG: producer loops without checking receiver health
async fn bad_producer(tx: mpsc::Sender) {
loop {
let data = generate_data().await;
tx.send(data).await.unwrap(); // panics when receiver drops
}
}
// CORRECT: handle send errors gracefully
async fn good_producer(tx: mpsc::Sender) {
loop {
let data = generate_data().await;
match tx.send(data).await {
Ok(()) => {}
Err(_) => {
tracing::info!("Receiver dropped, stopping producer");
break;
}
}
}
}
// CORRECT: use try_send for non-blocking send with backpressure detection
async fn producer_with_backpressure(tx: mpsc::Sender) {
loop {
let data = generate_data().await;
match tx.try_send(data) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!("Channel full - consumer is slow");
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
Err(mpsc::error::TrySendError::Closed(_)) => break,
}
}
}Quick Reference: Pitfalls and Fixes
// 1. std::thread::sleep in async -> tokio::time::sleep
// 2. std::fs::read in async -> tokio::fs::read or spawn_blocking
// 3. std::sync::Mutex held across await -> shorten scope or use tokio::sync::Mutex
// 4. Unbounded task spawn -> Semaphore or JoinSet with limit
// 5. Sequential awaits when concurrent is needed -> tokio::join! or JoinSet
// 6. CPU loop without yield -> spawn_blocking or yield_now every N iterations
// 7. unwrap() on channel send -> handle Err for closed receiverWhat Comes Next
Part 9 covers observability – how to see what your async service is actually doing. tokio-console gives you a live view of task state, tracing gives you structured logs and spans, and diagnosing runtime starvation requires knowing what to measure.
References
- Tokio Documentation – “Bridging with sync code” (https://tokio.rs/tokio/topics/bridging)
- Tokio Documentation – “spawn_blocking” (https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html)
- Alice Ryhl – “Async: What is blocking?” (https://ryhl.io/blog/async-what-is-blocking/)
- Oleg Kubrakov – “Practical Guide to Async Rust and Tokio” (https://medium.com/@OlegKubrakov/practical-guide-to-async-rust-and-tokio-99e818c11965)
- WyeWorks – “Async Rust: When to Use It and When to Avoid It” (https://www.wyeworks.com/blog/2025/02/25/async-rust-when-to-use-it-when-to-avoid-it/)
