DevgainsDevgainsDevgains
All articles

Async Rust, Two Years In: What Actually Got Better

·5 min read
Async Rust, Two Years In: What Actually Got Better

Photo: Unsplash

Two years ago, writing async Rust meant a steady diet of workarounds. Traits could not hold async methods without a macro, error messages from a stuck future read like hieroglyphics, and every tutorial seemed to assume you already knew which runtime to pick. I shipped real services on it anyway, because the performance and safety were worth the friction, but I would not have called it pleasant.

I have spent the last two years building with it continuously, and the honest assessment is that a lot has genuinely improved while a few well-known sharp edges remain exactly where they were. This is a field report, not a sales pitch.

async fn in traits stopped being a fight

The single biggest day-to-day change is that you can now write async fn directly in a trait definition. For years this required the async-trait macro, which worked but boxed every future and showed up as noise in both your code and your profiler.

trait Fetcher {
    async fn fetch(&self, url: &str) -> Result<String, std::io::Error>;
}
 
struct HttpFetcher;
 
impl Fetcher for HttpFetcher {
    async fn fetch(&self, url: &str) -> Result<String, std::io::Error> {
        // ... perform the request and return the body
        Ok(format!("body of {url}"))
    }
}

This reads exactly like the synchronous version with one keyword added, which is how it should have always been. There are still limitations around using these traits as dyn trait objects, and the ecosystem has not fully migrated, but for application code that defines and implements its own traits, the macro is no longer mandatory. That removed a whole category of confusion for newcomers.

The runtime question got less paralyzing

Early on, the "which async runtime" question stalled a lot of projects. The practical answer has settled: for the overwhelming majority of network services, Tokio is the default, and the surrounding ecosystem assumes it. That convergence is itself the improvement. You no longer have to audit whether every crate you depend on is compatible with your runtime choice, because most of them target the same one.

The Tokio documentation has also matured into something you can actually onboard a team with. Spawning tasks, working with channels, and handling graceful shutdown are documented as cohesive workflows rather than scattered API references.

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        // runs concurrently on the runtime
        compute().await
    });
 
    let result = handle.await.expect("task panicked");
    println!("got {result}");
}
 
async fn compute() -> u32 {
    42
}

If you are starting fresh today, picking Tokio is the low-regret choice. Not because alternatives are bad, but because the gravity of the ecosystem means you spend your time on your problem instead of on integration glue.

Diagnostics improved, but cancellation is still subtle

Compiler diagnostics for async have come a long way. The infamous "future is not Send" error, which used to point at the wrong line and bury the actual cause, now usually tells you which non-Send value is being held across an .await point. That alone has saved me hours. The async book does a good job explaining why that constraint exists: when a task can move between threads, everything alive across a suspension point must be safe to send.

What has not gotten meaningfully easier is reasoning about cancellation. In async Rust, a future can be dropped at any .await point, for example when you race it inside a select! and another branch wins. If that future was halfway through mutating shared state, you are responsible for making sure the partial work is safe to abandon. The language does not warn you. This "cancellation safety" property is something you have to learn per-API and design for deliberately.

use tokio::time::{timeout, Duration};
 
async fn with_deadline() {
    // if the timeout fires, `slow_write` is dropped mid-flight.
    // is your state still consistent? that is YOUR problem to answer.
    let _ = timeout(Duration::from_secs(1), slow_write()).await;
}
 
async fn slow_write() {
    // imagine a multi-step update here
}

This is the area where I still see experienced engineers get surprised in production. It is not a bug in Rust; it is an inherent property of cooperative cancellation that the ecosystem documents better now but cannot make disappear.

Structured concurrency is becoming the norm

A cultural shift, more than a language one, is that people now reach for structured patterns by default. Instead of spawning detached tasks and hoping they finish, the common idioms now keep child tasks scoped to a parent, so that errors propagate and nothing is silently orphaned. Tooling like task tracking and join sets in Tokio makes this ergonomic. Two years ago "fire and forget" was the path of least resistance; today the path of least resistance encourages you to handle the join.

This matters because the most painful async bugs I debugged early on were not crashes, they were tasks that quietly died, taking a piece of the system offline while everything else kept reporting healthy. Scoped, joined concurrency turns those silent failures into surfaced errors.

What I would tell a team adopting it now

If you evaluated async Rust two years ago and bounced off, it is worth another look. The trait ergonomics alone change the daily experience. But set expectations honestly: cancellation safety and the Send boundary are real concepts your team needs to learn, not paper cuts that a future release will sand away. Budget time for the mental model, lean on the async book for the foundations, and standardize on one runtime early.

The technology was always fast and safe. What changed is that it is now also, most of the time, comfortable.

Takeaways

  • async fn in traits removed the biggest ergonomic wart; the async-trait macro is no longer mandatory for ordinary application code.
  • The runtime question largely resolved in practice: Tokio is the low-regret default because the ecosystem converged on it.
  • Compiler diagnostics for async, especially the Send boundary, are dramatically clearer and now usually point at the real cause.
  • Cancellation safety remains a genuine concept you must design for; futures can be dropped at any await point and the language will not warn you.
  • Structured, scoped concurrency is now the cultural default, which turns silent dead-task failures into visible errors.
5 min read

Read next