Bounding a Request with timeout and timeout_at
An HTTP client with no timeout is an outage waiting to happen: one slow upstream, a pool of tasks parked forever, and a service that never recovers because nothing is left to cancel. Tokio's fix is a combinator that races your future against a timer:
use tokio::time::{timeout, Duration};
match timeout(Duration::from_secs(3), client.get(url)).await {
Ok(Ok(body)) => println!("{} bytes", body.len()),
Ok(Err(e)) => println!("upstream error: {e}"),
Err(_) => println!("gave up after 3s"), // Elapsed: "deadline has elapsed"
}
The signature is timeout(dur, fut) -> Timeout<F>; awaiting it yields Result<F::Output, Elapsed> — a nested Result when the inner future also returns one, which is why the arms read Ok(Ok(..)), Ok(Err(..)), Err(..). The Err(Elapsed) arm is where your deadline logic lives; the Display impl for Elapsed is deadline has elapsed.
Mechanically, timeout is a select! with two branches: your future and a sleep. When the timer wins, your future is dropped — not paused, not sent a stop signal. Whatever it had not finished is abandoned, and its destructors run. That single sentence is the spine of this topic; the next chapter unpacks what "abandoned" costs you.
In real code the arms usually collapse with ? inside a function returning Box<dyn Error> (both Elapsed and io::Error convert), or into .ok() when the operation is optional — a metrics flush that may be dropped, a cache warm-up nobody is waiting on:
if let Some(body) = timeout(Duration::from_millis(200), cache.get(key)).await.ok().flatten() {
return body; // deadline hit = cache miss, not an error
}
For absolute deadlines — a request inheriting a client-wide budget — use timeout_at:
let deadline = Instant::now() + budget; // budget: Duration from the caller
let user = timeout_at(deadline, fetch_user(id)).await;
let left = deadline.saturating_duration_since(Instant::now());
let hist = timeout_at(deadline, fetch_history(id)).await; // shares the same instant
Per-call timeouts without an outer budget are a classic interview trap: three upstream calls at 5 s each can spend 15 s inside a 5 s SLO. Budgets compose downward — one Instant for the whole operation, sub-timeouts clamped to what remains.
Two configuration facts worth stating precisely:
- Timers need the runtime's time driver.
#[tokio::main]enables it; a hand-builtBuilder::new_multi_thread().build()withoutenable_all()/enable_time()compiles fine and panics at the first timer. timeoutitself never touches a thread — the sleep is registered with the timer driver and the task parks, so thousands of concurrent timeouts cost thousands of entries in a timer wheel, not thousands of threads.
Related tool for periodic work: tokio::time::interval(period) ticks forever and reports how it handled a late wake-up via MissedTickBehavior — Burst (default: fire immediately to catch up), Skip (jump to the next whole period), Delay (restart counting from now). Choosing wrong turns a monitoring loop into a thundering herd after a GC pause.
let mut tick = tokio::time::interval(Duration::from_secs(10));
tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop { tick.tick().await; collect_metrics(); }
Two facts to close with. First, timers are cheap and virtual: every timeout/sleep inserts an entry into a hierarchical timer wheel driven by the runtime — one driver thread for the whole process, so ten thousand concurrent timeouts are ten thousand structs, not ten thousand threads. Second, timeouts do not compose with blocking: a spawn_blocking closure already running cannot be cut short by your Elapsed — the deadline only cancels the await, so never put a join!-style wait you cannot abandon on the critical path. For tests, Tokio can virtualize all of this: #[tokio::test(start_paused = true)] auto-advances the clock, so a 5-second timeout test finishes in microseconds and asserts the deadline logic instead of sleeping through it.
Framing for interviews: "Where do you put timeouts?" — on connect, on each read/write, and on the total operation; derived from one shared deadline; each Err(Elapsed) branch documented as to whether the partial work must be compensated.
A closing pattern that combines budget and cancellation — capping a shutdown so a wedged worker cannot hang the process:
let drain = timeout(Duration::from_secs(10), async {
for h in handles { let _ = h.await; } // wait for in-flight tasks
}).await;
if drain.is_err() { println!("drain timed out; dropping stragglers"); }
You are waiting for cooperative exit and still holding a forced cancel in reserve. That two-layer design — graceful first, forced as a backstop — is what production cancellation looks like, and it is built entirely out of the two mechanisms in this topic: timeout for the backstop, dropped futures for the enforcement.
Cancellation Is Dropping the Future: What Rust Guarantees and What You Own
timeout did not interrupt your request. It dropped it. In Rust, dropping a future at an .await point is cancellation — the same mechanism by which select! discards losing branches, an aborted task stops, and a runtime shutdown cleans up. Learn to read cancellation as RAII:
tokio::time::timeout(Duration::from_millis(10), save_order(order)).await
// on Elapsed: the `save_order` future is dropped here — locals unwound in order,
// MutexGuards released, buffers freed, Drop impls run to completion
Because the state machine is an ordinary Rust value, everything the language already promises still holds: no dangling references, no leaked locks, no undefined behavior. Memory safety is automatic; logical safety is yours.
| Cancellation source | What happens |
|---|---|
timeout(..) expires |
inner future dropped inside the combinator |
losing a select! branch |
branch futures dropped at the winner's return |
JoinHandle::abort() |
task dropped at its next poll (usually next .await) |
runtime shutdown / main returns |
unfinished tasks dropped |
dropping a JoinHandle |
nothing — the task detaches and keeps running |
What the compiler cannot fix for you: partial effects. Three failure modes to name in an interview:
- Progress loss — a
read_exactfuture dropped mid-transfer has already consumed bytes; recreating it restarts at the wrong offset. This is why only cancel-safe operations may sit in aselect!branch — the rule from join! and select! for Concurrent Futures has a real cost here. - Non-idempotent side effects — the charge went through, the confirmation future was dropped, and no one records it. Cancellation is not rollback; compensate explicitly (outbox, idempotency key).
- Silent non-cooperation — a task in a loop with no
.awaitnever reaches a poll, soabort()cannot land; a task stuck insidespawn_blockingfinishes the blocking call regardless. Cancellation lands at an await, never in the middle of a poll.
That third row drives the design of clean shutdowns: make cancellation cooperative, something the task opts into at well-chosen points.
async fn worker(mut stop: tokio::sync::oneshot::Receiver<()>) {
loop {
tokio::select! {
_ = &mut stop => { println!("draining"); break; } // signal arrived
job = queue.recv() => process(job),
}
}
}
Production code usually generalizes this to tokio_util::sync::CancellationToken (cloneable, hierarchical: cancelling the parent cancels children) or a watch channel broadcast to every task, so one signal orderly-stops a tree of workers, then awaits the in-flight tasks instead of dropping them.
Cancellation is observed, never preemptive. While a task is inside a poll — executing plain Rust between two awaits — nothing can touch it. The runtime only acts when the task is polled again: either it resumes at the .await or it drops the future instead. Three consequences fall out: a poll that runs 100 ms of CPU delays cancellation by 100 ms; a task in a loop with no await is effectively uncancellable; and your destructors run inside the cancellation path, so a Drop that blocks (joining a thread, flushing synchronously, taking a lock) blocks the shutdown itself. Keep destructors fast and free of blocking work — cancellation quality equals destructor quality.
| You can rely on | You cannot rely on |
|---|---|
| destructors running exactly once, in declaration order | partial side effects being undone |
| guards releasing; no UB, no leaks of borrowed data | a blocked (non-await) region stopping early |
Drop observing a consistent, memory-safe state |
cancellation arriving at a specific point in time |
| a cooperative task exiting at its chosen await | a task with no awaits stopping at all |
Interview answer to "how do you cancel async work?": forced path = drop the future (abort, timeout, losing select!), effective at the next await; graceful path = a signal future raced with the work inside select!, letting each task finish its current unit and release resources deliberately. Prefer graceful for shutdown, forced for deadlines.
Why TcpStream::connect().await Doesn't Block: The Reactor Model
std::net::TcpStream::connect(addr) blocks the calling thread until the kernel completes the handshake — under Tokio that would pin a worker for the duration, exactly the starvation described in The Tokio Runtime. Tokio's TcpStream solves it by never blocking the thread at all:
use tokio::net::TcpStream;
use tokio::io::AsyncReadExt;
let mut stream = timeout(Duration::from_secs(2), TcpStream::connect(addr)).await
.map_err(|_| "connect timed out")??; // Elapsed -> io::Result<TcpStream>
let mut buf = [0u8; 1024];
let n = timeout(Duration::from_secs(1), stream.read(&mut buf)).await
.map_err(|_| "read timed out")??;
println!("read {n} bytes");
Under the hood, three pieces cooperate:
- Reactor. On first use, Tokio registers the socket with its I/O driver — mio on top of
epoll(Linux),kqueue(macOS/BSD), or IOCP (Windows). One thread multiplexes every registered descriptor. - Readiness.
connect().awaitreturnsPoll::Pendingwhile the handshake is in flight and subscribes for writability. Your task is parked; the worker goes to run other tasks. - Wake. When the socket becomes ready, the reactor fires the task's
Waker, the scheduler re-polls the future, and the operation completes on that poll —Ready(Ok(stream)).
The payoff is the scale argument interviewers love: 10,000 concurrent connections need one reactor thread plus a handful of workers and 10,000 state machines in memory — not 10,000 OS threads with multi-megabyte stacks. A connection costs bytes of state, not a thread. Blocking calls are the exception path: a synchronous DNS lookup, a legacy driver, a blocking TLS handshake in a library with no async API — those go to spawn_blocking, off the workers.
A minimal server shows the whole loop — accept, hand off, keep listening:
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (stream, peer) = listener.accept().await?; // parks until a SYN arrives
tokio::spawn(async move { handle(stream, peer).await }); // new task, new state machine
}
accept().await is on the cancel-safe list, which matters because it routinely sits in a select! racing a shutdown branch — an accept lost to cancellation simply happens again on the next loop trip.
The type mapping between blocking and async networking is one table, and choosing the wrong column is how a server's latency graph develops a cliff:
Blocking (std) |
Async (tokio) |
Notes |
|---|---|---|
TcpStream::connect |
TcpStream::connect(..).await |
reactor-driven handshake |
TcpListener::accept |
listener.accept().await |
returns a non-blocking stream |
stream.read (blocks) |
stream.read(&mut buf).await |
park + waker instead of a blocked thread |
to_socket_addrs sync DNS |
lookup_host(..).await |
resolver on the blocking pool |
| thread-per-connection | one task per connection | state vs. stacks |
Cancellation safety shows up here too, because I/O futures get dropped constantly — every timeout and losing select! branch. Tokio draws the line precisely:
| Operation in an async context | Cancel safe? | Why |
|---|---|---|
AsyncReadExt::read / read_buf |
yes | bytes stay in the socket buffer until a poll returns the count |
read_exact, read_to_end, read_to_string |
no | bytes already moved into your buffer; progress dies with the future |
AsyncWriteExt::write |
yes | returns how many bytes it took; nothing taken = nothing lost |
write_all |
no | it may have flushed a prefix with no record of how much |
TcpListener::accept |
yes | the accepted connection is only removed on the completing poll |
So the rule for wrapping I/O in timeout: prefer the single-shot read/write primitives inside races and loops, keep the accumulating helpers (read_exact, read_to_string, write_all) in straight-line code where nothing can drop them mid-flight. Wrong choice produces the nastiest bug class in async I/O — a response that is truncated or duplicated only under timeout pressure, i.e. only in production.
Where next: the address-stability requirement behind every poll(self: Pin<&mut Self>) — Pin and Unpin Explained.
Quiz
1. What does `tokio::time::timeout(d, fut).await` return when the deadline passes first?
2. Which statement about cancelling an async task is true?
3. Why does `TcpStream::connect(addr).await` not occupy an OS thread while the handshake runs?
4. You wrap `AsyncReadExt::read_to_string` in a timeout inside a loop. Why is this dangerous?
Flashcards
Question
What are the signatures and results of timeout and timeout_at?
Click to reveal answer
Answer
timeout(Duration, F) and timeout_at(Instant, F) both await to Result<F::Output, Elapsed> — nested as Result<Result<T, E>, Elapsed> when the inner future is fallible. On expiry the inner future is dropped (cancelled) and Elapsed displays 'deadline has elapsed'.
Question
Define async cancellation in Rust terms and give four triggers.
Click to reveal answer
Answer
Cancellation is dropping a future while it is suspended at an await: locals unwind, Drop impls run, nothing resumes. Triggers: a timeout elapsing, losing a select! branch, JoinHandle::abort() (at the next poll), and runtime shutdown. Dropping a JoinHandle is NOT a trigger — it detaches.
Question
What does Rust guarantee when a future is cancelled, and what remains the application's responsibility?
Click to reveal answer
Answer
Guaranteed: memory safety — destructors run in order, mutex guards release, no dangling references or undefined behavior. Yours: logical integrity — partial side effects (charges, sent bytes, taken messages) are not rolled back, so use idempotency keys, outboxes, or compensate explicitly.
Question
Describe the reactor path from TcpStream::connect().await to completion.
Click to reveal answer
Answer
The socket is registered with Tokio's I/O driver (mio over epoll/kqueue/IOCP); the connect future returns Pending while the handshake proceeds and the task parks. Readiness fires the task's Waker, the scheduler re-polls, and the future returns Ready(Ok(stream)). One reactor thread multiplexes every descriptor — connections cost state, not threads.
Question
Which async I/O calls are safe to race against a timeout, and which are not?
Click to reveal answer
Answer
Safe: read, read_buf, write, write_buf, accept, recv, next — side effects only commit in the poll that returns Ready. Unsafe: read_exact, read_to_end, read_to_string, write_all — they consume or emit bytes before completing, so dropping them mid-flight loses progress. Also unsafe: Mutex::lock / Semaphore::acquire lose queue position.
Revision Notes
Key Takeaways
- 1. timeout races a sleep against your future and returns Err(Elapsed) by dropping the inner future — cancellation is not interruption
- 2. Dropping at an await runs destructors and is always memory-safe, but partial side effects are never rolled back
- 3. Cancellations land only at await points: abort(), timeouts, and select! losers all take effect when the task is next polled
- 4. The reactor parks tasks as Pending on readiness and wakes them through Waker, so thousands of connections need no threads
- 5. Only cancel-safe primitives (read, write, recv, accept) may sit behind a timeout or select!; accumulating helpers lose data when dropped
Interview Tips
- • "How do you add a timeout to an async operation?" — timeout() gives Result<T, Elapsed>; mention the nested Result, then immediately note that expiry drops (cancels) the inner future
- • "What happens when a future is dropped mid-await?" — RAII unwind: destructors run, guards release, no UB; then show depth by separating memory safety from logical safety and naming a compensation strategy
- • "How do you cancel a Tokio task?" — abort() for forced (takes effect at next await), a oneshot/watch/CancellationToken raced in select! for graceful; note a task with no awaits cannot be stopped
- • "Why does async Rust scale better than thread-per-connection?" — readiness-based reactor: park on Pending, wake via Waker, connections are state machines; contrast with blocking sockets and 10k threads
Cheat Sheet
Timeouts, Cancellation, and Async I/O — Cheat Sheet
Bounding:
match timeout(Duration::from_secs(3), fut).await {
Ok(v) => handle(v), // future completed
Err(_) => give_up(), // Elapsed -> inner future dropped (cancelled)
}
let dl = Instant::now() + budget;
timeout_at(dl, fut).await; // shared absolute deadline across sub-operations
Cancellation = dropping the future at an await:
| Source | Effect |
|---|---|
timeout elapsed |
inner future dropped |
select! loser |
branch future dropped on the winner's return |
JoinHandle::abort |
task dropped at next poll/await |
| runtime shutdown | unfinished tasks dropped |
drop of JoinHandle |
none — task detaches |
Guaranteed vs. your job:
- Guaranteed: destructors run, guards release, no UB, no dangling state.
- Yours: idempotency (compensate partial side effects), cancel-safe primitives, cooperative stop points for graceful shutdown.
Cancel-safe I/O (tokio docs): read, read_buf, write, write_buf, accept, recv, next — yes. read_exact, read_to_end, read_to_string, write_all, Mutex::lock, Semaphore::acquire — no.
Reactor: socket registers with the I/O driver (epoll/kqueue/IOCP) → task parks on Pending → readiness fires the Waker → re-poll completes. Connections are state, not threads; blocking libraries go to spawn_blocking.