The dashboard problem
You're building a dashboard that needs three pieces of data: the user's profile, their recent posts, and a count of unread notifications. Each request takes time. You don't want to wait for the profile to finish before starting the posts request. You want them all flying at once. In JavaScript, you'd reach for Promise.all. In Python, asyncio.gather. Rust gives you two macros that feel familiar but have sharper edges: join! and select!.
These macros let you orchestrate concurrent work without spawning threads. They expand into efficient state machines that the async executor can poll. Knowing which one to pick prevents subtle bugs where work gets silently dropped or where you wait longer than necessary.
join! waits for everyone
join! takes multiple futures and waits for all of them to complete. It returns a tuple containing the results in the same order you passed the futures.
Think of join! like ordering a combo meal. You don't get to eat until the burger, fries, and drink are all on the tray. The kitchen works on everything in parallel, but you block until the whole set is ready. If the drink takes ten minutes, you wait ten minutes, even if the burger was ready in two.
join! is a macro, not a function. This matters because macros can capture the types of your futures and generate specialized code. The generated code polls each future in turn. If one future is ready, it stores the result and moves on. If a future isn't ready, it yields control back to the executor. This interleaving happens so fast that all futures make progress simultaneously.
use std::time::Duration;
/// Demonstrates join! waiting for all futures to complete.
fn demo_join() {
trpl::block_on(async {
// Create two futures that return strings after sleeping.
// These represent independent tasks like fetching data.
let fut_a = async {
trpl::sleep(Duration::from_secs(1)).await;
"fast task"
};
let fut_b = async {
trpl::sleep(Duration::from_secs(2)).await;
"slow task"
};
// join! polls both futures concurrently.
// It yields to the executor when neither is ready.
// It only resumes when both have produced a value.
let (result_a, result_b) = trpl::join!(fut_a, fut_b);
// Results arrive in the same order as the arguments.
println!("join results: {}, {}", result_a, result_b);
});
}
The macro generates a temporary struct that holds your futures. It implements the Future trait for that struct. The poll method checks each future. This means join! has zero allocation overhead. It doesn't create threads. It doesn't allocate memory. It just weaves your futures together in the executor's loop.
try_join! for fallible work
If your futures return Result<T, E>, join! has a flaw. It waits for all futures to finish, even if one fails immediately. That wastes time and resources.
The convention is to reach for try_join! when dealing with fallible futures. try_join! behaves like join! but short-circuits on the first error. If any future returns Err, try_join! drops the remaining futures and returns the error immediately.
use std::time::Duration;
/// Demonstrates try_join! short-circuiting on error.
fn demo_try_join() {
trpl::block_on(async {
// A future that fails quickly.
let fut_a = async {
trpl::sleep(Duration::from_millis(100)).await;
Err("connection refused")
};
// A future that would succeed but takes longer.
let fut_b = async {
trpl::sleep(Duration::from_secs(5)).await;
Ok("data")
};
// try_join! stops as soon as fut_a returns Err.
// fut_b is dropped and never completes.
let result = trpl::try_join!(fut_a, fut_b);
println!("Result: {:?}", result);
});
}
Use try_join! whenever your concurrent tasks can fail. It saves time and prevents you from waiting for useless work. Reach for try_join! the moment you see Result in a concurrent block.
select! races to the finish
select! watches multiple futures and wakes up the moment any one of them finishes. It returns the result of the winner.
Think of select! like a waiter standing by a table with three dishes. As soon as the first plate hits the counter, the waiter brings it over. The other dishes keep cooking, but you get the first result immediately.
select! is also a macro. It expands into code that polls each future. When one future returns Poll::Ready, the macro matches against the corresponding branch and returns the result. The other futures are dropped.
use std::time::Duration;
/// Demonstrates select! returning the first ready future.
fn demo_select() {
trpl::block_on(async {
let fut_a = async {
trpl::sleep(Duration::from_secs(1)).await;
"fast"
};
let fut_b = async {
trpl::sleep(Duration::from_secs(2)).await;
"slow"
};
// select! polls both futures.
// It returns as soon as one completes.
// The pattern on the left captures the result.
trpl::select! {
result = fut_a => println!("Winner: {}", result),
result = fut_b => println!("Winner: {}", result),
}
});
}
The syntax looks like pattern matching, because it is. Each branch has a pattern, an equals sign, and a future. When a future completes, the macro matches the result against the pattern and runs the block. You can use _ to discard a result. You can use guards to filter results.
The destructive trap of select!
select! is destructive. When one branch wins, the other futures are dropped immediately. This is the most common pitfall with select!.
If a future is doing cleanup work, or holding a resource, or running a long background task, select! kills it. The drop happens silently. You won't get a warning. The work just vanishes.
This matters when you're racing a timeout against a long-running task. If the timeout wins, the task is dropped. If that task was writing to a file or holding a lock, you might leave the system in a bad state.
use std::time::Duration;
/// Demonstrates the destructive nature of select!.
fn demo_select_drop() {
trpl::block_on(async {
// A future that does work and prints on drop.
let long_task = async {
trpl::sleep(Duration::from_secs(10)).await;
"done"
};
// A timeout that wins quickly.
let timeout = async {
trpl::sleep(Duration::from_millis(100)).await;
"timeout"
};
// select! picks the timeout.
// long_task is dropped immediately.
trpl::select! {
_ = long_task => println!("Task finished"),
result = timeout => println!("Timeout: {}", result),
}
// long_task never printed "done".
// It was dropped by select!.
});
}
If you need the loser to survive, you can't use select! directly. You need to spawn the task or use a channel. Spawn the work so it runs independently. Then select! on the join handle or the channel receiver. The spawned task stays alive even if the select! branch wins.
Realistic timeout pattern
Timeouts are the classic use case for select!. You want to fetch data, but if it takes too long, you give up. select! makes this clean.
The pattern is to race the real work against a sleep future. The sleep future represents the deadline. Whichever finishes first wins.
use std::time::Duration;
/// Implements a timeout using select!.
/// Returns the result if ready in time, or an error if not.
async fn fetch_with_timeout() -> Result<String, String> {
// The actual work.
let fetch = async {
trpl::sleep(Duration::from_secs(5)).await;
Ok("data")
};
// The deadline.
let timeout = async {
trpl::sleep(Duration::from_secs(1)).await;
Err("timed out")
};
// Race the fetch against the timeout.
trpl::select! {
result = fetch => result,
result = timeout => result,
}
}
fn demo_timeout() {
trpl::block_on(async {
let result = fetch_with_timeout().await;
println!("Result: {:?}", result);
});
}
This pattern is safe because the timeout future is just a sleep. Dropping the fetch future is usually fine. The fetch future might be a network request. Dropping it cancels the request. That's often what you want.
If the fetch future holds resources, wrap it in a guard or use spawn. The convention is to assume select! drops losers. Design your futures so dropping is safe. If dropping isn't safe, don't put the future in select!.
Pitfalls and compiler errors
join! and select! have strict rules. The compiler enforces them.
If you try to use a future after passing it to join!, the compiler rejects you with E0382 (use of moved value). Both macros take ownership of the futures. You can't reuse them. If you need to run the same future multiple times, you need to clone it or recreate it.
// This fails to compile.
// let fut = async { 42 };
// let result = trpl::join!(fut, fut);
// Error[E0382]: use of moved value `fut`
select! requires futures to be Unpin or pinned. If you pass a future that isn't Unpin, you might get a trait bound error. The macro handles pinning internally, but some futures are complex. If you hit a pinning error, try boxing the future with .boxed() or .boxed_local(). This allocates the future on the heap and makes it Unpin.
// If a future isn't Unpin, box it.
let fut = complex_future().boxed();
trpl::select! {
result = fut => println!("{}", result),
}
select! syntax is strict. Each branch needs a pattern, an equals sign, and a future. If you forget the pattern, the macro fails. If you use a function that returns a future instead of the future itself, you get a type error. The macro expects a future, not a function call.
// This fails. fetch() returns a future, but select! sees the call.
// trpl::select! {
// result = fetch() => println!("{}", result),
// }
// Error: expected future, found function item
Call the function outside the macro or use an async block. The macro needs to capture the future value.
let fut = fetch();
trpl::select! {
result = fut => println!("{}", result),
}
In tokio, select! branches are polled in random order to prevent starvation. If you have a branch that's always ready, it might starve others. Use the biased keyword if you need deterministic order. biased polls branches in source order. This is a convention for loops where order matters.
Decision matrix
Use join! when you need results from all futures and can wait for the slowest one. Use join! when the futures are short-lived and you don't want the overhead of spawning tasks. Use try_join! when your futures return Result and you want to stop on the first error. Use select! when you need to react to the first event among several, like a timeout or a race condition. Use select! when you want to cancel the remaining work as soon as one branch succeeds. Use tokio::spawn when you need to run a future in the background and keep it alive even if the caller moves on. Use spawn when select! would drop work you need to preserve.
Don't use select! if you need the loser to survive. Don't use join! if you can fail fast. Don't fight the macros. They are designed for specific patterns. Reach for the right tool and let the compiler guide you.