When the network hangs
You are building a dashboard that pulls metrics from three different APIs. The code looks clean. You use await everywhere. The UI feels snappy. Then you deploy. One API starts responding slowly. The request hangs. Your dashboard freezes. The user clicks refresh. Nothing happens. The app is stuck waiting for a response that may never arrive.
Async code gives you concurrency, not reliability. It lets you do other work while waiting, but it does not protect you from slow or dead endpoints. If you do not set a deadline, your code waits forever. The external world is unreliable. Networks drop packets. Servers crash. Latency spikes. You need a way to enforce a maximum wait time and move on.
That is what tokio::time::timeout does. It wraps an async operation and cancels it if the operation takes too long. You get a clear signal when time runs out, and the runtime cleans up the abandoned work automatically.
The timeout wrapper
timeout takes two arguments: a Duration and a Future. It returns a Result. The Ok variant contains the result of the future if it finished before the duration expired. The Err variant contains an Elapsed marker if the timer fired first.
Think of timeout like a microwave timer. You put the food in and set the clock. If the food is done before the clock hits zero, you eat. If the clock hits zero first, the microwave stops. The cooking process halts immediately. Rust's timeout works the same way. When the duration expires, the future is dropped. Dropping the future runs its destructors and releases resources. You do not leak memory. You do not leak threads. The state machine vanishes.
The cancellation is cooperative. The runtime polls the future and the timer simultaneously. If the timer wins, the runtime drops the future. The future cannot run any more code. It cannot hold locks. It cannot keep file handles open. The cleanup happens through Drop. This is why async Rust handles timeouts safely. You rely on the ownership system to guarantee cleanup.
Minimal example
Here is the basic pattern. You wrap the async call with timeout and match on the result.
use tokio::time::{timeout, Duration};
/// Simulates an operation that takes 5 seconds to complete
async fn slow_task() -> String {
// Sleep to mimic network latency or heavy computation
tokio::time::sleep(Duration::from_secs(5)).await;
"done".to_string()
}
#[tokio::main]
async fn main() {
// Create a timeout wrapper with a 2-second limit
// The future runs concurrently with the timer
let result = timeout(Duration::from_secs(2), slow_task()).await;
// Result::Ok contains the task's output if it finished in time
// Result::Err indicates the timer expired first
match result {
Ok(value) => println!("Success: {}", value),
Err(_) => println!("Timeout!"),
}
}
The slow_task returns a String. The timeout wraps that return type. The signature of timeout is timeout(dur, fut) -> Result<T, Elapsed>. In this case, T is String. The match handles both outcomes. If the task finishes, you get Ok(String). If the timer fires, you get Err(Elapsed). The Elapsed type is a zero-sized marker. It carries no data. It just signals that the deadline passed.
Convention aside: The community expects timeouts to fail fast. Do not retry immediately inside the timeout wrapper. Handle the timeout error at a higher level where you can decide whether to retry, log, or abort.
How the race works
When you call timeout, you are not checking a timestamp. You are racing two futures. The runtime polls your task and the timer at the same time. The race continues until one side resolves.
If your task resolves first, timeout captures the result and returns Ok(result). The timer is dropped. If the timer fires first, timeout cancels your task. Cancellation means the future is dropped immediately. The runtime does not wait for the task to finish. It tears down the state machine.
This behavior is crucial for resource management. Suppose your task holds a database connection. If the task times out, the connection is dropped. The connection returns to the pool. You do not leak connections. Suppose your task allocates a large buffer. The buffer is freed. You do not leak memory.
The cancellation relies on Drop. If your future holds resources, the Drop implementation must run quickly and safely. If Drop blocks or panics, you break the timeout guarantee. The runtime expects cancellation to be clean. Trust the borrow checker and the ownership system to handle the cleanup. Do not fight the cancellation mechanism.
Real code with nested results
Real async functions usually return Result<T, E>. This adds a layer of nesting. The task returns Result<T, E>. The timeout returns Result<Result<T, E>, Elapsed>. You have to match twice.
use tokio::time::{timeout, Duration};
use std::io;
/// Fetches data from a simulated API
async fn fetch_api() -> Result<String, io::Error> {
// Simulate a slow response
tokio::time::sleep(Duration::from_secs(3)).await;
Ok("api_response".to_string())
}
#[tokio::main]
async fn main() {
// Timeout returns Result<Result<T, E>, Elapsed>
// The outer Result is from the timeout wrapper
// The inner Result is from the actual task
let outcome = timeout(Duration::from_secs(1), fetch_api()).await;
match outcome {
Ok(Ok(data)) => println!("Got data: {}", data),
Ok(Err(e)) => println!("Task failed: {}", e),
Err(_) => println!("Operation timed out"),
}
}
The outer Ok means the task finished in time. The inner Ok means the task succeeded. The inner Err means the task failed with an error. The outer Err means the timeout fired. This double result is standard in Rust. You can flatten it with helper methods, but the explicit match is clearer for learning. It forces you to consider all three paths: success, failure, and timeout.
Convention aside: Many libraries provide a TimeoutError enum that combines the task error and the elapsed marker. This avoids the nested result. If you write a public API, consider wrapping the result in a custom error type. For internal code, the nested result is fine.
Cancellation safety and zombi tasks
Timeouts are powerful, but they have edge cases. The biggest risk is the "zombi task". This happens when a timed-out future spawns a child task that keeps running after the parent is cancelled.
use tokio::time::{timeout, Duration};
async fn bad_timeout() {
// Spawn a task that runs forever
let handle = tokio::spawn(async {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
// This timeout cancels the current future
// But it does not cancel the spawned task
timeout(Duration::from_secs(1), async {
// Wait for the handle, which never resolves
handle.await;
}).await;
}
#[tokio::main]
async fn main() {
bad_timeout().await;
// The spawned task is still running in the background
// It leaks resources and CPU time
}
The timeout cancels the async block inside bad_timeout. The async block is dropped. The handle is dropped. Dropping the handle does not cancel the spawned task. The task continues running. You have leaked a task. This is a zombi.
To fix this, you must manage the lifecycle of spawned tasks. Use tokio::task::JoinSet or AbortHandle to cancel children when the parent times out. Or avoid spawning inside a timeout unless you have a clear cancellation strategy. Timeouts cancel the future you pass to them. They do not cancel tasks you spawn inside that future.
Another pitfall is blocking code. If your future calls std::thread::sleep or runs a heavy CPU loop without yielding, the timeout cannot cancel it. The runtime polls the future. If the future blocks the thread, the runtime cannot poll other tasks. The timer fires, but the runtime is stuck. The timeout returns Err, but the work is still running in the background. The thread is leaked.
Use tokio::time::sleep for delays. Use tokio::task::spawn_blocking for CPU-bound work. spawn_blocking returns a future that can be timed out. However, timeouting the future only cancels the wait. The thread inside spawn_blocking keeps running until the function returns. You must design blocking functions to check for cancellation or finish quickly.
Timeouts only work if the runtime can poll your code. Blocking the thread defeats the purpose. Keep async code async. Yield control regularly. Let the runtime do its job.
Pitfalls and compiler errors
If you pass a plain value to timeout, the compiler rejects it with E0277 (trait bound not satisfied). timeout requires a Future. You must call the async function to get the future. timeout(Duration, fetch_data()) works. timeout(Duration, fetch_data) fails because fetch_data is a function pointer, not a future.
// This fails with E0277
// timeout(Duration::from_secs(1), fetch_api).await;
// This works
// timeout(Duration::from_secs(1), fetch_api()).await;
The error message tells you that the trait bound Future is not satisfied. You passed something that does not implement Future. Add the parentheses to call the function.
Another common mistake is ignoring the timeout error. If you unwrap the result, your program panics on timeout. This turns a graceful failure into a crash. Handle the Err case. Log the timeout. Retry if appropriate. Do not unwrap network operations.
Convention aside: The community treats timeouts as expected errors, not bugs. A timeout means the system is slow or unresponsive. It is a signal to degrade gracefully. Log the timeout with the duration and the operation name. This helps with debugging. Do not treat timeouts as exceptional. They are part of normal operation in distributed systems.
Decision matrix
Use tokio::time::timeout when you need to enforce a deadline on a single async operation and cancel it if the deadline passes. Use tokio::time::sleep when you want to delay execution without canceling anything; sleep pauses the current task but never aborts other work. Use tokio::select! when you need to race multiple futures against each other and handle the winner dynamically; timeout is a specialized case of select. Use spawn with a channel when you need to run the task in a separate task and let it finish in the background even if the caller times out; timeout cancels the future immediately.
Timeouts are your circuit breaker. They protect your app from hanging. They keep resources clean. They make your code resilient. Set deadlines everywhere you talk to the outside world. Trust the cancellation mechanism. Handle the errors. Your users will thank you.