The async function pattern
You are building a function to fetch a user profile from a remote API. In a synchronous world, calling this function freezes the entire thread until the server replies. If you have ten users waiting, you need ten threads, or one user waits nine times longer. Rust offers a different path. You write async fn. The function returns immediately, handing you a handle to the work. You .await that handle later. The thread stays free to do other things while the network request is in flight.
This pattern lets you handle thousands of concurrent connections with a single thread. It changes how you think about control flow. An async function doesn't run to completion when you call it. It builds a machine that can pause and resume. You drive that machine by awaiting it.
What async actually does
An async fn is syntactic sugar. The compiler rewrites it into a regular function that returns a type implementing the Future trait. The return type is impl Future<Output = T>, where T is what you wrote after the arrow.
Think of a restaurant kitchen. When you order food, the waiter doesn't cook the meal at your table. The waiter takes your order, gives you a ticket, and walks away. The ticket represents the work in progress. You can't eat the ticket. You wait for the kitchen to call your number. When the food is ready, you exchange the ticket for the meal.
In Rust, the async fn is the waiter. It takes your arguments, hands you a Future (the ticket), and returns immediately. The Future contains the logic to do the work. The executor (the kitchen) polls the Future to make progress. You call .await on the Future to suspend your current task until the Future produces a value.
use std::future::Future;
/// Fetches a title from a URL.
/// Returns the title or an error string.
async fn fetch_title(url: &str) -> Result<String, String> {
// The compiler transforms this function into:
// fn fetch_title(url: &str) -> impl Future<Output = Result<String, String>>
//
// The body becomes an async block that captures `url`.
// The return type is a state machine that yields control at .await points.
let response = trpl::get(url).await.map_err(|e| e.to_string())?;
let text = response.text().await.map_err(|e| e.to_string())?;
// Parse the HTML and extract the title.
// This is synchronous work; it runs immediately when polled.
let title = Html::parse(&text)
.select_first("title")
.map(|t| t.inner_html())
.unwrap_or_else(|| String::from("No title"));
Ok(title)
}
The function returns a Future. The Future is lazy. Nothing happens until you poll it. Polling usually happens when you .await the Future inside another async context, or when you pass it to a runtime like Tokio.
Don't call an async function and ignore the return value. You just built a machine and threw it away. Await the Future to run the work.
Walking through suspension
When you .await a Future, the current task yields control back to the executor. The executor saves the state of your task and switches to another task that is ready to run. This context switch is cheap. It involves saving a few registers and jumping to a different stack frame. No kernel involvement.
The executor runs other tasks. Eventually, the I/O operation completes. The executor marks the Future as ready and polls it again. Your task resumes exactly where it left off. Variables are preserved. The local stack is restored. From your perspective, time passed, but the code looks sequential.
The compiler generates a state machine for each async function. The state machine tracks which .await point you are at. It stores local variables in a struct. When you yield, the struct is stored on the heap. When you resume, the struct is loaded back. This is why async functions can capture variables by value or reference. The captured data lives as long as the Future lives.
Convention aside: The community treats async fn as the standard way to write async code. You rarely write fn -> impl Future manually. The compiler handles the desugaring, including the tricky Pin mechanics required to ensure the Future doesn't move in memory while suspended. You only need to worry about Pin if you are implementing Future yourself.
Realistic example with error handling
Real async code deals with errors. Network calls fail. JSON parsing fails. Use Result to propagate errors. The ? operator works inside async functions just like it does in sync code. It unwraps the value or returns early with the error.
use serde::Deserialize;
/// Represents a user from the API.
#[derive(Debug, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
}
/// Fetches a user by ID.
/// Returns the user or a reqwest error.
async fn get_user(id: u32) -> Result<User, reqwest::Error> {
let url = format!("https://api.example.com/users/{}", id);
// reqwest::get returns a Future.
// .await polls the Future until the request is sent and response received.
let response = reqwest::get(url).await?;
// response.json() returns a Future that deserializes the body.
// .await polls until deserialization completes.
// The ? operator propagates any error from the response or parsing.
response.json().await
}
/// Demonstrates calling the async function.
/// This function is also async, so it can .await get_user.
async fn main_demo() -> Result<(), Box<dyn std::error::Error>> {
// get_user returns a Future.
// .await runs the Future and yields until the user is fetched.
let user = get_user(42).await?;
println!("Found user: {:?}", user);
Ok(())
}
The ? operator in async functions works seamlessly. It checks the Result. If it's Ok, it extracts the value. If it's Err, it returns the error from the function. The return type must be compatible. If the function returns Result<T, E>, the error type of the expression must convert to E.
Trust the error types. If the compiler complains about mismatched types, check your return type and the errors you are propagating.
The runtime requirement
Async functions don't run themselves. They need an executor. The executor polls Futures, manages I/O events, and schedules tasks. Rust doesn't have a built-in executor in the standard library. You must add a runtime crate like Tokio or async-std.
If you try to run an async function in main without a runtime, the compiler rejects you. main must return (), but async fn main returns a Future. You need an attribute macro to set up the runtime and drive the Future to completion.
// This will not compile.
// main must return (), but async fn main returns impl Future.
// Error E0752: `main` function is not allowed to be `async`
async fn main() {
println!("Hello");
}
Add #[tokio::main] to main. The macro rewrites main to initialize the Tokio runtime and await the async block.
use tokio;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let user = get_user(42).await?;
println!("User: {:?}", user);
Ok(())
}
The attribute macro handles the boilerplate. It creates the runtime, spawns the async block as a task, and blocks the current thread until the task completes. This is the standard entry point for async applications.
Convention aside: Use #[tokio::main] for the entry point. Use #[tokio::test] for async tests. The macros are consistent across the ecosystem. Don't write your own runtime loop unless you have a specific reason.
Pitfalls and compiler errors
Async code introduces new failure modes. The compiler catches many issues, but some require understanding the runtime.
If you try to return a Future from a non-async function without impl Future, the compiler rejects you with E0277 (trait bound not satisfied). The return type must implement Future.
// Error E0277: `impl Future<Output = ...>` is not a valid return type
// because the function is not async and doesn't return impl Future.
fn bad_example() {
async { 42 }
}
Fix this by making the function async or returning impl Future.
If you mix types incorrectly, you get E0308 (mismatched types). This often happens when returning Result vs Option, or when the error types don't match.
// Error E0308: mismatched types
// Expected Result<String, Error>, found Option<String>
async fn bad_return() -> Result<String, Box<dyn std::error::Error>> {
Some("hello".to_string())
}
A critical runtime pitfall is blocking the executor. If you call a synchronous function that blocks the thread, like std::thread::sleep, you freeze the entire executor. No other tasks can run. The application hangs.
// BAD: Blocks the executor thread.
async fn bad_sleep() {
std::thread::sleep(std::time::Duration::from_secs(1));
}
Use async-aware sleep functions. Tokio provides tokio::time::sleep.
// GOOD: Yields control to the executor.
async fn good_sleep() {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
If you must run blocking code, use tokio::task::spawn_blocking. This runs the code on a separate thread pool designed for blocking work.
use tokio::task;
async fn run_blocking_work() {
// Runs the closure on a blocking thread pool.
// Returns a JoinHandle that you can .await.
let result = task::spawn_blocking(|| {
// Heavy CPU work or blocking I/O here.
heavy_computation()
}).await;
// Unwrap the JoinHandle result.
let value = result.expect("Task panicked");
}
Don't block the executor. Use async primitives or spawn blocking tasks.
Decision matrix
Choose the right tool based on your workload. Async is powerful but adds complexity. Use it when it pays off.
Use async fn when you need to perform I/O or wait for external events without blocking the thread. Use async fn when you want to handle many concurrent connections with low memory overhead. Use async fn when your codebase is already async and you need to integrate with existing async libraries.
Use a synchronous fn when the work is pure computation or fast local I/O. The overhead of async isn't worth it for simple math or reading a small file from memory. Use a synchronous fn when you are writing a library that might be used in sync contexts; returning impl Future forces callers to be async.
Use tokio::spawn when you want to run an async function concurrently with other work. Calling an async fn alone just builds the Future. You must spawn it or await it to drive it. Use tokio::spawn to fire and forget, or to run multiple tasks in parallel.
Use tokio::task::spawn_blocking when you must call a synchronous function that blocks. Isolate the blocking call so it doesn't freeze the async executor. Use spawn_blocking for heavy CPU work that would starve other tasks if run on the async thread.
Async is a contract. If you return impl Future, you promise to yield. Keep your promises and the executor will reward you with concurrency.