Skip to content
rusthacks.dev
Concurrency

Rust Warning Unused Future Must Be Used and What It Hides

Futures are lazy: calling an async fn without .await does nothing. The unused_must_use warning is the compiler catching a missing await, a forgotten spawn, or a dropped JoinHandle.

6 min read
Summarize in AI
Flat illustration for the post “Rust Warning Unused Future Must Be Used and What It Hides”, composed around async.

The warning "unused implementer of Future that must be used" is one of the few Rust warnings that nearly always points at a real bug. It fires when you call an async fn and throw the result away without awaiting it. Because a future is only a description of work, the call does nothing: no code of the function body has run, no side effect has happened, and no error is reported. The program compiles, runs and quietly skips the step. This post shows the warning, the three ways the bug hides from it (let _, an underscore-prefixed binding, a stored-and-forgotten future), the clippy lint that catches the first, the places where a missing .await is intentional, and how to make the compiler stop the bug in CI. Everything compiled on Rust 1.99, edition 2024, with tokio 1.

The bug, and the warning that finds it

rust
async fn save(n: u32) {
    println!("saved {n}");
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    save(1);
    println!("done");
}
Text
done

Compiling it prints this warning (the program still builds):

Text
warning: unused implementer of `Future` that must be used
 --> src/main.rs:7:5
  |
7 |     save(1);
  |     ^^^^^^^
  |
  = note: futures do nothing unless you `.await` or poll them
  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default

The output shows only "done". The call save(1) built a future and dropped it on the next line. The compiler's note is exact: "futures do nothing unless you .await or poll them". The warning is attached to the Future trait, which carries #[must_use], so it fires for every future you drop in statement position, including those from library functions and from adapters like join_all. The fix is the missing word: save(1).await;. If the work should instead run in the background, spawn it and say so (tokio::spawn(save(1));), an intention a reader can see.

Three ways it hides

The lint is silenced by several innocent-looking spellings, and each deserves a look because the same bug survives behind them.

rust
async fn save(n: u32) {
    println!("saved {n}");
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let _ = save(2);
    let _pending = save(3);
    let h = tokio::spawn(save(4));
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    drop(h);
    println!("main finished");
}
Text
saved 4
main finished

Only "saved 4" appears. Line by line: let _ = save(2); tells the compiler "I meant to ignore this", so rustc stays quiet, and it is exactly what a developer types to make a warning go away without understanding it. let _pending = save(3); binds the future to a name that starts with an underscore, which also suppresses the unused-variable warning, and the future is dropped at the end of its scope without ever being polled. Only the spawned one ran, because tokio::spawn submits the future to the runtime. Dropping the join handle does not cancel the task, as spawn and join explains.

The clippy lint for the first

Rustc cannot distinguish a deliberate let _ = from a lazy one, but clippy can, for futures specifically. The let_underscore_future lint flags the pattern when the value is a future:

Text
warning: non-binding `let` on a future
 --> src/main.rs:5:5
  |
5 |     let _ = save(2);
  |     ^^^^^^^^^^^^^^^^
  |
  = help: consider awaiting the future or dropping explicitly with `std::mem::drop`
  = note: `#[warn(clippy::let_underscore_future)]` on by default

It does not catch the underscore-prefixed name (_pending), because that is an ordinary binding. Review for it by reading: a variable named with a leading underscore whose type is a future is a bug until proven otherwise.

Where a missing await is intentional

Not every future is meant to be awaited on the spot. These are the legitimate cases, and the code should make the intent visible:

  • Composition. You build several futures and await them together: let a = fetch(1); let b = fetch(2); let (x, y) = tokio::join!(a, b);. The futures are bound to names and used.
  • Racing. select! takes futures by expression and polls them; the losers are dropped on purpose.
  • Handing off. Passing a future to spawn, timeout, a stream adapter or a function that takes impl Future.
  • Deferred start. Storing a future in a struct to poll later, as in the retry example in Pin and Box dyn Future.

In each, the future is consumed by something. The bug is a future that is consumed by nothing. A short, runnable check shows the difference between building futures eagerly and running them:

rust
use tokio::time::{sleep, Duration, Instant};

async fn step(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    format!("{name} done")
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let t = Instant::now();

    let a = step("first", 100);
    let b = step("second", 100);
    println!("built both at {:?}, nothing has run", t.elapsed());

    let (x, y) = tokio::join!(a, b);
    println!("{x}, {y} at {:?}", t.elapsed());
}
Text
built both at 0ns, nothing has run
first done, second done at 100ms

Both futures were created at time zero and neither started its sleep until join! polled them, which then ran them together in 100 ms. Creating a future is free and effect-free; awaiting is what starts it. That asymmetry is the whole warning.

The same warning for streams, iterators and results

The principle is wider than futures. Lazy values in Rust are marked #[must_use] so the compiler can tell you when you built something and never used it: an iterator adapter (v.iter().map(...) as a statement does nothing), a stream, a Result (an ignored error), a Duration-producing method. A statement that is a pure expression and discards its value usually means a forgotten consumer. The iterator case is shown in higher-order functions, and the result case in Result explained. For streams the message reads "streams do nothing unless polled", and the cure is a while let Some(x) = s.next().await loop or collect.

Make the compiler stop it for good

The warning is on by default, so what lets the bug through is people ignoring warnings. Three guards, in increasing strength:

  1. Treat warnings as errors in CI: RUSTFLAGS="-D warnings" cargo build, or cargo clippy -- -D warnings, which also enables the clippy lint above. The setup is in continuous integration for Rust.
  2. Deny the specific lints for the crate: #![deny(unused_must_use)] at the top of main.rs, or in the manifest's lints table (the lints table) so every crate in a workspace inherits it, together with clippy::let_underscore_future.
  3. Write a test that observes the effect. A test that calls the function and then checks that the record was saved fails when the await is missing, whatever the compiler said. For code with I/O, run the test under #[tokio::test(start_paused = true)] so it is fast and deterministic.

When you get the warning on something you cannot await

Occasionally the future is returned by a trait method in synchronous context (a Drop impl, a callback). The right response is not let _ =. Spawn it on a captured Handle (there is no reactor running shows how), send the work to a channel that a task drains, or restructure so the async step happens in an async function. If the work is genuinely fire-and-forget, tokio::spawn with a comment is both honest and visible. And when the task you spawn may fail, keep the join handle or log inside the task, since an error in a detached task disappears unless something reports it; the neighbouring problem of a task that never completes is in tasks that never finish.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.