Home

Why I stopped agonising over the Rust async runtime

Rust does not ship an async runtime. async/await is in the language, but the thing that actually polls your futures is a library you choose, and coming from Swift — where the runtime is simply there — this felt like a significant architectural decision.

I spent a week comparing them. Here is the ten-minute version I wish I had read.

Use tokio

Unless you have a specific reason not to, use tokio. Not because it is technically superior in every dimension, but because of the thing that actually decides it: the ecosystem is written against it.

axum, tonic, sqlx, reqwest, hyper, tower, aws-sdk-rust, redis-rs — the crates you will reach for either require tokio or list it as the default feature. Choosing something else means either finding alternatives for each of them or running a compatibility shim, and both are work you are doing instead of building the thing.

This is not an argument about elegance. It is the same argument as “use the standard library collections”: the value is in everything else already agreeing with you.

The others, and when they make sense

async-std aimed to mirror the standard library’s API surface, which made it pleasant to learn. Its momentum has faded and the ecosystem did not follow it. I would not start something new on it today.

smol is small, readable and genuinely elegant — the whole executor is a few thousand lines, and reading it is the best way I know to understand how async Rust actually works. It is a reasonable choice for an embedded-ish context or a small tool where you want minimal dependencies, and it can run tokio-based libraries through async-compat if you need one.

No runtime at all is the underrated answer for a CLI. If your program does three sequential HTTP requests and exits, ureq and blocking I/O will be simpler, faster to compile, and easier to debug than anything async. Async solves a concurrency problem; a program without a concurrency problem does not need it.

Tip

The question to ask is not “which runtime” but “do I need one”. Async in Rust pays off with many concurrent I/O-bound tasks. For a tool that does a handful of things in order, blocking code is genuinely the better engineering choice, and it saves you the entire coloured-function problem.

The parts of tokio worth knowing early

Runtime flavours. The default #[tokio::main] is multi-threaded, with a work-stealing scheduler across all cores. #[tokio::main(flavor = "current_thread")] is single-threaded and is the right choice for tests and for anything holding non-Send state.

#[tokio::main(flavor = "current_thread")]
async fn main() { … }

Never block the runtime. This is the mistake that will bite you, and it is quiet — no error, just a service that stops responding under load.

// wrong: blocks a runtime worker thread
async fn handler() -> Result<String> {
    let data = std::fs::read_to_string("large.json")?;   // blocking syscall
    Ok(parse(&data))
}

// right: I/O through tokio
async fn handler() -> Result<String> {
    let data = tokio::fs::read_to_string("large.json").await?;
    Ok(parse(&data))
}

// right: CPU-bound work moved off the async threads
async fn handler() -> Result<String> {
    let data = tokio::fs::read_to_string("large.json").await?;
    let parsed = tokio::task::spawn_blocking(move || expensive_parse(&data)).await??;
    Ok(parsed)
}

The rule: spawn_blocking for CPU work and for any blocking library you cannot avoid. The async-aware equivalent for anything that has one.

Feature flags matter. tokio = { version = "1", features = ["full"] } is fine to start with and worth trimming later — full pulls in everything, and compile times notice. rt-multi-thread, macros and net cover most services.

The one real cost

Tokio is a large dependency. A hello-world with tokio and axum pulls in around a hundred crates and takes a while to compile the first time. That is a genuine downside and there is no way around it other than not using async.

Incremental builds after that are fast, and cargo build --timings will show you where the time actually goes if it becomes a problem. It bothered me for about a week and then stopped mattering.

What I would tell someone starting

Pick tokio, use spawn_blocking for anything CPU-bound, and get on with the actual program. The runtime choice is not the interesting part of your architecture and it is very unlikely to be what you get wrong.

If you later find a specific reason to move — a genuinely embedded target, a hard binary size constraint — the async code itself is mostly portable, because async/await is a language feature and only the spawning and I/O calls are runtime-specific. That is a much smaller migration than the week of comparison shopping suggests, which is the main thing I got wrong.