Async Rust is powerful, but it rewards careful thought. We've shipped enough services that hang unexpectedly or leak resources to know: async pitfalls in Rust: a practical guide for developers starts with understanding what actually happens when you await. In this article, we'll walk through four critical mistakes we see repeatedly in production, why they break your service, and how to fix them.
Blocking inside async code
The most common pitfall we encounter is blocking the async runtime with synchronous I/O or CPU-bound work. When you call a blocking function inside an async context, you're tying up the thread that should be multiplexing hundreds of concurrent tasks. Your throughput collapses.
Let's say we're building a request handler that needs to hash a password and query a database:
// WRONG: blocking crypto inside async handler
async fn handle_login(username: String, password: String) -> Result<String> {
let hash = bcrypt::hash(&password, 12)?; // 200ms blocking call!
let user = db.query_user(&username).await?; // This waits, then hashing blocks again
Ok(format!("Welcome, {}", username))
}When this handler runs, the 200ms hash operation blocks the entire tokio thread pool thread for the duration. If you have 1000 concurrent requests, most of them stall. CPU usage stays high but request latency is awful.
The solution: move blocking work to a dedicated thread pool.
// CORRECT: spawn blocking work on separate thread
async fn handle_login(username: String, password: String) -> Result<String> {
let hash = tokio::task::spawn_blocking(|| {
bcrypt::hash(&password, 12)
})
.await??;
let user = db.query_user(&username).await?;
Ok(format!("Welcome, {}", username))
}By using spawn_blocking, we tell tokio: "this is blocking work, run it elsewhere." Tokio maintains a separate thread pool for exactly this scenario. The async runtime stays responsive for your thousands of other tasks.
Common culprits: standard library file I/O (std::fs), CPU-intensive crypto, serialization on very large payloads, and database drivers that don't have async support. When in doubt, measure. Use flamegraph or perf to spot where the async thread is stalling.
Holding locks across await points
Locks (Mutex, RwLock) hold a thread, not just execution. If you hold a lock over an await point, you're potentially blocking other tasks. In async code, you want to release locks as quickly as possible.
Here's a real scenario from a trading service we worked on:
// WRONG: lock held across await
async fn update_position(portfolio_id: u64, delta: f64) -> Result<()> {
let mut guard = POSITIONS.lock().unwrap(); // Lock acquired
guard.insert(portfolio_id, delta); // Quick update
let response = http_client.post("/notify").json(&guard).await?; // Locked during this await!
Ok(())
}If the HTTP request takes 500ms and there are 50 other tasks trying to update positions, they're all queued on the lock. Your service becomes single-threaded for position updates.
The fix: release the lock before awaiting.
// CORRECT: lock released before await
async fn update_position(portfolio_id: u64, delta: f64) -> Result<()> {
let data_to_send = {
let mut guard = POSITIONS.lock().unwrap();
guard.insert(portfolio_id, delta);
guard.clone() // Grab what we need, drop lock
}; // Lock released here
let response = http_client.post("/notify").json(&data_to_send).await?;
Ok(())
}The critical difference: we clone the data inside the lock, then the guard is dropped. When we await the HTTP call, no lock is held. Other tasks can proceed.
If you're working with async-friendly locks like tokio::sync::Mutex, the rules are slightly different—they're designed for this use case—but the principle remains: hold them as briefly as possible. Consider using channels to decouple the critical section from async work entirely.
Unbounded task spawning
Tokio lets you spawn tasks with tokio::spawn. It's tempting to spawn one per incoming request, one per message, one per anything. But unbounded spawning exhausts memory and overloads the scheduler.
We saw this in a real-time notification service:
// WRONG: unbounded spawning
async fn handle_incoming_messages(mut rx: mpsc::Receiver<Message>) {
while let Some(msg) = rx.recv().await {
tokio::spawn(async move {
process_message(msg).await
});
}
}If you're receiving 100k messages per second, you're spawning 100k tasks per second. Eventually, the task queue grows unbounded. Memory usage climbs. The scheduler can't keep up. Latency spikes.
The solution: bound concurrency with a semaphore or a bounded channel.
// CORRECT: bounded concurrency
async fn handle_incoming_messages(mut rx: mpsc::Receiver<Message>) {
let semaphore = Arc::new(Semaphore::new(1000)); // Max 1000 concurrent tasks
while let Some(msg) = rx.recv().await {
let permit = semaphore.acquire().await.unwrap();
tokio::spawn(async move {
let _permit = permit; // Hold permit for task lifetime
process_message(msg).await
});
}
}Now we're capped at 1000 concurrent message processing tasks. If we're at capacity, acquire waits. Back-pressure flows back to the message receiver naturally.
Alternatively, use a bounded channel directly—if the channel is full, the sender blocks, and you've achieved back-pressure at the source. The key insight: in production, you always want some form of concurrency limit. Unbounded work queues are a classic path to cascading failure.
Forgetting cancellation safety
Rust futures can be dropped at any await point. This is powerful—it enables graceful shutdown and timeouts—but it can leave your code in an inconsistent state if you're not careful.
Consider a service that batches database writes:
// RISKY: not cancellation safe
async fn batch_writer(mut rx: mpsc::Receiver<Record>) {
let mut batch = Vec::new();
loop {
match timeout(Duration::from_secs(1), rx.recv()).await {
Ok(Some(record)) => {
batch.push(record);
if batch.len() >= 100 {
db.write_batch(&batch).await.ok(); // If this is cancelled...
batch.clear();
}
}
_ => {
if !batch.is_empty() {
db.write_batch(&batch).await.ok();
}
break;
}
}
}
}If the timeout fires or the task is cancelled during db.write_batch, you've lost the batch. Those records are dropped from memory without being persisted.
The solution: structure your code so cancellation leaves you in a safe state. Use a persistent queue or ensure data is stored before clearing.
// CORRECT: cancellation safe
async fn batch_writer(mut rx: mpsc::Receiver<Record>, db: Db) {
let mut batch = Vec::new();
loop {
match timeout(Duration::from_secs(1), rx.recv()).await {
Ok(Some(record)) => {
batch.push(record);
if batch.len() >= 100 {
// Write and only clear on success
if db.write_batch(&batch).await.is_ok() {
batch.clear();
}
}
}
_ => {
// Flush remaining
if !batch.is_empty() {
let _ = db.write_batch(&batch).await;
}
break;
}
}
}
}Better yet, keep unwritten records in a persistent queue on disk so they survive a cancellation. Or use a message broker like Kafka to guarantee delivery. The principle: assume your async code will be cancelled at any await point and structure your state accordingly.
Common mistakes
- Assuming
awaitgives you parallelism: It doesn't. Multiple concurrent tasks give you parallelism. A single task that awaits is just paused. If it's the only task running, nothing else happens. - Holding locks in closure arguments: A lock guard can't cross an await boundary safely in regular Mutex. You need async-aware locks like
tokio::sync::Mutexor you must release before awaiting. - Not using
spawn_blockingfor CPU-bound work: Sorting a large vec, hashing passwords, decompressing payloads—all belong inspawn_blocking. - Ignoring cancellation in long-running tasks: Always consider: what happens if this task is cancelled right now? Is data lost? Corrupted? In an inconsistent state?
Key takeaways
- Block sync work in
spawn_blocking; never in the async runtime thread. - Release locks (especially Mutex) before any await point; use
tokio::sync::Mutexif you must hold across await. - Bound task spawning with semaphores or bounded channels. Unbounded queues cause memory exhaustion.
- Design for cancellation: assume futures drop at any await and keep state consistent.
- Measure in production. Use flamegraph and tokio-console to spot blocking and contention.






Comments
Loading comments…