How to Use async-std as an Alternative to Tokio

Switch from Tokio to async-std by updating your Cargo.toml dependencies and replacing Tokio-specific macros and imports with their async-std equivalents.

When the standard library feels out of reach

You are building an async service. You started with tokio because it is the default choice in tutorials. It works, but you notice a pattern. Every file operation requires tokio::fs. Every network socket needs tokio::net. Every sleep call pulls in tokio::time. You miss the simplicity of std. You want to write File::open and have it just work. You want the API to look like the standard library, even though the code runs asynchronously.

That is where async-std comes in. It is designed to be a drop-in replacement for std in async contexts. The goal is familiarity. You keep the mental model of the standard library, and async-std provides the async machinery underneath. You do not need to learn a new set of types for every operation. You write code that looks like sync Rust, and the runtime handles the concurrency.

The philosophy of familiarity

async-std takes a different approach than tokio. tokio builds a complete async ecosystem with its own types and modules. You opt into async by importing tokio::fs instead of std::fs. async-std mirrors the standard library's structure. It provides async_std::fs, async_std::net, and async_std::io that closely resemble their std counterparts.

Think of tokio as a specialized workshop. You bring your own tools, but the workshop has its own layout, its own power outlets, and its own safety protocols. You learn the workshop to be productive. Think of async-std as a universal adapter. You keep your standard tools. The adapter translates your standard actions into async operations. You do not change your workflow. The adapter handles the translation.

This design choice matters for migration and learning. If you are porting sync code to async, async-std often requires fewer import changes. If you are teaching async Rust, async-std lets students focus on await and futures without memorizing runtime-specific module paths. The trade-off is ecosystem breadth. tokio has more third-party support because it reached critical mass first. async-std covers the standard library well, but some niche crates only target tokio.

Minimal setup

Switching to async-std starts with Cargo.toml. You need the crate and the attributes feature. The feature enables the procedural macros that bootstrap the runtime. Without it, you cannot use the #[async_std::main] macro.

[dependencies]
async-std = { version = "1.12", features = ["attributes"] }

The attributes feature is optional by design. It keeps compile times lower for users who only need the library types and do not want the macro overhead. If you skip the feature, you must call async_std::task::block_on manually. The convention is to enable it for applications. It simplifies the entry point.

use async_std::task;

/// Entry point that runs the async-std executor.
#[async_std::main]
async fn main() {
    println!("Hello from async-std!");
}

The macro expands to a standard main function. It calls block_on with your async block. block_on starts the executor and waits for the future to complete. The executor schedules tasks and drives them to completion. When the future finishes, block_on returns the result. The macro hides this boilerplate. You get a clean entry point that looks like sync code.

Convention aside: async_std::task::spawn returns a JoinHandle. The handle implements Future. You can await the handle to get the result. This mirrors std::thread::JoinHandle but works asynchronously. The naming is intentional. It signals that you are joining a task, not waiting for a thread.

How the executor drives your code

The executor is the engine. It holds a queue of tasks. When you call spawn, the task goes into the queue. The executor polls tasks in a loop. If a task is ready, it runs. If a task is waiting on I/O or a timer, the executor moves to the next task. This non-blocking behavior is what makes async efficient. One thread can manage thousands of tasks.

async-std uses a multi-threaded executor by default. It spawns a pool of worker threads. Each thread runs its own loop. Tasks can migrate between threads. This design provides good throughput for I/O-bound workloads. You do not need to configure the thread pool for most applications. The defaults work well.

If you need a single-threaded executor, you can use async_std::task::LocalSet. It runs tasks on the current thread. This is useful when you have state that cannot be shared across threads. LocalSet ensures that spawned tasks stay on the same thread. You still get concurrency, but the tasks share the thread's stack and memory space.

use async_std::task;

/// Runs tasks on a single thread using a LocalSet.
#[async_std::main]
async fn main() {
    let mut local_set = task::LocalSet::new();

    local_set.spawn_local(async {
        task::sleep(std::time::Duration::from_millis(100)).await;
        println!("Task ran on the local set");
    });

    // Block until all tasks in the local set complete.
    local_set.await;
}

LocalSet implements Future. Awaiting it drives all spawned tasks to completion. This is a common pattern for testing or for isolating non-send state. The spawn_local method takes a future that does not need to be Send. This relaxes the constraint compared to spawn, which requires Send futures.

Realistic usage: files and concurrency

A realistic example shows how async-std mirrors std. You can read files, spawn tasks, and handle errors using familiar patterns. The API shape is the same. The methods return futures instead of results. You add .await to drive them.

use async_std::fs::File;
use async_std::io::prelude::*;
use async_std::task;

/// Reads a file and spawns a background task to process data.
#[async_std::main]
async fn main() -> std::io::Result<()> {
    // Open the file asynchronously.
    let mut file = File::open("data.txt").await?;
    let mut contents = String::new();
    
    // Read the file contents.
    file.read_to_string(&mut contents).await?;
    println!("Read {} bytes", contents.len());

    // Spawn a task to simulate background work.
    let handle = task::spawn(async move {
        // Simulate processing.
        task::sleep(std::time::Duration::from_millis(50)).await;
        contents.len() * 2
    });

    // Await the result from the spawned task.
    let processed = handle.await;
    println!("Processed result: {}", processed);

    Ok(())
}

The code looks like sync Rust. File::open returns a future. read_to_string returns a future. task::spawn returns a handle. You await each step. The error handling uses ? just like sync code. The return type is std::io::Result. async-std uses standard error types where possible. This reduces friction when integrating with other crates.

Convention aside: async_std::task::sleep takes a Duration. Never use std::thread::sleep in async code. std::thread::sleep blocks the entire thread. If you block the thread, the executor cannot run other tasks. The application hangs. The compiler does not catch this. It is a runtime mistake. Always use the async sleep function provided by your runtime.

Pitfalls and compiler errors

Switching runtimes introduces pitfalls. The most common issue is mixing runtimes. You cannot easily mix tokio and async-std tasks in the same binary. If you try to use tokio::spawn inside an async_std::main, the code fails to compile.

The compiler rejects this with E0433 (unresolved import) if you forget to import tokio. If you import both and try to use types from one runtime in the other, you get E0277 (trait bound not satisfied). The types are incompatible. tokio::sync::Mutex is not the same as async_std::sync::Mutex. They have different internal structures and different methods. You must choose one runtime and stick with it.

Another pitfall is blocking calls. If you use std::fs::read_to_string instead of async_std::fs::File, you block the executor. The compiler does not warn you. The code compiles and runs. The application just hangs when the blocking call takes time. This is a logical error, not a syntax error. You must audit your code for blocking operations. Replace std::fs with async_std::fs. Replace std::net with async_std::net. Replace std::thread::sleep with async_std::task::sleep.

Ecosystem lock-in is a third pitfall. Some crates only support tokio. If you depend on a crate that uses tokio::runtime::Handle, you cannot use it with async-std. You might need to find an alternative or write an adapter. Check your dependencies before switching. Look for crates that support multiple runtimes via features. Many popular crates like reqwest and serde work with any runtime. Niche crates might not.

Decision: when to use async-std

Use async-std when you want an API that mirrors the standard library and you value familiarity over ecosystem breadth. Use async-std when you are porting sync code to async and want minimal import changes. Use async-std when you are teaching async Rust and want students to focus on futures rather than runtime-specific types.

Use tokio when you need the largest ecosystem of async crates, since most third-party libraries target tokio first. Use tokio when you are building high-performance network services where the runtime's optimization and multi-threaded work-stealing scheduler matter more than API shape. Use tokio when you depend on crates that only support tokio, such as certain database drivers or streaming frameworks.

Do not mix runtimes in the same binary unless you have a specific reason and understand the isolation boundaries. Treat the runtime choice as a project-level decision. Switching later is possible but requires refactoring imports and types. Pick the runtime that matches your team's mental model and your dependency graph.

Where to go next