Java's timeouts let go of finished requests, except java.util.Timer
This is the fourth 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, even after the timer is cancelled. Node doesn't, but a hand-rolled Promise.race timeout does. On Java 17, 21 and 25 the JDK's own timeouts keep nothing: orTimeout, completeOnTimeout, HttpClient request timeouts, and a cancelled ScheduledExecutorService task all let go as soon as the request is done. The exception is the oldest timer in the JDK. A cancelled java.util.TimerTask keeps everything it references until the time it would have run.
The test
Each request is a CompletableFuture that a worker thread completes with a 32 KiB byte array as soon as it picks the request up. Each one gets a 30-second timeout, armed one of the ways below. I keep a WeakReference to every array. After 2,000 requests have finished, with their timeouts still pending, I drop my own references, call System.gc() three times and count the arrays still reachable. I ran Eclipse Temurin 17.0.20, 21.0.12 and 25.0.4 (the August 2026 updates) on Linux arm64, three runs of each. All three versions gave the same counts, except one Java 21 run of the Timer row, which kept 1,999.
| Pattern | Kept of 2,000 |
|---|---|
| No timeout (control) | 0 |
cf.orTimeout(30, SECONDS) | 0 |
cf.completeOnTimeout(…, 30, SECONDS) | 0 |
HttpClient.sendAsync, request with and without .timeout(…) (response bodies counted) | 0 |
ScheduledThreadPoolExecutor.schedule, cancelled when done (default policy) | 0 |
Same with setRemoveOnCancelPolicy(true) | 0 |
cf.applyToEither(timeoutFuture, …), timeout task not cancelled | 0 |
CompletableFuture.anyOf(cf, timeoutFuture), timeout task not cancelled | 0 |
ScheduledThreadPoolExecutor.schedule, never cancelled | 2,000 |
runAsync(…, CompletableFuture.delayedExecutor(30, SECONDS)) | 2,000 |
java.util.Timer, TimerTask.cancel() when done | 2,000 |
Same, then Timer.purge() | 1 |
The rows that kept 2,000 held about 69 MB of heap after GC, against 3–5 MB for the others. With a 3-second timeout, checked after 4 seconds, all three were back to 0. As in Node, nothing leaks forever; it's held for exactly the timeout.
Why the JDK's own timeouts are clean
On 17 and 21, orTimeout schedules its timeout on a shared ScheduledThreadPoolExecutor that's created with setRemoveOnCancelPolicy(true), and adds a completion action that cancels it. Cancelling takes the task out of the queue right away. Java 25 moved these timeouts onto ForkJoinPool's new delay scheduler (arrangeTimeout in CompletableFuture.java), whose cancel nulls the task's runnable at once and removes it from the heap later (DelayScheduler.java). Either way, the finished request is unreachable from the timer as soon as it completes.
A plain ScheduledThreadPoolExecutor keeps cancelled tasks in its queue by default, and they really are still there: the queue held all 2,000 (20,000 in a larger run). But FutureTask drops its callable when it's cancelled, "to reduce footprint" as the comment says (FutureTask.java). What's left is an empty shell. 20,000 of them came to 1–2 MB, roughly 50–100 bytes each, with no request data.
The race-style idioms are clean too, which is where Java differs from Node. With applyToEither or anyOf, the uncancelled timeout future is still held by the scheduler for 30 seconds, and it still has the dependent completion that links it to the result. But a CompletableFuture completion nulls its links to its sources and its result future once it has fired. Node's Promise.race keeps that link, so there the result stays reachable.
Why java.util.Timer keeps it
TimerTask.cancel() only sets the task's state to cancelled (TimerTask.java). The task stays in the timer's queue, and the timer thread throws it away only when it reaches the front at its scheduled time (Timer.java). A TimerTask is your own subclass, usually an anonymous class closing over the request, so everything it captured stays reachable for the full delay. The JDK can't null your fields the way FutureTask nulls its callable.
Timer.purge() removes cancelled tasks, and its javadoc says it's for "the rare application that cancels a large number of tasks", which is any application that uses Timer for timeouts. After a purge, one request was still kept in every run. That's the earliest task: the timer thread fetched it from the queue and holds it in a local variable while it waits for that task's time, and purge() doesn't wake the thread.
The other two are by design
A task that's never cancelled keeps what it captured until it runs; that's what it's for. delayedExecutor is the one to watch, because it's the JDK's own building block for delays and it returns an Executor, so there's no handle to cancel anything submitted to it. Used as a timeout, it holds every request for the full timeout. If you're on Java 9 or later, orTimeout and completeOnTimeout do the same job and let go.
What to take from it
Like Node and unlike asyncio and Go, Java's runtime keeps nothing it can't still use. The JDK's timeout APIs (orTimeout, completeOnTimeout, HttpClient's request timeout) release the request when it completes, and so does a scheduled task you cancel. To keep request data in Java you have to use java.util.Timer, an uncancellable delayedExecutor, or a scheduled task you never cancel, and then you hold request rate × timeout × request size all the time. At 440 requests a second with 30-second timeouts and 32 KiB per request, the load from the Node post, that would be about 13,000 requests and 430 MB. Here that's arithmetic from the one-shot counts, not a separate load test.