I got this wrong for about six months. Back in 2024 I started a Rust side project and picked async-std because the docs looked clean and the ergonomics felt friendlier than Tokio’s. Two years later I was patching over dead dependencies while the async-std GitHub sat quiet, and I finally moved everything to Tokio in a very grumpy weekend.
If you’re picking a Rust async runtime in 2026, the short answer is Tokio, unless you have a specific reason not to. But “specific reason” is doing a lot of work in that sentence, and this post is my attempt to unpack it honestly.
I’ll cover the four runtimes worth knowing about, the code differences you’ll actually feel, the cancellation trap that bit me twice, and how the async fn in traits situation actually shakes out on stable now. A bit of code, a bit of opinion, and one very specific thing I still don’t like about Tokio.
The four runtimes worth knowing about
Right now the honest picture is:
- Tokio is the answer. Almost every crate in the async ecosystem targets it. If you’re writing an HTTP service, a database client, or anything that touches the network, you’re going to end up on Tokio whether you started there or not.
- async-std existed as the “look, we can be nicer than Tokio” runtime. Development stalled and the maintainers effectively wound it down. Don’t start a new project here.
- smol is a small, embeddable runtime for people who want to understand what’s going on. I like it a lot for CLI tools and glue code where I don’t want to bring in Tokio’s full feature set.
- glommio is the Datadog one. Thread-per-core, io_uring only, Linux only. It’s excellent for the narrow case it’s built for and completely wrong for anything else.
That’s the whole picture. If you read a post that keeps namechecking Bastion or riker as async runtimes in 2026, close it.
The code you’d actually write in each
Same trivial “fetch two URLs concurrently” job. Tokio:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (a, b) = tokio::try_join!(
reqwest::get("https://httpbin.org/delay/1"),
reqwest::get("https://httpbin.org/delay/2"),
)?;
println!("{} {}", a.status(), b.status());
Ok(())
}
Smol:
fn main() -> anyhow::Result<()> {
smol::block_on(async {
let (a, b) = futures::try_join!(
surf::get("https://httpbin.org/delay/1").send(),
surf::get("https://httpbin.org/delay/2").send(),
)?;
println!("{} {}", a.status(), b.status());
Ok(())
})
}
Glommio (Linux, io_uring):
fn main() -> anyhow::Result<()> {
glommio::LocalExecutorBuilder::default()
.spawn(|| async move { /* per-core work */ })?
.join()?;
Ok(())
}
Nothing shocking at the surface. The differences show up under load and in ecosystem support. Tokio wins both by a wide margin, and finding someone who’s already fixed your weird bug on a Sunday night is genuinely easier when your runtime is the default one.
The other thing that only becomes obvious once you’ve shipped is that the runtime bleeds into your dependencies. reqwest, hyper, axum, sqlx, sea-orm, redis-rs, lapin: they all speak Tokio natively. Some of them have async-std shims or smol-compatible modes, but you can feel the second-class citizen thing every time you upgrade a version and something breaks. That’s the real cost of picking a niche runtime, and it’s a cost you only pay in retrospect.
Cancellation is where I got burned
This is the part I wish I’d known earlier. Rust’s async model is what people call cooperative cancellation. When you drop a future, its state is dropped, and any work-in-progress just stops. If that work was holding a database transaction or a partially-written file, you can end up with weird half-states.
The specific trap I hit: I had a request handler that spawned a background task with tokio::spawn, then awaited a select! between the task and a timeout. On timeout, the future was dropped, but tokio::spawn had already handed the future off to the runtime, so it kept running in the background and eventually wrote a stale row.
The fix isn’t complicated once you see it. Use a JoinHandle and call abort() explicitly, or use tokio::task::JoinSet, which cleans up when it’s dropped:
let mut set = tokio::task::JoinSet::new();
set.spawn(process(id));
tokio::select! {
Some(res) = set.join_next() => println!("done: {:?}", res),
_ = tokio::time::sleep(Duration::from_secs(5)) => {
// Drop of `set` aborts still-running tasks.
println!("timed out");
}
}
Tokio’s documentation on cancellation safety is worth reading before your next production incident, not after it.
Async fn in traits: it works now, mostly
Rust 1.75 stabilized async fn in traits. In 2026 you can write:
trait Store {
async fn get(&self, id: u64) -> Result<Row, Error>;
}
and it just compiles. Before that we all reached for the async-trait crate, which wrapped every method in Pin<Box<dyn Future>> and paid a small allocation cost. That crate is still fine for library authors who need object safety, because the native syntax doesn’t yet give you dyn Trait for async methods without a helper. This is the one place I still hit friction on stable.
If you’re inside your own binary, use the native syntax. If you’re writing a library and need trait objects, keep using async-trait for now. The Rust project has a tracking issue for dyn-safe async trait methods with the current state.
Structured concurrency and the tasks I actually reach for
The thing I use the most, once the boilerplate is out of the way, is JoinSet for fan-out work and spawn_blocking for CPU or blocking IO. That covers most of my day-to-day async Rust.
If I’m reading files or doing heavy CPU work inside an async handler, wrapping it in spawn_blocking keeps the runtime healthy:
let parsed = tokio::task::spawn_blocking(move || {
serde_json::from_slice::<Report>(&payload)
}).await??;
Cheap trick. Saved me from a couple of ugly latency spikes when a JSON payload got fat.
If you’re building a service and want the same server-side pattern I use, I wrote about it in the axum plus sqlx setup I actually use in production. Between that post and this one you have the practical Rust web stack I’ve been shipping this year.
When I actually reach for smol instead
I do reach for smol maybe once a quarter. Small CLI tools with one or two async calls, where I don’t want to compile Tokio’s full feature and its dependencies. smol::block_on and futures are enough. Compile times are noticeably faster. If the tool grows past a couple hundred lines I move it to Tokio and stop worrying about it.
Glommio I’ve used exactly once, on a Linux-only ingest path where we cared about io_uring throughput and were happy to write thread-per-core code. It was great for that. I would not use it for a general web service.
The thread-per-core model is genuinely different from what Tokio does. Instead of one shared work-stealing pool, each core gets its own executor and its own tasks, and cross-core coordination is explicit. When your workload is uniform (say, packet processing or a storage engine), you get very predictable tail latency. When your workload is bursty and irregular, all the coordination you removed comes back as scheduling headaches you now have to write yourself. That’s the tradeoff nobody puts on the front page of the docs.
What I’d do this week
If you already have a Tokio project, don’t switch. If you’re on async-std, plan the migration now while it’s a chore, not a fire. If you’re picking fresh, pick Tokio, install it with features = ["full"], and revisit full when your binary size actually annoys you. If you want the wider picture of how I put a Rust backend together, my project notes live on my portfolio.
The point isn’t which runtime is theoretically fastest. It’s which runtime lets you ship, sleep, and open the maintenance dashboard on Monday without dread. Two years of grumpy weekends taught me that’s Tokio.