{"id":529,"date":"2026-07-31T13:02:45","date_gmt":"2026-07-31T13:02:45","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/rust-async-runtime-comparison-your-cargo-lock-already-chose\/"},"modified":"2026-07-31T13:02:45","modified_gmt":"2026-07-31T13:02:45","slug":"rust-async-runtime-comparison-your-cargo-lock-already-chose","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/rust-async-runtime-comparison-your-cargo-lock-already-chose\/","title":{"rendered":"Rust Async Runtime Comparison: Your Cargo.lock Already Chose"},"content":{"rendered":"<p>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 <code>cargo audit<\/code> at it and got told that <code>async-std<\/code>, the runtime the entire thing is built on, has been discontinued.<\/p>\n<p>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&rsquo;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.<\/p>\n<p>So this is the post I wanted instead. It doesn&rsquo;t try to tell you which runtime is best. It asks which one you already have, and what it costs to leave.<\/p>\n<h2 id=\"async-std-is-gone-and-it-went-quietly\">async-std is gone, and it went quietly<\/h2>\n<p>The commit that marks async-std as discontinued <a href=\"https:\/\/github.com\/async-rs\/async-std\/commit\/fb56bffdbb4699e1add70a0f834dee6f57c398eb\" rel=\"nofollow noopener\" target=\"_blank\">landed on March 1, 2025<\/a>. The recommended replacement is <a href=\"https:\/\/github.com\/smol-rs\/smol\" rel=\"nofollow noopener\" target=\"_blank\">smol<\/a>, which is reasonable, since smol was already doing the heavy lifting underneath async-std anyway.<\/p>\n<p>What I find more interesting is how badly that news travelled. There&rsquo;s a <a href=\"https:\/\/internals.rust-lang.org\/t\/async-std-deprecation\/23395\" rel=\"nofollow noopener\" target=\"_blank\">thread on the Rust internals forum from August 2025<\/a> where someone points out that the deprecation notice was in the GitHub README and basically nowhere else. Not on docs.rs where you&rsquo;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.<\/p>\n<p>I don&rsquo;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&rsquo;t, and if you&rsquo;re auditing an old project you should assume there are more of these than you think.<\/p>\n<h2 id=\"run-cargo-tree-before-you-read-another-comparison\">Run <code>cargo tree<\/code> before you read another comparison<\/h2>\n<p>Here&rsquo;s the thing I should have done before opening a single blog post. Ask your project which runtime it&rsquo;s actually using, and who dragged it in:<\/p>\n<pre><code class=\"language-bash\"># who depends on tokio in my tree?\ncargo tree -i tokio\n\n# same question for the others\ncargo tree -i async-std\ncargo tree -i smol\n\n# and: am I accidentally running two runtimes at once?\ncargo tree -d\n<\/code><\/pre>\n<p>The <code>-i<\/code> flag is inverse dependencies, so you get the list of crates that pulled the runtime in. On my scraper, <code>cargo tree -i async-std<\/code> came back with exactly one entry that mattered, and it was a HTTP client I&rsquo;d picked because it had a nice API. That was the whole decision. I never evaluated a runtime.<\/p>\n<p>This is not a me problem. Matthias Endler&rsquo;s <a href=\"https:\/\/corrode.dev\/blog\/async\/\" rel=\"nofollow noopener\" target=\"_blank\">State of Async Rust<\/a> 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 <a href=\"https:\/\/lib.rs\/crates\/tokio\/rev\" rel=\"nofollow noopener\" target=\"_blank\">over twenty thousand crates<\/a>. If you&rsquo;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.<\/p>\n<p><code>cargo tree -d<\/code> is the one people skip. It shows duplicate dependencies, and it&rsquo;s how you catch the situation where you&rsquo;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.<\/p>\n<h2 id=\"the-migration-was-mostly-mechanical\">The migration was mostly mechanical<\/h2>\n<p>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.<\/p>\n<p>Before, on async-std:<\/p>\n<pre><code class=\"language-rust\">use async_std::fs::File;\nuse async_std::io::prelude::*;\nuse async_std::task;\n\nfn main() -&gt; std::io::Result&lt;()&gt; {\n    task::block_on(async {\n        let mut file = File::open(&quot;prices.json&quot;).await?;\n        let mut buf = String::new();\n        file.read_to_string(&amp;mut buf).await?;\n        println!(&quot;read {} bytes&quot;, buf.len());\n        Ok(())\n    })\n}\n<\/code><\/pre>\n<p>After, on smol:<\/p>\n<pre><code class=\"language-rust\">use smol::fs::File;\nuse smol::io::AsyncReadExt;\n\nfn main() -&gt; std::io::Result&lt;()&gt; {\n    smol::block_on(async {\n        let mut file = File::open(&quot;prices.json&quot;).await?;\n        let mut buf = String::new();\n        file.read_to_string(&amp;mut buf).await?;\n        println!(&quot;read {} bytes&quot;, buf.len());\n        Ok(())\n    })\n}\n<\/code><\/pre>\n<p><code>task::block_on<\/code> becomes <code>smol::block_on<\/code>, the prelude import becomes an explicit <code>AsyncReadExt<\/code>, and the body doesn&rsquo;t change at all. My compile times dropped noticeably too, which makes sense given smol&rsquo;s dependency count is a fraction of Tokio&rsquo;s and the executor core is around a thousand lines.<\/p>\n<p>Where it stopped being mechanical was the HTTP client, because that crate was async-std-only and is now also unmaintained. That&rsquo;s the real migration cost. Not the runtime, the orphaned libraries sitting on top of it.<\/p>\n<h2 id=\"when-smol-is-actually-the-better-answer\">When smol is actually the better answer<\/h2>\n<p>I want to be careful here, because &ldquo;smol is lighter&rdquo; is true and mostly irrelevant. Compile time is nice. It is not a reason to fight your entire dependency tree.<\/p>\n<p>The one difference that changes how you write code is the threading model. Tokio&rsquo;s multi-threaded runtime is the default, and it work-steals, which means anything you spawn has to be <code>Send + 'static<\/code>. That requirement propagates outward through your types:<\/p>\n<pre><code class=\"language-rust\">\/\/ Tokio multi-thread runtime: this does not compile.\n\/\/ Rc isn't Send, and tokio::spawn demands Send + 'static.\nlet counter = std::rc::Rc::new(0);\ntokio::spawn(async move {\n    println!(&quot;{counter}&quot;);\n});\n<\/code><\/pre>\n<p>smol&rsquo;s <code>LocalExecutor<\/code> doesn&rsquo;t ask for <code>Send<\/code>, because it never moves tasks between threads:<\/p>\n<pre><code class=\"language-rust\">\/\/ smol LocalExecutor: single-threaded, so Rc is fine.\nlet ex = smol::LocalExecutor::new();\nlet counter = std::rc::Rc::new(0);\nlet task = ex.spawn(async move {\n    println!(&quot;{counter}&quot;);\n});\nsmol::block_on(ex.run(task));\n<\/code><\/pre>\n<p>If you&rsquo;ve ever wrapped something in <code>Arc&lt;Mutex&lt;_&gt;&gt;<\/code> purely to satisfy the compiler rather than because you actually had two threads touching the same data, this is why. On a workload that&rsquo;s one connection doing sequential I\/O, which describes my scraper exactly, the multi-threaded default buys nothing and costs you the borrow checker&rsquo;s good mood. Tokio does offer a current-thread runtime, and it&rsquo;s underused, but <code>spawn<\/code> still carries the <code>Send<\/code> bound unless you reach for <code>LocalSet<\/code>.<\/p>\n<p>For embedded work none of this applies and you want <a href=\"https:\/\/github.com\/embassy-rs\/embassy\" rel=\"nofollow noopener\" target=\"_blank\">embassy<\/a>, which is a different design for a different machine.<\/p>\n<h2 id=\"the-escape-hatch-when-you-need-both\">The escape hatch when you need both<\/h2>\n<p>Say you&rsquo;re on smol and you need reqwest, which insists on a Tokio context. You don&rsquo;t have to migrate. <a href=\"https:\/\/docs.rs\/async-compat\/latest\/async_compat\" rel=\"nofollow noopener\" target=\"_blank\"><code>async-compat<\/code><\/a> wraps a future so a Tokio runtime exists while it&rsquo;s being polled:<\/p>\n<pre><code class=\"language-rust\">use async_compat::Compat;\n\nfn main() -&gt; Result&lt;(), Box&lt;dyn std::error::Error&gt;&gt; {\n    smol::block_on(Compat::new(async {\n        \/\/ reqwest is Tokio-only. Compat sets up the\n        \/\/ Tokio context so it works under smol anyway.\n        let body = reqwest::get(&quot;https:\/\/example.com\/prices.json&quot;)\n            .await?\n            .text()\n            .await?;\n        println!(&quot;{} bytes&quot;, body.len());\n        Ok(())\n    }))\n}\n<\/code><\/pre>\n<p>This works, and I use it. It also means you&rsquo;re now shipping both runtimes, so you&rsquo;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 <code>Compat<\/code>, that&rsquo;s your tree telling you to just use Tokio.<\/p>\n<h2 id=\"what-id-actually-do-this-week\">What I&rsquo;d actually do this week<\/h2>\n<p>Run <code>cargo tree -i async-std<\/code> across your Rust projects. If anything comes back, you have an unmaintained runtime in production and you probably didn&rsquo;t know. Then run <code>cargo tree -d<\/code> and see whether you&rsquo;re carrying two executors.<\/p>\n<p>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&rsquo;t need work stealing. If you&rsquo;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&rsquo;s work.<\/p>\n<p>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.<\/p>\n<p>I ended up keeping my scraper on smol, mostly because it&rsquo;s small enough that I own the whole tree. I wrote up more of what I&rsquo;ve shipped in Rust in my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work on backend services<\/a>, and if you want the framework layer rather than the runtime layer, my notes on <a href=\"https:\/\/abrarqasim.com\/blog\/rust-axum-2026-the-web-backend-that-stopped-fighting-me\" rel=\"noopener\">axum<\/a> cover the part that sits on top of all this.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>async-std is discontinued and most runtime comparisons miss the point: your dependency tree already picked Tokio. Here is how I checked mine, and when smol wins.<\/p>\n","protected":false},"author":2,"featured_media":528,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"async-std is discontinued and most runtime comparisons miss the point: your dependency tree already picked Tokio. Here is how I checked mine, and when smol wins.","rank_math_focus_keyword":"rust async runtime comparison","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,142],"tags":[569,452,49,64,453,418],"class_list":["post-529","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-rust","tag-async-rust-2","tag-async-std","tag-backend","tag-rust","tag-smol","tag-tokio"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/529","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=529"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/529\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/528"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=529"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=529"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=529"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}