Note
This article uses the Java 21 preview structured concurrency API (StructuredTaskScope, JEP 453). API shape changed in later previews. See Part 9 for Java 21 -> Java 25 migration guidance. Compile and run with--enable-preview.
Originally published on engnotes.dev:
Timeout Patterns in Java 21
This is a shortened version with the same core code and takeaways.
Timeout handling gets oversimplified pretty often.
The real design question is usually not “did we hit the deadline?” It is “what should the response mean once we do?” Some endpoints should fail completely. Others should return what is ready and mark the rest as missing. Structured concurrency helps, but it does not make that decision for you.
The Strict Version
This is the simple all-or-nothing pattern from the article:
public <T> T runInScopeWithTimeout(Callable<T> task, Duration timeout) throws Exception {
Instant deadline = Instant.now().plus(timeout);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var future = scope.fork(task);
scope.join();
if (Instant.now().isAfter(deadline)) {
throw new TimeoutException("Operation exceeded timeout: " + timeout);
}
scope.throwIfFailed();
return future.get();
}
}
This is usually the right choice when the whole response has to be correct or not returned at all.
The Partial-Results Version
When some sections are optional, the response policy changes:
public <T> List<Optional<T>> executeWithPartialResults(List<Callable<T>> tasks, Duration timeout)
throws InterruptedException {
Instant deadline = Instant.now().plus(timeout);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<StructuredTaskScope.Subtask<T>> subtasks = new ArrayList<>();
for (Callable<T> task : tasks) {
subtasks.add(scope.fork(task));
}
try {
scope.joinUntil(deadline);
} catch (TimeoutException ignored) {
// Deadline reached: return what is available.
}
List<Optional<T>> results = new ArrayList<>(subtasks.size());
for (var subtask : subtasks) {
if (subtask.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
results.add(Optional.of(subtask.get()));
} else {
results.add(Optional.empty());
}
}
scope.shutdown();
return results;
}
}
That is the part I like here. The timeout policy is explicit. You can tell from the code whether the endpoint is strict or whether it is allowed to degrade.
The Important Java 21 Caveat
joinUntil(...) only tells you the deadline was reached. It does not decide your response policy for you.
That means you still need to answer a few questions yourself:
- should the endpoint fail completely?
- should it return partial data?
- how should missing sections be represented?
- should unfinished work be cancelled right away?
That last point matters more than people think. In the Java 21 preview style, if you return early with partial data, remember scope.shutdown(). Forgetting that is a good way to leave stale work running after the response is already gone.
When To Choose Which
I would use full failure when:
- the response only makes sense if all parts are present
- downstream decisions depend on complete data
- partial data would be misleading
I would consider partial results when:
- some fields are optional or just enrichments
- degraded output is acceptable
- the response contract clearly marks what is missing
That decision is usually more important than the concurrency primitive itself.
The Practical Takeaway
What structured concurrency gives you here is not “automatic timeout design.” It gives you a cleaner place to express timeout design.
That is useful because the code stops hiding the response policy. You can see whether the endpoint is strict, whether it degrades, and whether unfinished work gets cleaned up properly.
Full article with more examples, progressive callbacks, test guidance, runnable repo, and live NoteSensei chat:
Top comments (0)