Java deadlines: spend one budget across every stage
Separate elapsed time from civil time and carry a shrinking deadline through a request.
On this page
Key takeaways
- A timeout per stage can silently multiply the caller's latency budget.
- Measure local elapsed time with nanoTime, not a wall-clock timestamp.
First principles
A request budget is a promise about how long a caller is willing to wait, not a promise that every dependency receives that much time. Suppose an endpoint has two seconds to authenticate, query inventory, and submit a reservation. Giving each stage a two second timeout permits almost six seconds of waiting. The individual settings look reasonable while their composition violates the endpoint contract. A deadline solves this by giving every stage a view of the same diminishing allowance.
There are two different questions hidden inside the word time. A civil timestamp answers when an event happened relative to a calendar. An elapsed measurement answers how much duration passed between observations. A system clock adjustment can move civil time while work is running. Java's System.nanoTime() is intended for elapsed measurements within one JVM. Its absolute value has no calendar meaning, and values from separate JVMs cannot be compared as timestamps. Precision of the unit also does not guarantee nanosecond scheduling accuracy.
Model the budget as its starting observation plus an allowed duration. Subtract observations to measure elapsed time rather than interpreting their absolute signs. For ordinary short request lifetimes, subtraction accommodates counter wraparound as long as the interval is within the supported signed range. Keep that assumption explicit: this is a request deadline helper, not a centuries-long calendar timer. An expired budget must prevent starting another stage; passing zero into a library is unsafe when zero means wait forever.
Runnable example
Save this as DeadlineDemo.java and run javac DeadlineDemo.java && java DeadlineDemo with JDK 17 or later. The injected clock makes expiry deterministic without sleeping or depending on machine load.
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier;
public class DeadlineDemo {
record Budget(long started, long allowed, LongSupplier clock) {
static Budget begin(Duration duration, LongSupplier clock) {
long allowed = duration.toNanos();
if (allowed <= 0) throw new IllegalArgumentException("positive budget required");
return new Budget(clock.getAsLong(), allowed, clock);
}
long remainingNanos() {
long elapsed = clock.getAsLong() - started;
return Math.max(0, allowed - elapsed);
}
long requireRemainingNanos() {
long remaining = remainingNanos();
if (remaining == 0) throw new IllegalStateException("deadline expired");
return remaining;
}
}
public static void main(String[] args) {
AtomicLong clock = new AtomicLong(100);
Budget budget = Budget.begin(Duration.ofMillis(200), clock::get);
clock.addAndGet(Duration.ofMillis(150).toNanos());
System.out.println(budget.requireRemainingNanos() / 1_000_000);
clock.addAndGet(Duration.ofMillis(50).toNanos());
try {
budget.requireRemainingNanos();
throw new AssertionError("expired work was accepted");
} catch (IllegalStateException expected) {
System.out.println(expected.getMessage());
}
}
}
Production code supplies System::nanoTime. Before an outgoing operation, convert the remaining duration into the dependency client's supported timeout unit and apply its own smaller maximum if appropriate. Converting nanos to whole milliseconds truncates. If a positive remainder becomes zero, reject it or use a documented bounded rounding policy; never accidentally turn an expiring call into an unlimited one. Connection acquisition, request execution, and response reading may require separate settings even though they consume one shared budget.
Contextual failure case
An inventory service times out after waiting for its connection pool. The endpoint retries with the original timeout, then waits for payment using another fresh timeout. At the edge, the caller has already disconnected. The server continues creating reservations, making apparent availability fluctuate and increasing dependency pressure. Extending the timeout can deepen this failure because abandoned work lives longer. Capture queue wait separately from network wait before deciding that the dependency itself is slow.
The deeper distinction is between no longer waiting and stopping the effect. Completing a future exceptionally does not necessarily interrupt its underlying operation. Thread interruption is cooperative, and a remote service may commit after the client gives up. A deadline therefore needs cancellation plumbing, a policy for work that cannot be cancelled, and an idempotency strategy for ambiguous writes. It is a resource admission rule, not a distributed rollback mechanism.
Verification
The demonstration must print 50 followed by deadline expired. Add a test that advances the fake clock by 199 milliseconds and confirms a positive remainder, then by one more millisecond and confirms rejection. Test zero and negative construction separately. In an integration test, record stage entry times and prove a retry never receives more time than the first attempt's remaining budget. For incident evidence, use timestamp conversion to inspect recorded wall-clock events, but do not substitute those values for elapsed measurements.
Exercise
Add a maximum of two attempts to a fake dependency that consumes 120 milliseconds on its first attempt. Start with 200 milliseconds and reserve 20 milliseconds for producing the response. Compute what the second attempt may spend and reject it if the response reserve cannot be preserved. Verify the same behavior with a deterministic clock rather than real delays. Explain which reservation should be enforced at stage entry and which checks still belong inside long-running loops.
Safety and caveats
Never send a raw nanoTime deadline across process boundaries. Send a documented relative budget or a wall-clock deadline with an explicit clock-skew policy, and decrease the allowance at each hop. Validate unreasonable durations before conversion because Duration.toNanos() can overflow. Do not log authentication material while diagnosing late work. Preserve the distinction between a user's timeout, an internal overload rejection, and a dependency error so operators can identify which boundary actually refused progress.