What is the difference between tokio and async-std

Tokio is a multi-threaded runtime for high performance, while async-std is a single-threaded runtime mimicking the standard library.

The runtime war: tokio vs async-std

You are building a web scraper that needs to fetch hundreds of pages without waiting for each one to finish. You write an async fn to handle the requests. You compile it. The compiler accepts the code but warns you that the main function returns a Future that is never polled. Your async code is just a recipe sitting on the counter. It needs a chef to execute it.

In Rust, that chef is the runtime. The ecosystem offers two primary choices: tokio and async-std. They both run your futures, but they approach the job with different philosophies. One is a high-performance engine built for scale and deeply integrated with the majority of the async ecosystem. The other is a drop-in replacement for the standard library that prioritizes API familiarity and minimal friction.

What a runtime actually does

Async functions in Rust do not run automatically. Calling an async function returns a Future, which is a state machine describing how to compute a value. The runtime takes that future, polls it to make progress, and manages the event loop. When a future waits for I/O, the runtime parks it and switches to another task. When the I/O is ready, the runtime wakes the future back up.

The runtime also provides the primitives you use inside async code. Channels, mutexes, timers, and task spawners all depend on the runtime's internal machinery. If you use a timer from one runtime inside a task spawned by another, the waker mechanisms will not match. The future will never wake up, and your program hangs.

Think of tokio as a specialized logistics company. They reorganized the warehouse, built custom conveyor belts, and trained staff to move at maximum speed. You have to follow their procedures, but the throughput is incredible. async-std is like a generalist company that kept the warehouse layout exactly the same as the old system but hired faster workers. You can walk in and find everything where you expect it, but the internal processes are different.

Minimal examples

Both crates provide a macro to bootstrap the runtime in main. The syntax looks similar, but the underlying types diverge immediately.

// tokio example
use tokio::net::TcpListener;

/// Starts a listener using tokio's I/O driver.
/// The macro sets up a multi-threaded runtime by default.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // TcpListener is tokio's async-aware type.
    // It integrates with tokio's event loop.
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Listening with Tokio");
    Ok(())
}
// async-std example
use async_std::net::TcpListener;

/// Starts a listener using async-std's I/O driver.
/// The macro sets up a single-threaded runtime by default.
#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // TcpListener mirrors std::net::TcpListener but is async.
    // The API surface matches the standard library.
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Listening with async-std");
    Ok(())
}

The community convention is to pick one runtime per binary and stick to its types. Mixing tokio::net::TcpListener inside an async-std runtime causes silent failures. The future will never complete because the runtime does not know how to poll tokio's internal state.

How the machinery differs

tokio was designed for performance and scalability from the start. It uses a work-stealing scheduler that distributes tasks across multiple threads efficiently. The I/O driver is built on mio and supports edge-triggered events on Linux for maximum throughput. tokio also provides a rich set of macros and utilities like select!, join!, and time::sleep that are idiomatic in the community.

async-std was designed to be a drop-in replacement for std. It aims to provide the same API surface as the standard library but with async behavior. If you know std::fs, async-std::fs works the same way. The runtime uses the polling crate for I/O and supports both single-threaded and multi-threaded modes. The default is single-threaded, which matches the mental model of many JavaScript developers and simplifies reasoning about shared state.

The waker implementation is where the incompatibility lives. Each runtime creates a Waker that knows how to reschedule a future on its own executor. When you poll a future, you pass a Context containing the waker. If a tokio future receives an async-std waker, it cannot register itself with the tokio scheduler. The future gets stuck.

The runtime is the engine. The future is the car. You cannot put a diesel engine in a hydrogen fuel cell car and expect it to run.

Realistic patterns: Send, Rc, and select

The choice of runtime affects how you write concurrent code. tokio defaults to a multi-threaded runtime. Every task spawned with tokio::spawn must be Send. This means you cannot use Rc inside a tokio task. You must use Arc to share data across threads.

async-std defaults to a single-threaded runtime. Tasks never move between threads, so Rc is allowed. You can use Rc and RefCell for interior mutability without the overhead of atomic reference counting. This can be faster for single-threaded workloads and matches the ergonomics of synchronous code that uses Rc.

use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task;

/// Shared counter protected by an async mutex.
/// Arc is required because tasks may run on different threads.
struct Counter {
    value: Mutex<i32>,
}

#[tokio::main]
async fn main() {
    let counter = Arc::new(Counter {
        value: Mutex::new(0),
    });

    // Spawn tasks that increment the counter.
    // Each task clones the Arc, not the counter.
    for _ in 0..10 {
        let counter_clone = counter.clone();
        task::spawn(async move {
            // Lock is async to avoid blocking the runtime thread.
            let mut val = counter_clone.value.lock().await;
            *val += 1;
        });
    }
}
use std::cell::RefCell;
use std::rc::Rc;
use async_std::task;

/// Shared counter using Rc and RefCell.
/// Rc is safe because the runtime is single-threaded.
struct Counter {
    value: RefCell<i32>,
}

#[async_std::main]
async fn main() {
    let counter = Rc::new(Counter {
        value: RefCell::new(0),
    });

    // Spawn tasks that increment the counter.
    // Rc works here because tasks never leave the main thread.
    for _ in 0..10 {
        let counter_clone = counter.clone();
        task::spawn(async move {
            // Borrow is synchronous.
            // No other task can run while this borrow is active.
            let mut val = counter_clone.value.borrow_mut();
            *val += 1;
        });
    }
}

tokio also provides powerful macros for composing futures. select! allows you to wait on multiple futures and run the branch that completes first. async-std relies more on combinators or manual polling for complex patterns, though it has some macro support. The community convention in tokio is to reach for select! whenever you have concurrent branches. In async-std, you might reach for future::select or write a loop that polls manually.

Convention aside: tokio::time::sleep is the standard way to delay execution. async-std::task::sleep does the same job. Do not mix them. If you use tokio::time::sleep in an async-std runtime, the sleep never fires. Check your imports carefully.

Pitfalls and compiler errors

The biggest pitfall is mixing runtimes. The compiler will not stop you from using tokio types inside an async-std function. Both implement Future. The error manifests at runtime as a hang or a panic. You might see a panic message about "future created inside executor" or simply watch your program do nothing.

Another pitfall is blocking the runtime. Both runtimes expect tasks to yield frequently. If you perform a heavy CPU calculation or call a synchronous blocking function, you block the entire thread. In a multi-threaded runtime, you block one worker. In a single-threaded runtime, you block everything.

Use tokio::task::spawn_blocking or async_std::task::spawn_blocking to offload blocking work to a separate thread pool. The compiler cannot detect blocking calls. It is up to you to audit your code.

If you try to spawn a non-Send task in a multi-threaded runtime, the compiler rejects you with E0277 (trait bound not satisfied). This happens if you use Rc or a non-Send type inside a tokio::spawn call. The fix is to switch to Arc or use a single-threaded runtime.

Block the thread and you block the runtime. Offload heavy CPU work to a blocking pool, or your async advantage vanishes.

Decision matrix

Use tokio when you are building a production service, web server, or network tool where ecosystem maturity matters. Most async crates depend on tokio, so choosing it avoids runtime conflicts. Use tokio when you need high-performance multi-threaded scheduling out of the box. The work-stealing scheduler handles CPU-bound bursts and I/O waits efficiently without manual tuning. Use tokio when you want access to a rich set of macros and primitives. Features like select!, join!, and time::sleep are idiomatic and widely used in the community.

Use async-std when you are writing a script, CLI tool, or educational project where familiarity with std is more important than raw throughput. The API mirrors the standard library, so you can swap std::fs for async-std::fs and keep the same mental model. Use async-std when you prefer a runtime that defaults to a single-threaded event loop. This matches the behavior of many JavaScript environments and simplifies reasoning about shared state without locks. Use async-std when you are building a library that wants to remain runtime-agnostic but needs a default for examples. The crate exposes traits that allow users to inject their own runtime if needed.

Check your dependencies. If three of your crates require tokio, you are using tokio.

Where to go next