Skip to content
rusthacks.dev
Concurrency

Timeouts in Tokio and Why a Timeout Does Not Stop the Work

tokio::time::timeout drops the future when time runs out, which is not the same as stopping the work behind it. Learn what actually gets cancelled.

8 min read
Summarize in AI
Flat illustration for the post “Timeouts in Tokio and Why a Timeout Does Not Stop the Work”, composed around threads.

Every call that crosses a network needs a timeout, and in async Rust adding one is a single function: tokio::time::timeout(duration, future). It races your future against a timer and, if the timer wins, drops the future. That one line of behaviour contains most of what you need to know and most of what goes wrong: what the error looks like, what happens to the work that was cancelled, how to set an overall deadline across several steps, and why a timeout on top of a retry loop multiplies in surprising ways. This post covers each with code that runs on a paused clock, so the timings are exact. Everything compiled on Rust 1.99, edition 2024, with tokio 1.

The basic call

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

async fn fetch(millis: u64) -> &'static str {
    sleep(Duration::from_millis(millis)).await;
    "payload"
}

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

    let fast = timeout(Duration::from_millis(500), fetch(200)).await;
    println!("fast: {fast:?} after {:?}", t.elapsed());

    let slow = timeout(Duration::from_millis(500), fetch(2_000)).await;
    println!("slow: {slow:?} after {:?}", t.elapsed());

    if let Err(e) = slow {
        println!("error text: {e}");
    }
}
Text
fast: Ok("payload") after 200ms
slow: Err(Elapsed(())) after 700ms
error text: deadline has elapsed

The return type is Result<T, Elapsed>: Ok with the future's output if it finished in time, Err(Elapsed) otherwise. Elapsed carries no data and prints "deadline has elapsed". Two details are worth noticing. The slow call gave up after exactly 500 ms and not 2,000 (the clock reads 700 ms because it also includes the 200 ms of the first call): the future was dropped at that point. And there is a nesting trap: if the future itself returns a Result, the result of timeout is a Result<Result<T, E>, Elapsed>, which you flatten with a double question mark or a map_err.

Converting Elapsed into your own error

rust
use std::fmt;
use tokio::time::{sleep, timeout, Duration};

#[derive(Debug)]
enum FetchError {
    TimedOut(Duration),
    Failed(String),
}

impl fmt::Display for FetchError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            FetchError::TimedOut(d) => write!(f, "no response within {d:?}"),
            FetchError::Failed(m) => write!(f, "request failed: {m}"),
        }
    }
}

async fn call(millis: u64, ok: bool) -> Result<u32, String> {
    sleep(Duration::from_millis(millis)).await;
    if ok { Ok(200) } else { Err("connection reset".into()) }
}

async fn fetch(millis: u64, ok: bool) -> Result<u32, FetchError> {
    let limit = Duration::from_millis(300);
    timeout(limit, call(millis, ok))
        .await
        .map_err(|_| FetchError::TimedOut(limit))?
        .map_err(FetchError::Failed)
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    println!("{:?}", fetch(100, true).await);
    for (ms, ok) in [(100, false), (900, true)] {
        match fetch(ms, ok).await {
            Ok(v) => println!("ok {v}"),
            Err(e) => println!("error: {e}"),
        }
    }
}
Text
Ok(200)
error: request failed: connection reset
error: no response within 300ms

The first map_err handles the timeout and the second the inner error; the order matters because the first ? peels off the outer layer. Keeping the two failure kinds as separate variants is worth the three lines, because the right reaction differs: a timeout is often worth a retry, and a "connection reset" may not be. The error design is the subject of Result explained.

What the timeout does to the work

A timeout cancels by dropping, with the consequences from cancellation safety. The operation at the other end may or may not have happened. A request that was sent and processed, whose response arrived too late, was still carried out; the caller has no way to know, and a naive retry makes it happen twice. This is why retries over a timeout need idempotency keys or naturally idempotent operations, covered in idempotency keys. The following program makes the point with a side effect that is performed before the response is "sent":

rust
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::time::{sleep, timeout, Duration};

async fn charge(card: &str, charged: Arc<AtomicU32>) -> String {
    charged.fetch_add(1, Ordering::SeqCst);
    sleep(Duration::from_secs(3)).await;
    format!("receipt for {card}")
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let charged = Arc::new(AtomicU32::new(0));

    for attempt in 1..=3 {
        match timeout(Duration::from_secs(1), charge("card-1", charged.clone())).await {
            Ok(receipt) => {
                println!("attempt {attempt}: {receipt}");
                break;
            }
            Err(_) => println!("attempt {attempt}: timed out, retrying"),
        }
    }
    println!("customer charged {} times", charged.load(Ordering::SeqCst));
}
Text
attempt 1: timed out, retrying
attempt 2: timed out, retrying
attempt 3: timed out, retrying
customer charged 3 times

Three timeouts, three charges. The caller saw only failures. In a real payment flow the remote server also completed three charges. Any call with an external effect needs either a key the server uses to ignore repeats, or a status query before the retry.

Per-call timeouts and overall deadlines

A timeout per call does not bound the total time of a multi-step operation: five steps of 2 seconds each add up to ten. When the caller has an overall budget, compute a deadline once and use timeout_at for each step, so the remaining time shrinks as the earlier steps use it:

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

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

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let start = Instant::now();
    let deadline = start + Duration::from_millis(1_000);

    for (name, ms) in [("auth", 300), ("lookup", 400), ("render", 500)] {
        match timeout_at(deadline, step(name, ms)).await {
            Ok(msg) => println!("{:>5} ms: {msg}", start.elapsed().as_millis()),
            Err(_) => {
                println!("{:>5} ms: {name} cut off, deadline reached", start.elapsed().as_millis());
                break;
            }
        }
    }
    sleep_until(deadline).await;
    println!("budget fully used at {} ms", start.elapsed().as_millis());
}
Text
  300 ms: auth ok
  700 ms: lookup ok
 1000 ms: render cut off, deadline reached
budget fully used at 1000 ms

The third step needed 500 ms but only 300 were left, so it was cut off at exactly the deadline. One deadline travelling with the request through every layer is how services avoid the cascade where each layer waits its full individual timeout. Pass the Instant (or the remaining Duration) down explicitly; gRPC and HTTP stacks propagate it in a header for the same reason.

Idle timeouts for streams and connections

A single deadline suits a request. A long-lived stream (a websocket, a server-sent events feed, a line-oriented socket) needs a different kind: "if nothing arrives for N seconds, give up". That is a timeout around each next(), so every message resets the clock:

rust
use tokio::sync::mpsc;
use tokio::time::{sleep, timeout, Duration};

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<&str>(4);
    tokio::spawn(async move {
        for (delay, msg) in [(100, "one"), (200, "two"), (4_000, "three")] {
            sleep(Duration::from_millis(delay)).await;
            if tx.send(msg).await.is_err() {
                return;
            }
        }
    });

    let idle = Duration::from_secs(1);
    loop {
        match timeout(idle, rx.recv()).await {
            Ok(Some(msg)) => println!("got {msg}"),
            Ok(None) => {
                println!("stream ended");
                break;
            }
            Err(_) => {
                println!("idle for {idle:?}, closing the connection");
                break;
            }
        }
    }
}
Text
got one
got two
idle for 1s, closing the connection

Each received message restarts the one-second window, so a busy stream is never cut off and a silent one is, after exactly the idle period. Wrapping recv() is safe here because it is cancel safe: a message is removed from the channel only when recv returns it. The same loop shape with read() on a socket works because a single read is also cancel safe, while wrapping read_exact is not. For application-level liveness, many protocols prefer an explicit ping and pong over relying on silence, since silence can also mean "nothing to say".

Retries inside a timeout, and timeouts inside retries

Where you put the timeout changes the arithmetic. A timeout around each attempt inside a retry loop of 3 with 2-second timeouts can take 6 seconds plus the backoff waits. A single timeout around the whole retry loop caps it at the budget but can starve the last attempts. Choose on purpose, and write down both numbers (per attempt, total) in the code where the loop lives. Backoff patterns are in async resilience patterns.

Library timeouts are not the same thing

HTTP clients such as reqwest have their own timeouts (connect, read, total), and the sensible default is to set them as well as the outer tokio::time::timeout. They differ in what they can do: a client timeout knows to close the socket and release the connection to the pool; a dropped future does the same through its destructors. Neither sets a timeout on DNS resolution done through a blocking call, which is a known gap. Treat library-level timeouts as the specific limits and the outer timeout as the budget safety net, and see reqwest error handling for how they surface.

Cases where a timeout is the wrong fix

Timeouts bound how long you wait; they do not fix a slow dependency. If every call is timing out, the right response is to stop calling for a while (a circuit breaker) rather than pile up retries. A timeout also cannot interrupt blocking code: timeout(d, async { std::thread::sleep(..) }) never fires until the sleep returns, because the future cannot be polled while the thread is stuck. For that, move the blocking work to spawn_blocking and time out the join handle, remembering that the closure keeps running. And a timeout of zero is legal and means "poll once, and fail if it is not already ready", which makes timeout(Duration::ZERO, fut) a cheap way to check whether a future is ready right now.

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.