Tokio's timeouts drop the request; a spawned task keeps it
This is the sixth runtime I've checked for one question: when a request finishes before its timeout, does the timeout still keep the request's data alive? asyncio and Go do. Node, Java and .NET mostly don't. Rust's tokio is the cleanest of the six, for a structural reason: timeout and select! don't schedule anything that outlives the call. The sleep is a value inside your future, so when the future ends, the sleep and everything it borrowed are dropped on the spot. What keeps request data in tokio is a task you spawn and don't abort.
The test
Each request owns a 32 KiB buffer whose Drop decrements a live counter. 2,000 requests per run, tokio 1.53 and tokio-util 0.7 on a multi-thread runtime with four workers, release build, macOS arm64. The work takes 5 ms, or 1.5 s where noted. I count live buffers right after the last request finishes, at 0.5 s, 1 s after the long work would be done, and 1 s after the 3-second timeout. Three runs of each pattern gave identical counts.
| Pattern | Live buffers (of 2,000) |
|---|---|
timeout(3s, work), work finishes first | 0 |
select! { work, sleep(3s) } | 0 |
timeout(5ms, work) expires while work runs | 0 |
CancellationToken::child_token() of a long-lived parent, dropped with the request | 0 |
spawn(work) + timeout(5ms, handle), then abort the handle | 0 |
| Same, handle just dropped | 2,000 until the work ends (1.5 s), then 0 |
spawn(sleep(3s) then use state) alongside the request, not aborted | 2,000 until 3 s, then 0 |
| Same, sleeper task aborted when the request finishes | 0 |
Child token handed to a spawned task that waits on cancelled(), parent never cancelled | 2,000 at every sample (4 s) |
What this says
The first four rows are zero because nothing outlives the call. From the counts, dropping a Timeout or a Sleep leaves nothing in the runtime's timer that references the request, and a dropped child token leaves nothing on its parent. Compare Go and asyncio, where a timer callback is registered with the runtime and holds the request's context until the timer fires or the runtime cleans out the cancelled entry.
The costs are all in tasks. A dropped JoinHandle detaches the task instead of cancelling it, so a timeout wrapped around a handle ends your wait, not the work: the buffer lives until the work ends, however long that is. Aborting the handle fixes that. A detached sleeper holding an Arc of the state keeps it for the whole timeout, same as a never-cancelled timer elsewhere; abort it and it's gone. The last row isn't a timeout at all, but it's the version with no end: a task waiting on a token nobody cancels held its state through every sample, and by construction keeps it until the token is cancelled.
What to take from it
In tokio, put the timeout around the future itself, not around a handle to a spawned one. If you have to spawn, keep the handle and abort it when you stop caring. These counts are from synthetic requests with a counter, on one machine.
Why a dropped timer can't linger
I read the timer code afterwards (tokio 1.53.2). Dropping a Sleep drops its TimerEntry, whose Drop calls cancel. That takes the timer driver's lock and removes the entry from the timing wheel right then, from whichever thread dropped it (clear_entry in runtime/time/mod.rs). Go's runtime instead marks a stopped timer dead and leaves it in the heap until housekeeping gets to it, which is why Go keeps request data under load. Here there is no such deferral, so I checked the case that triggers it in Go: 64 concurrent requests, 200,000 in all, each with a 30-second timeout or a child token, beside 0, 10,000 or 100,000 other live one-hour timers. Request buffers still alive after each batch: 0 every time, and the process grew by 2 to 3 MB in every configuration.