Racing futures with select
Picture a chat app. You're waiting for a message from the server. At the same time, you're waiting for the user to press a button. If the server replies first, you show the message. If the user taps first, you send a request. You can't just await the server and then the button. That freezes the UI until the server responds. You need to run both at once and react to whichever finishes first.
The tokio::select! macro runs multiple async tasks concurrently and executes the code for the one that completes first. It's the standard way to race futures in Tokio.
Think of it like a waiter at a busy restaurant. The waiter takes orders from two tables. Table A wants soup. Table B wants steak. The waiter doesn't stand frozen at Table A until the soup arrives. The waiter watches both kitchens. The moment the soup is ready, the waiter serves it. The steak might still be cooking. The waiter handles the soup, then goes back to watching the remaining orders.
Minimal example
Here's a race between two timers. The shorter timer wins.
use tokio::time::{sleep, Duration};
/// Races two sleep futures and prints the winner.
#[tokio::main]
async fn main() {
// select! takes multiple branches.
// Each branch has a pattern, a future, and a block.
// The macro polls all futures concurrently.
// When one completes, its block runs.
// The other futures are cancelled.
tokio::select! {
// This branch wins because 1s < 2s.
_ = sleep(Duration::from_secs(1)) => {
println!("One second passed");
}
// This branch loses. The sleep is cancelled.
_ = sleep(Duration::from_secs(2)) => {
println!("Two seconds passed");
}
}
}
The output is always "One second passed". The two-second sleep never finishes. select! drops it the moment the one-second sleep returns.
What happens under the hood
When you call select!, Tokio generates a state machine. This machine polls every future you listed. Polling asks a future, "Are you done yet?" If a future returns Poll::Ready, the macro runs the corresponding block and stops. The remaining futures are dropped.
Dropping a future cancels it. This cancellation behavior is the most important detail. If you put a heavy computation or a network request in a branch, and another branch wins, that work vanishes. The future is dropped, resources are freed, and the task stops.
Cancellation is the default. If you need a task to survive a win elsewhere, you need to handle that explicitly.
Borrowing and ownership in select
The macro borrows every variable used in the branches. This creates constraints. You can't use the same variable in two branches if the borrow checker forbids it.
use tokio::time::{sleep, Duration};
/// Demonstrates a borrow conflict in select.
#[tokio::main]
async fn main() {
let data = String::from("hello");
// This code fails to compile.
// select! borrows `data` for the first branch.
// It also borrows `data` for the second branch.
// Rust sees two simultaneous borrows and rejects the code.
// You get E0502 (cannot borrow as mutable because it is also borrowed as immutable)
// or a similar conflict depending on the types.
tokio::select! {
_ = process(&data) => println!("Processed"),
_ = sleep(Duration::from_secs(1)) => println!("Timeout"),
}
}
async fn process(_s: &str) {}
The compiler rejects this because select! needs to hold references to all futures while polling. If data is borrowed in multiple places, the lifetimes clash.
The fix is to clone the data or restructure the code so each branch owns its inputs.
use tokio::time::{sleep, Duration};
/// Fixes the borrow conflict by cloning.
#[tokio::main]
async fn main() {
let data = String::from("hello");
// Clone data for the first branch.
// Each branch now has its own copy.
// The borrow checker is satisfied.
tokio::select! {
_ = process(data.clone()) => println!("Processed"),
_ = sleep(Duration::from_secs(1)) => println!("Timeout"),
}
}
async fn process(_s: String) {}
If you move a value into a branch, it's consumed. You can't use it in another branch. The compiler rejects this with E0382 (use of moved value). Clone early if you need the data in multiple branches.
Realistic example: Timeouts
A common pattern is racing a fetch against a deadline. If the fetch finishes, you get the data. If the timer finishes, you get an error.
use tokio::time::{sleep, Duration};
use std::io;
/// Simulates a network fetch that might be slow.
async fn fetch_data() -> String {
// Simulate network latency.
sleep(Duration::from_secs(10)).await;
"Data received".to_string()
}
/// Races a fetch against a deadline.
async fn fetch_or_timeout() -> Result<String, io::Error> {
// select! races the fetch and the timer.
// Whichever completes first determines the result.
// The loser is cancelled immediately.
tokio::select! {
// If fetch finishes, return the data.
data = fetch_data() => Ok(data),
// If timer finishes, return a timeout error.
_ = sleep(Duration::from_secs(2)) => Err(io::Error::new(
io::ErrorKind::TimedOut,
"Fetch exceeded 2 second limit",
)),
}
}
This pattern replaces manual timeout logic. The macro handles the cancellation of the fetch when the timer wins. The fetch task is dropped, freeing any sockets or buffers it held.
Event loops and bias
You often wrap select! in a loop to handle continuous events. This creates an event loop.
use tokio::time::{sleep, Duration};
use tokio::sync::mpsc;
/// Runs an event loop processing messages and heartbeats.
async fn event_loop(mut rx: mpsc::Receiver<String>) {
loop {
// select! races receiving a message and a heartbeat timer.
// The loop repeats after handling one event.
// This keeps the task responsive to both inputs.
tokio::select! {
// Receive a message from the channel.
// If the channel closes, recv returns None.
// We break the loop to shut down.
msg = rx.recv() => {
match msg {
Some(text) => println!("Got: {}", text),
None => break,
}
}
// Send a heartbeat every second.
// This prevents the loop from stalling.
_ = sleep(Duration::from_secs(1)) => {
println!("Heartbeat");
}
}
}
}
By default, select! polls branches in a random order. This prevents starvation. If one branch completes instantly, random polling ensures other branches still get a chance to run over time.
Sometimes you want deterministic order. If you have a branch that checks a local flag, you might want that branch to always win before checking network I/O. Add biased to the macro call.
tokio::select! {
biased;
// This branch is checked first.
// If it's ready, it wins immediately.
_ = check_local_flag() => { ... }
// This branch is checked second.
_ = network_io() => { ... }
}
The biased keyword forces top-to-bottom evaluation. Use this sparingly. Random polling is safer for fairness. If you bias the macro, you risk starving lower branches. Convention is to use biased only when you have a high-priority local check that must always take precedence.
Cancellation safety
Some futures aren't safe to cancel. If a future holds a lock or writes to a file, dropping it might leave the system in a bad state. select! drops the losers. If your future isn't cancellation-safe, you need to wrap it.
Consider a future that acquires a mutex and then sleeps. If select! cancels the sleep, the mutex is dropped and released. That's fine. But if the future writes data to a buffer and then waits for a flush, cancelling it might lose the data.
use tokio::sync::Mutex;
/// A future that is NOT cancellation safe.
/// It writes data and then waits.
/// If cancelled, the data is lost.
async fn unsafe_write(data: String, mutex: &Mutex<Vec<String>>) {
let mut guard = mutex.lock().await;
guard.push(data);
// Simulate a flush that might be cancelled.
tokio::time::sleep(Duration::from_secs(1)).await;
}
If you put unsafe_write in a select! branch, and another branch wins, the sleep is cancelled. The data is in the vector, but the flush never happened. The invariant is broken.
The fix is to spawn the task and race the handle.
use tokio::task::JoinHandle;
/// Races a spawned task against a timeout.
/// The task runs to completion even if the timeout wins.
async fn safe_race() {
// Spawn the task on the runtime.
// The JoinHandle represents the running task.
let handle: JoinHandle<()> = tokio::spawn(unsafe_write("data".into(), &mutex));
// select! races the handle and the timer.
// If the timer wins, the handle is dropped.
// Dropping the handle does NOT cancel the task.
// The task continues running in the background.
tokio::select! {
_ = handle => println!("Task finished"),
_ = sleep(Duration::from_secs(1)) => println!("Timeout, but task continues"),
}
}
Spawning detaches the task from the select! cancellation. The task runs until it finishes or panics. This is the standard pattern for non-cancellation-safe work.
Treat every branch as a potential cancellation point. If dropping the future breaks your logic, you need a wrapper.
When to use select
Use select! when you need to race multiple futures and handle the first completion. Use select! when you want to implement timeouts, cancellations, or event loops where one event triggers an action. Use join! when you need to wait for all futures to finish and collect their results. Use tokio::spawn when a task must run to completion regardless of other logic, like a background worker. Use a channel when you need to communicate between tasks rather than race them directly.
Pick the tool that matches your completion strategy. Race for select!, wait for join!, detach for spawn.