.NET's timeouts let go of finished requests; an undisposed linked token never does
Fifth runtime, same question: when a request finishes before its timeout, does the timeout still keep the request's data alive? asyncio and Go do. Node and Java don't, apart from a few patterns. On .NET 8, 9 and 10 the runtime's own timeouts keep nothing: Task.WaitAsync, HttpClient.Timeout, and even Task.WhenAny(work, Task.Delay(timeout)) with the delay never cancelled. The new failure is worse than anything in the earlier runtimes, though. A linked CancellationTokenSource that you don't dispose stays attached to its parent token after its timeout has fired, for as long as the parent lives. Everything the earlier runtimes kept was let go eventually, at the latest a while after the timeout. This one isn't let go at all.
The test
Each request gets a 32 KiB byte array, does a trivial async step, and finishes, 50 at a time. Each one has a 30-second timeout, set up one of the ways below. I keep a WeakReference to every array. After 2,000 requests have finished, with their timeouts still pending, I run three full blocking collections and count the arrays still reachable. I ran .NET 8.0.31, 9.0.20 and 10.0.12 on Linux arm64, three runs of each. All three versions gave the same counts, apart from a single stray array in four of the 153 runs.
| Pattern | Kept of 2,000 |
|---|---|
| No timeout (control) | 0 |
await work.WaitAsync(timeout) | 0 |
Task.WhenAny(work, Task.Delay(timeout)), delay never cancelled | 0 |
Same with three tasks, or a List<Task> | 0 |
Task.Delay(timeout, token), token cancelled when done | 0 |
new CancellationTokenSource(timeout) + Token.Register(…), disposed | 0 |
| Same, only the registration disposed | 0 |
System.Threading.Timer, disposed when done | 0 |
System.Threading.Timer, never stored anywhere | 0 |
new CancellationTokenSource(timeout) + Token.Register(…), nothing disposed | 2,000 |
Linked to a long-lived token + CancelAfter + Register, nothing disposed | 2,000 |
Task.Delay(timeout).ContinueWith(…) | 2,000 |
HttpClient.SendAsync, with or without Timeout | 50 |
The rows that kept 2,000 held about 66 MB of heap after collection, against 0.1–0.2 MB for the clean ones. With a 3-second timeout, recounted 5 seconds later, all three were back to 0 arrays. So far, the same shape as Java: you only keep request data if you hold it yourself until the timeout runs.
Why the runtime's own timeouts are clean
WaitAsync completes through a small promise that, as soon as either side finishes, disposes its timer and token registration and removes itself from the task it was watching (CancellationPromise.Cleanup in Task.cs). HttpClient makes a linked token source per request, calls CancelAfter on it, and disposes it when the request is done (HttpClient.cs).
The WhenAny result is the one that differs from Node. In Node, an uncancelled Promise.race timer kept every result for the full timeout, because the pending timeout promise still linked to the race. Here the uncancelled Task.Delay also sits in the timer queue for 30 seconds, but when the work finishes first, WhenAny drops its references to both tasks and removes its continuation from the delay (TwoTaskWhenAnyPromise, same file). The pending delay is left holding nothing but itself.
The one that never lets go
The pattern is common in background services: per operation, link a token source to a long-lived token (the app's stopping token, a worker's stoppingToken), set a timeout with CancelAfter, and pass the linked token down. If you write var cts = … instead of using var cts = …, here's what happens.
The linked source registers itself on the parent token when it's created. That registration is undone in exactly one place, the linked source's Dispose (CancellationTokenSource.cs). Timing out doesn't undo it. When the timeout fires, the linked source cancels and runs its own callbacks, which drops the request data. But the source itself stays in the parent's registration list.
To size it, I ran 200,000 requests with a 1-second timeout and recounted 4 seconds after they'd all fired:
| Pattern | Request arrays kept | Token sources kept | Heap after |
|---|---|---|---|
| Linked, disposed | 0 | 0 | 16.0 MB |
| Linked, not disposed | 0 | 200,000 | 45.0–45.3 MB |
| Not linked, not disposed | 0 | 0 | 16.2–16.5 MB |
That's about 145 bytes per request, the same on 8, 9 and 10, and it's never returned while the parent token lives. An unlinked source that isn't disposed is only held by its own timer, so it's gone once the timeout fires. Small per request, but unbounded: at 440 requests a second, the load from the Node post, it's about 64 KB a second or 5.5 GB a day. That's arithmetic from the counts above, not a load test, and the request data doesn't come into it. Disposing the linked source is the only thing that takes it off the parent.
Two smaller notes
A System.Threading.Timer you don't keep a reference to keeps nothing, but only because the timer itself gets collected. Its finalizer disposes the underlying timer, so the callback never runs: in the 3-second runs, none of the 2,000 fired. If the callback was your timeout, it silently never happens.
HttpClient kept exactly one request body per concurrent request: 5 at concurrency 5, 20 at 20, 50 at 50. That was the same with no timeout set and with Connection: close, and it was still there seconds later. It's bounded and it isn't the timeout. I didn't track down what holds them.
What to take from it
.NET is with Java and Node: the runtime's timeout machinery lets go of finished work, including the WhenAny-plus-Delay idiom that leaks in Node. You keep request data only by leaving your own registration on an undisposed token source, and then only for the length of the timeout. The exception is linking. A linked token source you don't dispose is the only thing in five runtimes that grew without bound in these tests. If you link, use using.