I keep a small Rust service that scrapes prices off a few sites and drops them into Postgres. I wrote it in 2023, forgot it existed, and it has been quietly running ever since. Two weeks ago I finally pointed cargo audit at it and got told that async-std, the runtime the entire thing is built on, has been discontinued.
My first instinct was to go find a runtime comparison and pick a replacement properly this time. That turned out to be a waste of an afternoon. Every comparison is the same table: Tokio has the biggest ecosystem, smol is small, embassy is for embedded, glommio does io_uring. All accurate. None of it told me anything I could act on, because I hadn’t actually chosen async-std in 2023. A crate I wanted had chosen it for me, and I found out two years later when the bill came due.
So this is the post I wanted instead. It doesn’t try to tell you which runtime is best. It asks which one you already have, and what it costs to leave.
async-std is gone, and it went quietly
The commit that marks async-std as discontinued landed on March 1, 2025. The recommended replacement is smol, which is reasonable, since smol was already doing the heavy lifting underneath async-std anyway.
What I find more interesting is how badly that news travelled. There’s a thread on the Rust internals forum from August 2025 where someone points out that the deprecation notice was in the GitHub README and basically nowhere else. Not on docs.rs where you’d actually land, not on async.rs, which is a polished site with a name that makes the crate look semi-official. A maintainer cut a 1.13.2 release the same day whose only change was putting the notice at the top of the docs.
I don’t think anyone did anything wrong here. Cargo has no real way to flag a crate as deprecated, and adding a compiler warning would have been unbearably noisy for everyone downstream. But it does mean the crate kept looking alive for months after it wasn’t, and if you’re auditing an old project you should assume there are more of these than you think.
Run cargo tree before you read another comparison
Here’s the thing I should have done before opening a single blog post. Ask your project which runtime it’s actually using, and who dragged it in:
# who depends on tokio in my tree?
cargo tree -i tokio
# same question for the others
cargo tree -i async-std
cargo tree -i smol
# and: am I accidentally running two runtimes at once?
cargo tree -d
The -i flag is inverse dependencies, so you get the list of crates that pulled the runtime in. On my scraper, cargo tree -i async-std came back with exactly one entry that mattered, and it was a HTTP client I’d picked because it had a nice API. That was the whole decision. I never evaluated a runtime.
This is not a me problem. Matthias Endler’s State of Async Rust makes the same point with numbers: runtime coupling means libraries have to be written against a specific executor, so the ecosystem consolidates hard around whichever one wins. Tokio is used at runtime by over twenty thousand crates. If you’re doing anything with HTTP, gRPC, or a database driver, the odds that some transitive dependency pins you to Tokio are very high. reqwest, for one, just requires it.
cargo tree -d is the one people skip. It shows duplicate dependencies, and it’s how you catch the situation where you’re linking two async runtimes into one binary because a stray crate brought its own. That compiles fine. It also wastes memory and gives you two schedulers fighting over the same cores.
The migration was mostly mechanical
Once I knew async-std was only holding up a thin layer, swapping to smol took an evening. The APIs are close enough that most of it is import rewriting.
Before, on async-std:
use async_std::fs::File;
use async_std::io::prelude::*;
use async_std::task;
fn main() -> std::io::Result<()> {
task::block_on(async {
let mut file = File::open("prices.json").await?;
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
println!("read {} bytes", buf.len());
Ok(())
})
}
After, on smol:
use smol::fs::File;
use smol::io::AsyncReadExt;
fn main() -> std::io::Result<()> {
smol::block_on(async {
let mut file = File::open("prices.json").await?;
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
println!("read {} bytes", buf.len());
Ok(())
})
}
task::block_on becomes smol::block_on, the prelude import becomes an explicit AsyncReadExt, and the body doesn’t change at all. My compile times dropped noticeably too, which makes sense given smol’s dependency count is a fraction of Tokio’s and the executor core is around a thousand lines.
Where it stopped being mechanical was the HTTP client, because that crate was async-std-only and is now also unmaintained. That’s the real migration cost. Not the runtime, the orphaned libraries sitting on top of it.
When smol is actually the better answer
I want to be careful here, because “smol is lighter” is true and mostly irrelevant. Compile time is nice. It is not a reason to fight your entire dependency tree.
The one difference that changes how you write code is the threading model. Tokio’s multi-threaded runtime is the default, and it work-steals, which means anything you spawn has to be Send + 'static. That requirement propagates outward through your types:
// Tokio multi-thread runtime: this does not compile.
// Rc isn't Send, and tokio::spawn demands Send + 'static.
let counter = std::rc::Rc::new(0);
tokio::spawn(async move {
println!("{counter}");
});
smol’s LocalExecutor doesn’t ask for Send, because it never moves tasks between threads:
// smol LocalExecutor: single-threaded, so Rc is fine.
let ex = smol::LocalExecutor::new();
let counter = std::rc::Rc::new(0);
let task = ex.spawn(async move {
println!("{counter}");
});
smol::block_on(ex.run(task));
If you’ve ever wrapped something in Arc<Mutex<_>> purely to satisfy the compiler rather than because you actually had two threads touching the same data, this is why. On a workload that’s one connection doing sequential I/O, which describes my scraper exactly, the multi-threaded default buys nothing and costs you the borrow checker’s good mood. Tokio does offer a current-thread runtime, and it’s underused, but spawn still carries the Send bound unless you reach for LocalSet.
For embedded work none of this applies and you want embassy, which is a different design for a different machine.
The escape hatch when you need both
Say you’re on smol and you need reqwest, which insists on a Tokio context. You don’t have to migrate. async-compat wraps a future so a Tokio runtime exists while it’s being polled:
use async_compat::Compat;
fn main() -> Result<(), Box<dyn std::error::Error>> {
smol::block_on(Compat::new(async {
// reqwest is Tokio-only. Compat sets up the
// Tokio context so it works under smol anyway.
let body = reqwest::get("https://example.com/prices.json")
.await?
.text()
.await?;
println!("{} bytes", body.len());
Ok(())
}))
}
This works, and I use it. It also means you’re now shipping both runtimes, so you’ve traded the compile-time win away. I treat it as a bridge for one stubborn dependency, not an architecture. If half your tree needs Compat, that’s your tree telling you to just use Tokio.
What I’d actually do this week
Run cargo tree -i async-std across your Rust projects. If anything comes back, you have an unmaintained runtime in production and you probably didn’t know. Then run cargo tree -d and see whether you’re carrying two executors.
After that, the decision is easier than the comparison tables make it look. If your project talks to the network or a database, you have Tokio already, and fighting that costs more than it returns. Stick with it and reach for the current-thread runtime if you don’t need work stealing. If you’re writing a CLI, a small daemon, or something where you control the whole dependency tree, smol is a nicer place to live and the migration from async-std is one evening’s work.
And the option everyone forgets: if you got here for performance rather than because a library forced you, benchmark threads first. Scoped threads plus blocking I/O handle a lot more load than people expect, and the error messages are so much better.
I ended up keeping my scraper on smol, mostly because it’s small enough that I own the whole tree. I wrote up more of what I’ve shipped in Rust in my work on backend services, and if you want the framework layer rather than the runtime layer, my notes on axum cover the part that sits on top of all this.