Unless you have a specific reason to avoid Tokio, use Tokio: it has 150M+ monthly downloads, ecosystem dominance, and proven stability in production at scale.
Rust's async/await syntax requires a runtime to execute futures. This guide compares the three main Rust async runtimes: Tokio, async-std, and smol: covering performance, ecosystem compatibility, API differences, and when to choose each.
By Max Wells, updated August 2026
TL;DR: Rust's async/await compiles to state machines but needs a runtime to drive them. In 2026, Tokio dominates production use. async-std and smol are alternatives with different design philosophies. Unless you have a specific reason to avoid Tokio, use Tokio.
- Tokio: battle-tested, most ecosystem support, 150M+ downloads/month: the production standard
- async-std: mirrors std API surface, simpler onboarding, smaller ecosystem
- smol: minimal runtime focused on composability and small binary size
- Runtime ≠ language feature:
async/awaitis stable Rust; you choose a runtime separately- Ecosystem lock-in: most async crates (reqwest, sqlx, axum, tonic) require Tokio
Who Should Read This?
This article is for engineers evaluating async Rust for the first time and experienced Rust developers who want a definitive comparison of the runtime landscape in 2026. The ideal reader is a software engineer at a US startup or tech company: typically earning $130K–$180K: who needs to pick an async runtime for a new service and wants to understand what they are committing to before adding it to a Cargo.toml. By the end you will have a clear answer for your use case and understand the ecosystem consequences of each choice.
Bottom line: Best default for production async Rust: Tokio. Best reason to use something else: a very specific simplicity, binary-size, or custom-runtime constraint.
Which Runtime Should You Choose in 2026?
Choose Tokio unless you have a strong reason not to. Choose async-std only if its API style materially helps your team. Choose smol only if minimalism and custom composition matter more than ecosystem gravity.
Use this quick filter:
- Choose Tokio if you are building a backend service, using Axum,
reqwest,sqlx, or anything else in the mainstream async Rust ecosystem. - Choose async-std if your team strongly prefers its
std-like API surface and the ecosystem tradeoff is acceptable. - Choose smol if you are building a smaller or more specialized async stack where every dependency and binary-size tradeoff matters.
Why Do Runtimes Exist in Rust?
Rust's async/await compiles futures to state machines but doesn't include an executor. You provide the executor (runtime) that polls futures to completion:
// Without a runtime, this won't compile:
// async fn main() { ... } // Doesn't work without an executor
// Tokio runtime:
#[tokio::main]
async fn main() {
let result = some_async_fn().await;
}
// async-std runtime:
#[async_std::main]
async fn main() {
let result = some_async_fn().await;
}
// Manual runtime (for embedded/custom needs):
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
some_async_fn().await;
});
}This design is intentional. Rust's core team decided that the language would provide the syntax and the Future trait, but not the executor: allowing runtimes to optimize for different environments (servers, embedded, WebAssembly, single-threaded applications). The cost is that you must choose a runtime; the benefit is that the right runtime can be orders of magnitude better for specific use cases than a one-size-fits-all solution.
What Is Tokio?
Tokio is the dominant Rust async runtime: work-stealing thread pool, I/O driver, timers, and ecosystem hub:
[dependencies]
tokio = { version = "1", features = ["full"] }
# Or minimal:
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "sync", "io-util", "macros"] }use tokio::{
net::TcpListener,
sync::{mpsc, Mutex},
time::{sleep, Duration, timeout},
task,
fs,
};
use std::sync::Arc;
#[tokio::main]
async fn main() {
// Spawn concurrent tasks
let handle = task::spawn(async {
sleep(Duration::from_millis(100)).await;
42u32
});
// Channels
let (tx, mut rx) = mpsc::channel::<String>(32);
task::spawn(async move {
tx.send("hello".to_string()).await.unwrap();
});
let msg = rx.recv().await.unwrap();
// TCP server
let listener = TcpListener::bind("0.0.0.0:8080").await.unwrap();
loop {
let (socket, _addr) = listener.accept().await.unwrap();
task::spawn(async move { handle_connection(socket).await });
}
}Tokio's thread model:
#[tokio::main]→ multi-thread runtime with one thread per CPU core#[tokio::main(flavor = "current_thread")]→ single-thread runtime (useful for WASM, embedded)- Work-stealing scheduler: idle threads steal tasks from busy threads
What Is async-std?
async-std mirrors the Rust standard library API: if you know std, you know async-std:
[dependencies]
async-std = { version = "1", features = ["attributes"] }use async_std::{
net::TcpListener,
task,
fs,
sync::Mutex,
channel,
};
#[async_std::main]
async fn main() {
// Very similar API to std + .await
let content = fs::read_to_string("file.txt").await.unwrap();
// Spawn tasks
let handle = task::spawn(async {
task::sleep(std::time::Duration::from_millis(100)).await;
42u32
});
// Channel (std-like)
let (tx, rx) = channel::unbounded::<String>();
task::spawn(async move {
tx.send("hello".to_string()).await.unwrap();
});
}async-std's design goals:
- API mirrors
std::fs,std::net,std::sync: lower learning curve - Single-thread and multi-thread executors
- Less common in the wider ecosystem (most crates target Tokio)
What Is smol?
smol is a minimal async runtime: small binary, composable design, used in embedded/constrained environments:
[dependencies]
smol = "2"fn main() {
smol::block_on(async {
// Run a single future on the current thread
let result = smol::Timer::after(std::time::Duration::from_millis(100)).await;
println!("Timer fired!");
});
}
// Multi-threaded with smol + a thread pool:
fn main() {
// smol uses a global thread pool (smol::Executor)
let ex = smol::Executor::new();
smol::block_on(ex.run(async {
let task = ex.spawn(async { 42u32 });
task.await
}));
}smol's philosophy: build runtimes from composable pieces (polling, async-io, async-executor). Used in embedded, async-h1, and specialized environments where Tokio's overhead matters.
How Do the Runtimes Compare?
| Feature | Tokio | async-std | smol |
|---|---|---|---|
| Downloads/month | 150M+ | 5M | 2M |
| Thread model | Work-stealing pool | Thread pool | Composable |
| I/O driver | epoll/kqueue/IOCP | async-io | polling |
| Timers | tokio::time | async_std::task::sleep | smol::Timer |
| Ecosystem | Largest (axum, reqwest, sqlx) | Moderate | Small |
| Binary size | Medium | Medium | Small |
| WASM support | current_thread flavor | Limited | Yes (with wasm32 executor) |
| Stability | 1.0 (stable) | 1.0 (stable) | 2.0 (stable) |
Which Crates Require Which Runtime?
Most production crates require Tokio:
# These require Tokio: they won't work with async-std or smol directly:
reqwest = "0.12" # HTTP client (uses Tokio under the hood)
axum = "0.7" # Web framework (Tokio)
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }
tonic = "0.12" # gRPC (Tokio)
tokio-postgres = "0.7"
redis = { version = "0.26", features = ["tokio-comp"] }
# async-std compatible:
surf = "2" # HTTP client (async-std ecosystem)
tide = "0.16" # Web framework (async-std)
async-postgres = "0.17"
# Runtime-agnostic (works with either):
futures = "0.3" # Core future traits
async-trait = "0.1" # Async traits
pin-project = "1"When Should You Choose Each Runtime?
Tokio (default choice):
- Web servers, APIs, microservices
- Any project using axum, reqwest, tonic, or sqlx
- Production systems where ecosystem breadth matters
- When in doubt
async-std:
- Projects that want std-like APIs
- Smaller codebases that don't need Tokio's full ecosystem
- Learning async Rust with familiar API names
smol:
- Embedded systems or constrained environments
- Custom executor requirements
- Projects where binary size is critical
- Building custom async infrastructure
How Does Tokio Handle CPU-Bound Work?
Tokio's async runtime is optimized for I/O-bound concurrency, not CPU-bound parallelism: blocking work must be explicitly offloaded to avoid starving other async tasks.
use tokio::task;
#[tokio::main]
async fn main() {
// WRONG: blocks the Tokio thread, starves other tasks
let result = expensive_cpu_computation();
// CORRECT: offload blocking work to a dedicated thread pool
let result = task::spawn_blocking(|| {
expensive_cpu_computation()
}).await.unwrap();
// For CPU-parallel work, use Rayon inside spawn_blocking:
let result = task::spawn_blocking(|| {
use rayon::prelude::*;
(0..1_000_000u64)
.into_par_iter()
.filter(|&x| x % 2 == 0)
.sum::<u64>()
}).await.unwrap();
}
fn expensive_cpu_computation() -> u64 {
// Simulate heavy computation
(0..10_000_000).sum()
}This pattern: spawn_blocking for CPU work, async tasks for I/O: is the correct division of labor in Tokio. Engineers who run CPU-heavy operations directly in async tasks create "blocking" problems that manifest as high latency under load, because a blocked Tokio thread cannot handle I/O for other tasks.
What Does Tokio's Task Model Look Like Under the Hood?
Tokio's work-stealing scheduler is the key architectural decision that makes it suitable for high-throughput server workloads:
Tokio runtime internals:
─────────────────────────────────────────────────────────
Thread pool: N threads (default: num_CPU_cores)
Each thread: has a local task queue + can steal from other queues
Task lifecycle:
spawn(async { ... })
→ Task placed in current thread's queue
→ If thread is busy, other threads steal the task
→ Task polled when a thread is available
→ If task yields (awaits), thread handles another task
→ When I/O is ready (epoll/kqueue), task woken and re-queued
Why work-stealing matters for web servers:
Request A: mostly I/O (database query, fast)
Request B: CPU-heavy JSON parsing (slow)
Without work-stealing: B blocks A on the same thread
With work-stealing: A moves to a free thread, processed immediatelyThis explains why Tokio dominates for web services: the work-stealing scheduler naturally handles mixed I/O and CPU workloads without requiring the developer to manually balance work across threads.
What Do Runtime Engineers Earn?
Engineers who build systems using Tokio for high-throughput production services: the kinds of roles at companies running millions of requests per second: typically earn $165K–$250K total compensation at US tech companies in 2026. Deep Tokio internals knowledge (task lifecycle, the Pin<Box<dyn Future>> machinery, epoll integration) is valued specifically in infrastructure and platform engineering roles.
What Common Mistakes Do Developers Make When Learning Rust Async Runtimes?
The most widespread mistake among developers new to async Rust is calling blocking functions (filesystem reads, heavy computation, std::thread::sleep) directly inside async tasks: this blocks the Tokio thread and degrades the entire service under load.
-
Calling blocking code in async tasks:
std::fs::read,std::thread::sleep, CPU-heavy loops: these block the Tokio thread. The fix istokio::task::spawn_blockingfor CPU work andtokio::fs/tokio::time::sleepfor I/O. Engineers who miss this discover the problem under load testing when latency spikes appear unpredictably. -
Creating unnecessary
tokio::mainruntimes inside library code: Libraries should not start their own Tokio runtime. A library that callstokio::maininternally will panic when used inside another Tokio context. Libraries should takePgPoolor other async resources as parameters, not create their own runtimes. -
Using async-std and then adding reqwest or axum: reqwest and axum are Tokio-only crates. Adding them to a project that started on async-std silently pulls in a second Tokio runtime, creating two executors running simultaneously: a setup that is technically possible but confusing and wasteful. Pick one runtime from the start.
-
Holding
MutexGuardacross await points: Rust'sstd::sync::Mutexis not async-aware. Holding aMutexGuardacross.awaitis a compile error in some cases and a deadlock in others. Usetokio::sync::Mutexwhen the guard must be held across await points. -
Ignoring the
#[tokio::test]attribute for async tests: Tests that use#[test]cannot call.await. Using#[tokio::test]creates a single-threaded Tokio runtime for the test. Engineers who forget this get confusing compile errors and sometimes resort toblock_onworkarounds that are unnecessary. -
Spawning hundreds of tasks and ignoring backpressure:
tokio::spawnis cheap but not free. Spawning unbounded tasks in response to incoming data (a queue consumer that spawns a task per message with no limit) creates memory pressure and can exhaust system resources. Use a semaphore or a bounded channel to limit concurrency.
Want to Learn Async Rust Systematically?
Async Rust has a steeper learning curve than most language features: the combination of futures, pinning, runtimes, and send bounds requires deliberate study. Rustify's 9-week bootcamp covers Tokio, async/await patterns, and common production pitfalls with 1:1 coaching. Students who finish the async module consistently report that the mental model solidifies during hands-on project work in a way that reading documentation alone does not achieve.
Keep Reading
- Rust Developer Salary in the USA (2026)
- How Long to Learn Rust by Background: Hours, Timeline & ROI
- Best Way to Learn Rust in 2026
- Is Rust Hard to Learn?
Frequently Asked Questions
Technically yes: futures are runtime-agnostic. But crates that depend on Tokio (like reqwest) will pull in Tokio's runtime; running them inside async-std requires bridging code. In practice, mixing runtimes is painful and unusual. Pick one.
For most applications: no. Tokio and async-std have similar throughput in practice. smol has lower overhead for simple workloads. CPU-bound tasks don't benefit from any async runtime: use tokio::task::spawn_blocking to offload blocking work.
Actix-web uses Tokio as its underlying runtime (as of actix-web 4). Earlier versions used their own actor-based system, but now it's standard Tokio.
Yes: Tokio 1.0 was released in December 2020 and has maintained API stability with no breaking changes. It underpins production systems at companies including AWS, Discord, Fly.io, Cloudflare, and thousands of others. It is the most battle-tested async runtime in the Rust ecosystem.
Use #[tokio::test] attribute instead of #[test]. This creates a single-threaded Tokio runtime for the test function. For tests that need a multi-threaded runtime, use #[tokio::test(flavor = "multi_thread", worker_threads = 4)]. The tokio-test crate provides additional utilities for testing time-dependent code.
Tokio tasks are significantly lighter than OS threads: a task costs roughly 64–128 bytes, while an OS thread typically costs 64–512 KB for its stack. This means you can spawn tens of thousands of Tokio tasks where OS threads would exhaust memory. The tradeoff: Tokio tasks must be async and cannot block; OS threads can run any code but scale poorly past hundreds of threads.
tokio::select! runs multiple futures concurrently and returns as soon as one completes, canceling the others: use it when you want the result of whichever future finishes first. futures::join! runs all futures concurrently and waits for all to complete: use it when you need all results. Both enable concurrent execution within a single async task without spawning separate tasks.

