The ticket that never rings
You write a function to fetch a user profile. You mark it async. You call it from main. The compiler stops you with a hard error. It says the function returns a Future, not the result you expect. You forgot .await.
This error trips up developers coming from JavaScript or Python. In those languages, calling an async function starts the work immediately. The function returns a Promise or coroutine, but the background task is already running. Rust works differently. Calling an async fn creates a value that contains the work, but does absolutely nothing until you explicitly tell it to run.
If you call an async function and ignore the return value, the work never happens. The compiler forces you to acknowledge the Future so you don't accidentally drop work on the floor.
Futures are lazy machines
An async fn does not return the result type you see in the signature. It returns an anonymous type that implements the Future trait. Think of a Future as a lazy state machine. It holds the code and the current state, but it sits idle until polled.
Picture a restaurant kitchen. In a synchronous world, you order a meal, the chef cooks it, and you wait at the table until it arrives. In an async world, you order a meal and the chef hands you a ticket. You can walk away. The ticket represents the pending work. Nothing happens to your meal until you stand at the pickup counter and present the ticket. The chef checks the kitchen, and if the meal is ready, hands it over. If not, the chef puts the ticket in a queue and tells you to come back later.
The async fn is the order. The Future is the ticket. .await is standing at the counter. If you just hold the ticket and walk home, you never get the food. The meal might not even start cooking until someone checks the ticket, depending on how the kitchen is designed.
This laziness is a feature, not a bug. It lets you compose complex async pipelines without paying the cost until you actually need the result. You can build a chain of operations that fetches data, transforms it, and saves it, and the entire chain sits in memory doing nothing until you .await the final step.
Minimal example
Here is the error in its simplest form. The code creates a Future but never polls it.
use trpl;
/// Simulates fetching a title from a URL.
async fn page_title(url: &str) -> String {
// This function returns a Future, not a String.
// The work inside this function hasn't started yet.
let response = trpl::get(url).await;
response.text().await
}
fn main() {
// This calls the function and gets a Future.
// The Future is stored in a variable.
let future = page_title("https://example.com");
// The compiler rejects this.
// The Future is created but never polled.
// The work will never run.
println!("Got future: {:?}", future);
}
The compiler rejects this with an error like "future must be used with await or sent to a blocking executor". The Future is a value that must be driven. If you let it go out of scope without polling, the work is lost.
To fix this, you need an async context and .await. The .await operator suspends the current function, hands control back to the executor, and resumes only when the Future is ready.
use trpl;
/// Simulates fetching a title from a URL.
async fn page_title(url: &str) -> String {
let response = trpl::get(url).await;
response.text().await
}
// The runtime macro sets up the executor.
#[trpl::main]
async fn main() {
// .await polls the Future until it completes.
// This suspends main() while the fetch runs.
let title = page_title("https://example.com").await;
println!("Title: {title}");
}
Convention aside: In real projects, trpl is a crate used by the Rust book for examples. Production code usually uses tokio or async-std. The pattern is identical. You'll see #[tokio::main] and tokio::spawn in the wild. The semantics of Future and .await are part of the standard library, so they work the same way regardless of the runtime.
What happens under the hood
When you write async fn, the compiler desugars it into a regular function that returns an impl Future. The body of the function becomes a state machine. Each .await point becomes a state transition.
When you call .await, the following happens:
- The executor calls the
pollmethod on theFuture. - The
Futureruns its logic until it hits an.awaitpoint or completes. - If the work is done, the
FuturereturnsPoll::Ready(value). The.awaitresumes and yields the value. - If the work is not done, the
FuturereturnsPoll::Pending. It registers aWakerso the executor knows when to call it back. The.awaitsuspends, and control returns to the executor. - The executor can now run other tasks. When the external event happens (like a socket receiving data), the executor wakes the
Futureand polls it again.
This mechanism allows thousands of async tasks to run on a single thread. The thread never blocks. It switches between tasks whenever one yields. If you forget .await, the Future is never polled. The state machine never advances. The work never starts.
Ah-ha reveal: In JavaScript, a Promise starts running the moment you create it. In Rust, a Future does absolutely nothing until polled. You can create a Future that downloads a gigabyte file, store it in a variable for an hour, and the download won't start until you .await it. This is the core difference between eager promises and lazy futures.
Realistic scenario
Consider a service that fetches data from two APIs and merges the results. You want to fetch both concurrently.
use trpl;
/// Fetches user data from an API.
async fn fetch_user(id: u32) -> String {
trpl::get(&format!("/user/{id}")).await.text().await
}
/// Fetches post data from an API.
async fn fetch_post(id: u32) -> String {
trpl::get(&format!("/post/{id}")).await.text().await
}
#[trpl::main]
async fn main() {
// Create both Futures. They are lazy.
// No network requests have started yet.
let user_future = fetch_user(42);
let post_future = fetch_post(99);
// Await both concurrently.
// The executor polls both Futures.
// They run in parallel on the same thread.
let (user, post) = trpl::join!(user_future, post_future);
println!("User: {user}");
println!("Post: {post}");
}
If you forget .await on one of the futures, the compiler catches it. But there is a subtler pitfall. If you store a Future in a struct and forget to poll it later, the work silently never happens. This is why the compiler warns about unused futures. It's protecting you from logic errors where you assume work is running but it isn't.
Pitfall: Holding a Future for too long can cause issues. Some Futures hold resources like file handles or network connections. If you create a Future and don't poll it promptly, you might hold the resource open without making progress. Always poll Futures as soon as you create them, or drop them if you no longer need the work.
Pitfalls and compiler errors
The most common error is calling an async function in a sync context without a runtime.
fn main() {
// Error: cannot call async fn in sync context.
let _ = fetch_user(42);
}
The compiler rejects this because main is not async. You need to mark main as async and provide a runtime, or use block_on to drive the future from sync code.
Another pitfall is forgetting .await inside a loop.
async fn process_items(items: Vec<String>) {
for item in items {
// Error: future must be awaited.
// This creates a Future and drops it immediately.
// The work never runs.
let _ = fetch_user(item.parse().unwrap());
}
}
The compiler warns about the unused future. If you suppress the warning with let _ =, you've introduced a bug. The loop runs instantly, creating and dropping futures without doing any work. Always await inside loops, or collect futures and join them later.
Convention aside: When you intentionally drop a future, use drop(future) to signal to readers that you considered the value and chose to discard it. let _ = future looks like you forgot to use it. drop(future) says "I know this is a future, and I'm throwing it away."
Decision matrix
Use .await when you need the result immediately and are inside an async context. Use tokio::spawn when you want to run the task in the background and don't need the result right now. Use block_on when you are in a synchronous context and must wait for an async task to finish. Use join! when you need to wait for multiple tasks concurrently. Use select! when you need to wait for the first of multiple tasks to complete.
Don't fight the compiler here. If it says a future must be awaited, it means you have a value that can do work but isn't doing it. Either poll it, spawn it, or drop it intentionally.