Skip to content
Codeloom
Rust

Tokio vs async-std: Choosing a Rust Async Runtime

A practical comparison of Tokio and async-std covering performance, ecosystem, API design, and migration considerations for async Rust projects.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How Tokio and async-std differ in architecture and API design
  • Performance characteristics of each runtime
  • Ecosystem and library compatibility trade-offs
  • How to pick the right runtime for your project

Prerequisites

None — this post is self-contained.

Rust does not ship a built-in async runtime. The language gives you async/await syntax and the Future trait, but you need an external crate to actually poll futures, schedule tasks, and handle I/O. The two leading options are Tokio and async-std. This article breaks down their differences so you can make an informed choice.

Architecture Overview

Both runtimes use a multi-threaded work-stealing scheduler by default, but they differ in philosophy. Tokio treats itself as a framework: you opt into its reactor, timer, and I/O driver explicitly. async-std aims to mirror the standard library, replacing std::fs, std::net, and std::task with async equivalents that feel familiar.

// Tokio: explicit runtime construction
#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
        .await
        .unwrap();
    println!("Listening on port 8080");
}
// async-std: mirrors std API surface
use async_std::net::TcpListener;
use async_std::prelude::*;

#[async_std::main]
async fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
    println!("Listening on port 8080");
}

The code looks similar at the surface, but the internal machinery differs. Tokio uses a dedicated I/O driver thread paired with a configurable thread pool. async-std uses a global executor that lazily spawns threads based on load.

Task Spawning and Scheduling

Both runtimes let you spawn tasks that run concurrently. The APIs are nearly identical, but the scheduling behavior has subtle differences.

// Tokio task spawning
#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        42
    });

    let result = handle.await.unwrap();
    println!("Result: {result}");
}
// async-std task spawning
use async_std::task;
use std::time::Duration;

#[async_std::main]
async fn main() {
    let handle = task::spawn(async {
        task::sleep(Duration::from_millis(100)).await;
        42
    });

    let result = handle.await;
    println!("Result: {result}");
}

Tokio’s spawn returns a JoinHandle that wraps the result in a Result to capture panics. async-std’s spawn returns a JoinHandle that directly yields the value. This means Tokio forces you to handle the possibility of a panicked task, while async-std will propagate the panic to the awaiting code.

Tokio also offers spawn_blocking for offloading CPU-intensive or blocking work to a dedicated thread pool, which prevents stalling the async executor. async-std provides the same via task::spawn_blocking.

Timer and Sleep

Timer APIs reveal a design philosophy difference. Tokio provides a rich timer module with sleep, interval, and timeout utilities:

use tokio::time::{self, Duration};

#[tokio::main]
async fn main() {
    // Timeout a slow operation
    let result = time::timeout(Duration::from_secs(2), async {
        time::sleep(Duration::from_secs(1)).await;
        "done"
    })
    .await;

    match result {
        Ok(val) => println!("Completed: {val}"),
        Err(_) => println!("Timed out"),
    }

    // Periodic work
    let mut interval = time::interval(Duration::from_millis(500));
    for _ in 0..3 {
        interval.tick().await;
        println!("tick");
    }
}

async-std keeps things minimal. You get task::sleep and can build timeouts using the future::timeout function from the async-std crate or reach for the futures crate’s utilities.

Channel Support

Channels are essential for communication between async tasks. Tokio ships its own channel implementations: mpsc, oneshot, broadcast, and watch. These are tightly integrated with the Tokio runtime.

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);

    tokio::spawn(async move {
        for i in 0..5 {
            tx.send(i).await.unwrap();
        }
    });

    while let Some(val) = rx.recv().await {
        println!("Received: {val}");
    }
}

async-std does not bundle its own channels. You typically use the async-channel crate or the channels from the futures crate. This keeps async-std lighter but means you pull in additional dependencies.

Ecosystem and Library Support

This is where the comparison becomes lopsided. Tokio dominates the Rust async ecosystem. Libraries like hyper, axum, tonic, reqwest, sqlx, and rdkafka are built on top of Tokio or require a Tokio runtime. If you pick async-std, you may find that critical dependencies either do not support it or require compatibility layers.

The async-compat crate can bridge the gap by running Tokio-dependent futures on an async-std runtime, but this adds complexity and can introduce subtle bugs.

// Using async-compat to run Tokio code on async-std
use async_compat::Compat;
use async_std::task;

fn main() {
    task::block_on(async {
        // Wrap Tokio-dependent code
        let body = Compat::new(async {
            let resp = reqwest::get("https://httpbin.org/get").await.unwrap();
            resp.text().await.unwrap()
        })
        .await;

        println!("Body length: {}", body.len());
    });
}

This works but is not ideal for production systems where you want a single, consistent runtime.

Performance Comparison

Benchmarks vary by workload, but the general findings are:

Throughput: Tokio’s work-stealing scheduler is heavily optimized and generally handles high-connection-count scenarios (10k+ concurrent connections) more efficiently. async-std performs well for moderate loads but has not received the same level of optimization effort.

Latency: Both runtimes deliver sub-millisecond task scheduling latency for typical workloads. Tokio’s io_uring support (via tokio-uring) gives it an edge on Linux for I/O-heavy applications.

Memory: async-std’s lazy thread spawning can result in lower memory usage under light load. Tokio pre-allocates its thread pool, which provides more predictable performance but uses more memory at idle.

For most applications, the performance difference is not the deciding factor. Ecosystem compatibility and API preferences matter more.

Configuration and Tuning

Tokio gives you fine-grained control over the runtime:

use tokio::runtime::Builder;

fn main() {
    let runtime = Builder::new_multi_thread()
        .worker_threads(4)
        .thread_name("my-worker")
        .thread_stack_size(3 * 1024 * 1024)
        .enable_all()
        .build()
        .unwrap();

    runtime.block_on(async {
        println!("Running on custom runtime");
    });
}

You can also create a single-threaded runtime with Builder::new_current_thread(), which is useful for testing or embedded scenarios.

async-std provides less configuration. Its global executor is initialized once and manages threads automatically. This simplicity is a feature if you do not need tuning, but a limitation if you do.

When to Choose Tokio

Pick Tokio when your project depends on libraries from the broader Tokio ecosystem (which covers most production Rust web services), when you need fine-grained runtime configuration, or when you are building a high-performance networked service that demands the most optimized scheduler available.

When to Choose async-std

Pick async-std when you value API simplicity and want async code that looks like synchronous standard library code, when you are building a smaller project with minimal external dependencies, or when you are teaching async Rust and want to reduce cognitive overhead.

Migration Between Runtimes

Migrating between runtimes is possible but not trivial. The main challenges are replacing runtime-specific types (channels, timers, I/O types) and ensuring all transitive dependencies are compatible. Using runtime-agnostic abstractions from the futures crate for core logic can reduce coupling.

// Runtime-agnostic code using the futures crate
use futures::stream::{self, StreamExt};

async fn process_items(items: Vec<i32>) -> Vec<i32> {
    stream::iter(items)
        .map(|x| async move { x * 2 })
        .buffer_unordered(4)
        .collect()
        .await
}

This function works with either runtime because it depends only on the futures crate.

Wrapping Up

Tokio is the default choice for production Rust async code. Its ecosystem dominance, performance optimizations, and configuration options make it the pragmatic pick for most projects. async-std offers a cleaner API that mirrors the standard library, but its smaller ecosystem limits its viability for complex applications. If you are starting a new project and are unsure, go with Tokio. You will encounter fewer dependency conflicts and find more community resources when you need help.