How to convert sync code to async

Add the async keyword to functions and use .await on operations to convert sync Rust code to async.

The problem with waiting

You are building a tool that fetches data from several websites. You write a function that requests a URL, parses the response, and returns the result. You run it, and it works. Then you try to fetch ten URLs. The program takes ten times as long. You realize the thread spends most of its time sitting idle, waiting for the network. While the thread waits for the first request, it cannot start the second. The thread is stuck staring at the wall.

You could spawn a new thread for each request. Threads are heavy. The operating system allocates stack memory and context-switching overhead for each one. Spawning hundreds of threads drains resources and slows the system down. You need a way to handle many waiting operations on a single thread without blocking it.

That is what async Rust solves. Async lets you write code that looks sequential but yields control whenever it has to wait. The thread can switch to another task while the network request is in flight. When the data arrives, the thread picks up exactly where it left off.

Async is a ticket system

Think of a busy deli. In a sync world, you order a sandwich, stand at the counter, and wait. The cashier cannot help anyone else until your sandwich is ready. The line moves one person at a time.

Async is the ticket system. You order, grab a ticket, and step aside. The cashier helps the next person. When your sandwich is ready, the ticket machine beeps, you step back up, and get your food. The cashier juggles many orders by switching between them whenever one has to wait.

In Rust, the thread is the cashier. The async function is the ticket. The .await keyword is the moment you step aside. The code runs until it hits .await, saves its state, and returns control to the runtime. The runtime runs other Futures. When the I/O operation completes, the runtime wakes up your Future and resumes execution.

The thread can only juggle tickets if the code hands control back. Async is cooperative, not preemptive. The runtime does not interrupt your code. Your code must yield explicitly via .await.

The minimal conversion

Converting a sync function to async requires two changes. Add the async keyword to the function signature. Add .await to any operation that performs I/O or waits for a result.

use reqwest;

/// Fetches the text content of a URL.
/// Returns an error if the request fails or the body cannot be read.
async fn fetch_text(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    // Start the HTTP request. The .await yields control back to the runtime
    // while the network operation is in progress. The thread is free to run
    // other tasks during this wait.
    let response = reqwest::get(url).await?;
    
    // Read the response body. Another await point where the thread
    // can switch to other tasks. This does not block the thread.
    let text = response.text().await?;
    
    Ok(text)
}

The function signature now returns a Future. The async keyword is syntactic sugar. The compiler rewrites the function to return a struct that implements the Future trait. The return type becomes impl Future<Output = Result<String, Box<dyn std::error::Error>>>.

You cannot call this function like a normal function and get the result. Calling fetch_text creates a Future and returns it immediately. The Future is a state machine that holds the code and variables. Nothing happens until you poll the Future.

To run the Future, you need a runtime. A runtime provides the loop that polls Futures and manages I/O events. The tokio crate is the most common runtime. You mark your main function with #[tokio::main] to set up the runtime.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Call the async function and await the result.
    // The main function is also async, so it runs as a Future
    // driven by the tokio runtime.
    let text = fetch_text("https://example.com").await?;
    
    println!("Got {} bytes", text.len());
    Ok(())
}

Calling an async function creates a Future. Nothing happens until you await it. The Future is a blueprint, not the building.

What the compiler actually does

The compiler transforms your async fn into a struct that implements the Future trait. This struct captures all your local variables and adds a state enum. The enum tracks exactly which line of code you executed last.

When you write .await, the compiler inserts a suspension point. At runtime, the function runs until that point, saves the state, and returns control to the runtime. The runtime puts the Future aside and runs something else. When the I/O operation completes, the runtime calls the Future's poll method. The state machine resumes from the saved state, restores the variables, and continues execution.

This state machine transformation has consequences. The async block captures variables by move. If you use a variable inside an async block, the compiler moves it into the Future struct. You cannot use that variable after the async block starts, unless you clone it first. This is similar to closures, but it catches people off guard because the async keyword looks like a modifier, not a closure.

Another consequence is that async fn cannot return references to local variables in the same way sync functions can. The local variables live inside the Future struct. If you try to return a reference, the compiler has to ensure the Future lives long enough. This often leads to lifetime errors. The safe pattern is to return owned types like String or Vec, or to use Rc or Arc for shared references if the data must outlive the Future.

The compiler builds a state machine for you. You write linear code; the compiler writes a jump table.

Realistic example: Concurrent fetch

The power of async shines when you run multiple tasks concurrently. You can create Futures for many operations and let the runtime poll them all. The runtime switches between them as they wait, achieving concurrency without multiple threads.

use futures::future;
use reqwest;

/// Fetches text from multiple URLs concurrently.
/// Returns a vector of results in the same order as the input URLs.
async fn fetch_many(urls: &[&str]) -> Vec<Result<String, Box<dyn std::error::Error>>> {
    // Create a Future for each URL. This does not start the requests yet.
    // It just builds the state machines. The work begins when the runtime polls them.
    let futures = urls.iter().map(|url| fetch_text(url));
    
    // Join all futures. They will run concurrently.
    // The runtime will poll them all, switching between them as they wait.
    // join_all returns a vector of results in the original order.
    let results = future::join_all(futures).await;
    
    results.into_iter().collect()
}

/// Fetches the text content of a single URL.
async fn fetch_text(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    let response = reqwest::get(url).await?;
    let text = response.text().await?;
    Ok(text)
}

The fetch_many function builds a collection of Futures. The join_all function takes the collection and returns a single Future that waits for all of them to complete. When you .await the result, the runtime polls all the inner Futures. As each request waits for the network, the runtime switches to the next one. When a request finishes, the runtime records the result and moves on.

Concurrency is free in async until you actually run it. Build the futures, then let the runtime orchestrate the dance.

Pitfalls and errors

Async code introduces new failure modes. The most common issue is the Send bound. Futures must be Send to move across threads. If you capture a !Send type in an async fn, the Future becomes !Send. The compiler rejects you with E0277 (trait bound not satisfied) because the Future needs to be Send to run on a multi-threaded runtime.

This often happens with RefCell. RefCell provides interior mutability but is not Send. If you use RefCell inside an async fn, the Future cannot cross threads. You cannot use it with tokio::spawn or a multi-threaded runtime. Use Mutex instead if you need interior mutability that is Send.

Another pitfall is blocking the runtime. If you call a synchronous blocking function inside an async fn, the thread stops processing other Futures. The runtime cannot switch tasks while the thread is blocked. This starves the runtime and kills performance. Use async versions of I/O operations, or offload blocking work to a dedicated thread pool using tokio::task::spawn_blocking.

A subtle logic error occurs when you call an async fn without .await. The Future is created but never polled. Nothing happens. The code compiles, but the task never runs. This is a common bug when refactoring sync code to async. Always check that every async call is awaited or spawned.

If your Future isn't Send, it's stuck on one thread. Check the trait bounds before you panic.

When to use async

Async is a tool for handling waiting, not a magic speed boost. Use it where the wait is real.

Use async fn for I/O operations where the thread would otherwise sit idle waiting for the network or disk. Use sync functions for CPU-bound calculations or simple logic; the overhead of creating a Future state machine is unnecessary when the work finishes in microseconds. Use tokio::spawn when you want to fire off a task and let it run in the background without blocking the current function. Use .await when the subsequent code depends on the result of the async operation. Use futures::join_all when you need to wait for a dynamic list of futures to complete concurrently. Use Mutex for interior mutability in async code instead of RefCell, because Mutex is Send and works across threads.

Convention aside: The Rust community strongly prefers async fn in function signatures. It is syntactic sugar for fn -> impl Future<Output = T>. The sugar is cleaner and handles the lifetime of the Future automatically. You only need to write impl Future manually if you are returning a trait object or dealing with complex generic bounds that the sugar cannot express. Stick to async fn unless you have a specific reason not to.

Convention aside: The community calls the rule of keeping unsafe blocks small the "minimum unsafe surface". In async code, this applies doubly. If you have unsafe code that interacts with I/O, ensure the safety invariants hold across suspension points. A variable might be mutated by another task while your Future is suspended. Document these invariants in // SAFETY: comments.

Async is for waiting. If there's no wait, there's no async.

Where to go next