How Rust does async differently (and why it matters)
Explore why Rust's "pull’" async model is different from JavaScript's "push" model. Learn about lazy futures, the poll method and state machines.
Jan 21st, 2026 6:00am by
Image by wal_172619 from Pixabay.
- Part I: The poll-based model (This article) – A look at why Rust futures are “lazy,” how the “pull” model differs from other languages, and how to build a state machine by hand.
- Part II: The mystery of pinning – It will demystify Pin, explain self-referential structs, and see why “moving” a future in memory can be dangerous.
- Part III: Executors and wakers – A dive into the “reactors” that drive code, exploring how the waker tells the executor exactly when to wake up and finish the job.
- Part IV: Async in practice – Moving beyond theory to look at real-world patterns like joining, selecting and handling timeouts.
1. The ‘pull’ model: Laziness as a virtue
In many languages, async operations are “push-based.” When you create a promise in JavaScript or spawn a Goroutine in Go, the operation starts immediately. The runtime schedules it, and it pushes the result to you when it’s done. Rust futures are “pull-based.” They are lazy. If you call an async function in Rust but don’t `.await` it (or poll it), absolutely nothing happens. The code inside the function is not executed.Code example: The lazy future
use std::time::Duration;
async fncomplex_calculation() {
println!("(2) Starting calculation...");
tokio::time::sleep(Duration::from_secs(1)).await;
println!("(3) Calculation finished!");
}
#[tokio::main]
async fnmain() {
println!("(1) Calling the function...");
// ⚠️ NOTHING HAPPENS HERE
// The function is called, but the code inside isn't executed yet.
// It returns a 'Future' state machine.
let my_future = complex_calculation();
println!("(4) I haven't awaited it yet, so nothing printed above.");
// 🚀 NOW the runtime starts pulling the future
my_future.await;
}
2. The future trait: The engine under the hood
At the core of this abstraction is the future trait. Simplified, it looks like this:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
Building the state machine by hand
We will create a CountdownFuture. It will:- Start with a count (in this case, 3).
- Every time the runtime polls it, it decrements the count.
- If the count is not 0, it tells the runtime “I’m not done, ask me again” (returns `Pending`).
- If the count is 0, it says “I’m done!” (returns `Ready`).
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;// 1. The State Machine
// This struct holds the state of our operation.
// In a generated async block, this would hold all your local variables.
structCountdownFuture {
count: u32,
}
impl CountdownFuture {
fnnew(count: u32) -> Self {
Self { count }
}
}
// 2. The Implementation
impl Future for CountdownFuture {
// This is what the future returns when it finishes.
typeOutput = String;
fnpoll(mutself: Pin<&mutSelf>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// Access the inner count
ifself.count == 0 {
// BASE CASE: We are done!
return Poll::Ready("Blastoff! 🚀".to_string());
} else {
// PROGRESS CASE: We are not done yet.
println!("Counting down: {}", self.count);
// Decrement our state
self.count -= 1;
// ⚠️ CRITICAL STEP: The Waker
// If we returned Pending without doing this, the runtime would
// put this task to sleep and NEVER check it again (a deadlock).
// By calling `wake_by_ref()`, we tell the runtime:
// "I made progress! Put me back in the queue to be polled again immediately."
cx.waker().wake_by_ref();
// Return Pending to yield control back to the executor
return Poll::Pending;
}
}
}
// 3. Using it
#[tokio::main]
async fnmain() {
let countdown = CountdownFuture::new(3);
// The runtime will poll this ~4 times until it returns Ready
let result = countdown.await;
println!("{}", result);
}
3. Breaking down the magic
Let’s break down exactly what is going on in that manual implementation.The poll signature
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
- Pin<&mut Self>: This allows us to mutate our state (self.count -= 1). The Pin wrapper ensures we are safe to use even if we were self-referential (though we aren’t in this simple example).
- Context: This carries the waker. The waker is the most important part of the ecosystem. It is the “callback” mechanism.
The return values
- Poll::Ready(T): The contract is fulfilled. The value “T” is handed to the caller, and the future is dropped.
- Poll::Pending: The future says, “I cannot complete right now.”
The waker magic
This is the specific line that confuses people:
cx.waker().wake_by_ref();
What about Pin?
You might have noticed the Pin type in the function signature above and wondered what exactly it does. While we briefly touched on it, Pin is one of the most complex (and misunderstood) topics in Rust. Why does the compiler force us to use it? What happens if we move a future in memory while it’s running? Part II of this series will demystify Pin, explore self-referential structs and explain why pinning is the secret sauce that makes Rust’s zero-cost async possible.
YOUTUBE.COM/THENEWSTACK
Tech moves fast, don't miss an episode. Subscribe to our YouTube
channel to stream all our podcasts, interviews, demos, and more.