When waiting blocks everything
You're writing a chat server. You loop through connections, read a message, process it, write a response. It works perfectly for one user. Then ten users connect. The server grinds to a halt. It's stuck waiting for User A to type, so it can't read User B's message. In Python or JavaScript, you'd mark the function async and sprinkle await to fix this. Rust gives you the same power, but it forces you to see the machinery. You aren't just pausing execution; you're handing control back to an executor that juggles hundreds of tasks on a single thread.
Async I/O without the magic
Async I/O solves the "waiting" problem by refusing to block the thread. When you ask the OS to read data from a network socket, the data might not be there yet. A blocking read locks your thread until the data arrives. An async read asks the OS, "When data is ready, wake me up," and then your code goes off to do other work. The OS interrupts you later.
Rust represents this "promise of future data" as a Future. A Future is a state machine that tracks where you are in the computation. It doesn't run by itself. An executor polls the future. If the work is done, the future returns the result. If the work is waiting on I/O, the future tells the executor to come back later, and the executor switches to another task.
Minimal async function
The async keyword on a function tells the compiler to rewrite the function body into a state machine. The function returns a Future immediately. You use .await to suspend the current task until that future completes.
use trpl::Html;
/// Fetches the title of a webpage asynchronously.
async fn page_title(url: &str) -> Option<String> {
// trpl::get returns a Future. We await it to get the response.
// The task suspends here until the network request finishes.
let response_text = trpl::get(url).await.text().await;
// Parse HTML and extract title.
// This part runs immediately once the text is available.
Html::parse(&response_text)
.select_first("title")
.map(|title| title.inner_html())
}
Convention aside: The trpl crate is a learning tool for the Rust Playground. Real-world code uses runtimes like tokio or async-std. The syntax is identical, but you'll need to add a runtime dependency and run your async code inside a #[tokio::main] block.
What happens under the hood
When you call page_title, the function body doesn't run. The compiler generates a state machine and returns it. The state machine captures the arguments and prepares to execute.
When you .await the future, the executor polls the state machine. The state machine runs until it hits the first .await. At that point, it checks if the underlying I/O is ready. If the network request isn't finished, the state machine returns Poll::Pending. The executor notes that this task is waiting and switches to another task.
Later, the OS signals that data has arrived. The executor polls the state machine again. The state machine resumes right after the .await, retrieves the data, and continues. If all .await points are done, the state machine returns Poll::Ready(value). The future is complete.
This suspension and resumption happens without creating a new OS thread. The executor multiplexes thousands of tasks on a few threads. The cost of switching tasks is tiny compared to the cost of creating a thread.
Realistic pattern: concurrent reads
Async shines when you have many independent I/O operations. You can spawn multiple tasks and wait for them all to finish. This is how you handle high concurrency without a thread per connection.
/// Fetches titles for multiple URLs concurrently.
async fn fetch_titles(urls: &[&str]) -> Vec<String> {
let mut handles = Vec::new();
for url in urls {
// Clone the URL string so the spawned task owns its data.
let url = url.to_string();
// Spawn a new task. This schedules the future on the executor.
// The task runs concurrently with other tasks.
let handle = trpl::spawn(async move {
page_title(&url).await
});
handles.push(handle);
}
// Collect results from all handles.
let mut titles = Vec::new();
for handle in handles {
// Await the handle to get the result of the spawned task.
// This suspends until the specific task completes.
if let Some(title) = handle.await {
titles.push(title);
}
}
titles
}
Convention aside: In production code, I/O operations return Result<T, Error>, not Option<T>. You'll see patterns like let text = response.text().await?; where the ? operator propagates errors. The trpl examples simplify this to Option for readability, but your real code should handle errors explicitly.
Pitfalls and compiler errors
Async Rust has sharp edges. The compiler helps, but you need to know what to look for.
Forgetting .await
If you call an async function without .await, you get a Future instead of the value. If you try to use that future as a value, the compiler rejects you with E0277 (trait bound not satisfied) or a type mismatch error. The future isn't the data; it's the machine that produces the data. You must await it.
Blocking the executor
Async runtimes assume your code yields control frequently. If you put a CPU-heavy loop or a blocking std::fs::read inside an async function, you freeze the executor. Other tasks can't run. The solution is spawn_blocking, which moves the work to a separate thread pool. Don't block the async thread. The executor is your boss.
Futures that aren't Send
When you spawn a task, the future often needs to move between threads. If the future captures a reference to stack data, it might not be Send. The compiler rejects this with E0277 because the future cannot be sent to another thread. Use owned data or ensure lifetimes don't cross task boundaries.
The "await inside a loop" trap Awaiting inside a loop runs tasks sequentially. If you have ten URLs, you wait for the first, then the second, then the third. This defeats the purpose of async. Spawn all tasks first, then await the handles. Batch your waits.
Decision matrix
Use async fn and .await when your code performs I/O operations like network requests, file reads, or database queries that spend most of their time waiting for the OS. Use blocking I/O when you are doing CPU-bound work or when you are writing a simple script where concurrency doesn't matter. Use spawn when you want to run multiple async tasks concurrently on the same thread pool. Use spawn_blocking when you must call a blocking library inside an async context; the executor will move that work to a separate thread so it doesn't stall other tasks. Reach for join when you need to wait for a fixed set of tasks to complete. Reach for select when you need to race tasks and handle the first one that finishes.