---
title: How to convert sync code to async
summary: Add the async keyword to functions and use .await on operations to convert sync Rust code to async.
lang: en
slug: how-to-convert-sync-code-to-async
published: 2026-04-17T12:05:42+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-convert-sync-code-to-async/
---

# How to convert sync 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.

```rust
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.

```rust
#[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.

```rust
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

- [How to Use Tracing for Async Debugging in Rust](/en/how-to-use-tracing-for-async-debugging-in-rust/)
- [How to use async with file IO in Rust](/en/how-to-use-async-with-file-io-in-rust/)
- [How to Use Async Traits with Dynamic Dispatch](/en/how-to-use-async-traits-with-dynamic-dispatch/)