cswork / libraryOpen practice workspace

Articles

CompletableFuture Interview Questions: Executors, Exceptions, and Cancellation

Practice CompletableFuture interview questions on executors, thread-pool starvation, exception recovery, timeouts, and the limits of cancellation in Java.

A service launches three downstream calls with CompletableFuture, adds a timeout, and returns promptly when one dependency stalls. Its request latency looks better, but the timed-out calls keep occupying connections. Another implementation uses a single worker and blocks that worker waiting for a child task queued behind it. Both programs look asynchronous on the surface.

CompletableFuture interview questions become easier when you separate three things: the dependency graph, the executor that provides capacity, and the lifetime of the underlying work. Completing a result does not automatically provide a policy for all three.

Evidence boundary: Official contracts below reference Java SE 26. Original exercises were checked locally with OpenJDK 26.0.2.1; they are functional observations, not throughput benchmarks or guarantees about every scheduler. Design recommendations are labeled separately. No candidate reports or employer question-frequency claims are used.

Start with Build a Custom CompletableFuture: Async Primitive and Parallel Array Processing and explain how completion differs from execution.

Which thread runs each stage?

Official fact: Non-async dependent actions may execute on a thread completing the future. In Java SE 26, async methods without an explicit executor use the common pool; subclasses can override the default executor for non-static methods. An explicit executor specifies where that async action is submitted. CompletableFuture API.

Do not answer that thenApply always creates a thread or always runs on the original supplier’s worker. In the local exercise, attaching thenApply to an already completed future ran its action on the registering thread. Attaching thenApplyAsync with a named single-thread executor ran on that executor’s worker. The observations illustrate why an exact thread-name prediction needs controlled setup.

Design inference: Annotate each stage with its executor and work type. A short mapping operation and a blocking database call deserve different capacity discussions. Passing an executor to the initial supplyAsync is not a promise that every later async stage uses it; make the intended executor explicit where the boundary matters.

For a slow pipeline, ask whether time was spent waiting in a queue, running CPU work, or waiting on an external service. Adding more stages does not create more database connections or downstream capacity.

When should you use thenApply or thenCompose?

Official fact: CompletionStage distinguishes transformations of a value from composition with another stage. It also provides combinations of independently produced results. These methods describe dependencies; their async variants describe execution facilities for the supplied actions. CompletionStage API.

Suppose an account lookup produces an account identifier, and a second asynchronous operation fetches that account’s balance. If the mapping function returns a future, an ordinary transformation produces a nested result. The caller now has an outer completion that can merely mean “the next future was created.”

The original example below keeps one future representing the final integer result:

CompletableFuture<Integer> result =
    CompletableFuture.supplyAsync(() -> 21, pool)
        .thenCompose(value ->
            CompletableFuture.supplyAsync(
                () -> value * 2, pool));

The local single-worker test returned 42. The important feature is the dependency edge: the continuation returns the next stage instead of parking the worker until that stage finishes. This is an explanation of composition, not a claim that two tiny arithmetic operations benefit from concurrency.

For independent lookups, launch them independently and combine their outputs. For a lookup that requires the first result, model that dependency explicitly. Calling everything “parallel” obscures a real ordering constraint and makes latency estimates unreliable.

Why can join cause thread-pool starvation?

Consider this deliberately broken interview fragment. Assume pool has one worker and an ordinary task queue:

CompletableFuture<Integer> outer =
    CompletableFuture.supplyAsync(() -> {
        CompletableFuture<Integer> child =
            CompletableFuture.supplyAsync(() -> 21, pool);
        return child.join();
    }, pool);

The parent owns the only worker. The child needs that worker. The parent will not release it until the child completes. There is no free worker to make the awaited progress. Replacing join with an indefinitely blocking get does not remove the dependency cycle.

In the local reproduction, a bounded get replaced the unbounded wait so the demonstration could terminate safely. The parent timed out while its child remained queued. Once the parent returned, the queue could progress. The 150 ms test deadline was a safety bound, not a performance measurement.

StepWorker and queue
Parent startsWorker occupied
Child submittedChild queued
Parent waitsWorker still occupied
Child needs workerNo progress possible

Design inference: Prefer expressing the next operation as a dependency rather than blocking inside a capacity-constrained worker. Increasing the pool size can postpone starvation without removing the underlying risk when many parent tasks repeat the pattern. Explain the wait-for graph before recommending a larger number.

Also distinguish an intentional blocking boundary from accidental blocking in the middle of the graph. A command-line caller waiting for its final result has different capacity constraints from a worker waiting for work scheduled onto its own saturated executor.

What is the difference between observing and recovering an exception?

Official fact: handle can transform either success or failure. exceptionally supplies a recovery value for exceptional completion. whenComplete observes completion while ordinarily preserving its outcome; exceptions thrown by the observer have specified propagation rules. CompletionStage completion methods.

The local exercise began with an IllegalStateException representing an unavailable inventory service. A non-throwing whenComplete incremented an observation counter, and its returned stage still failed. A handle branch produced -1, while exceptionally produced 0. Those are different application outcomes, even though all three callbacks saw the failure.

Design inference: Choose recovery values for their business meaning. Returning an empty inventory list can turn “inventory unavailable” into “nothing is in stock.” That might suppress purchases silently rather than exposing a degraded dependency. Prefer an explicit result describing success, absence, or failure when callers need that distinction.

Place recovery at the boundary that owns the policy. Recovering one optional recommendation call differs from recovering the entire checkout pipeline. Keep the returned stage: creating a recovery branch and then waiting on the original failed future does not make the original result successful.

Do not let logging become an unexamined failure source. If an observer throws, inspect the returned stage and its cause instead of assuming the observer is harmless because its intended purpose was telemetry.

How should you explain get, join, allOf, and anyOf?

Official fact: Exceptional get uses ExecutionException, while join exposes unchecked completion failures; cancellation has its own exception behavior. allOf completes after all inputs complete. anyOf adopts a completed input’s result or exception. Neither method provides a general policy for stopping sibling work. CompletableFuture result and aggregation methods.

The local aggregation test paired one failed future with one unfinished future. The allOf result remained unfinished until the second input completed, then failed. The corresponding anyOf result failed immediately because the failed input was already complete. This counterexample matters when a candidate describes anyOf as “the first successful response.”

Design inference: Decide whether the product requires all successful results, partial results, or the first acceptable success. For partial results, preserve an outcome for each dependency rather than discarding every failure behind a default. For first acceptable success, define how failures are counted and what happens when every candidate fails.

After an aggregate finishes, account for outstanding operations. A completed response is not evidence that the connection pool is free. Track which branches are still executing and whether they can safely be abandoned, cooperatively stopped, or allowed to finish for another consumer.

Does a timeout stop the underlying operation?

Official fact: orTimeout exceptionally completes the same future if it is still incomplete; completeOnTimeout instead supplies a fallback value. CompletableFuture.cancel is exceptional completion, and its mayInterruptIfRunning argument does not interrupt processing in this implementation. CompletableFuture timeout and cancellation API.

The local timeout exercise confirmed that orTimeout returned the same object. After its deadline won, a later attempt to complete that object normally returned false. The test did not involve a network request, so it makes no claim about socket cancellation or a particular HTTP client.

Design inference: A consumer-specific deadline should not accidentally become a shared producer’s deadline. If several callers share one future, mutating that future’s completion state affects the shared object. Give ownership and per-consumer views deliberate treatment; do not assume a timeout call always creates an independent wrapper.

For real I/O, propagate a deadline into the client or protocol where supported and define what cancellation means there. A request may already have reached the server. Cancelling the local response cannot prove that a remote payment, write, or email did not happen. Reconcile ambiguous outcomes and use the operation’s idempotency mechanism before retrying.

How can you demonstrate the cancellation boundary reliably?

Use synchronization rather than hoping a sleep gives the task enough time to start. The original local test used three latches: started, release, and finished. A supplier signaled started, waited on release, then performed a controlled in-memory side effect and signaled finished.

The test thread waited for started before calling cancel(true). It verified cancellation, released the supplier, and waited for finished. The supplier still performed its side effect, and its interrupted flag remained false. This isolates an already-running task from the separate question of whether queued work starts at all.

EventResult versus work
Supplier startsWork active
Future cancelledResult terminal
Supplier releasedWork continues
Supplier finishesSide effect observed

Design inference: If the application needs cooperative stopping, introduce a mechanism that the operation actually checks or supports. Define cleanup responsibilities, resource release, and cancellation propagation through child operations. A boolean named cancelled is useful only if the relevant work observes it at an appropriate boundary.

Test cancellation before start, during execution, after completion, and while another consumer still needs the result. These cases answer different ownership questions and should not be collapsed into one happy-path assertion.

What executor policy should a production answer include?

Official fact: ThreadPoolExecutor relates pool size, queue behavior, and rejection policy. An unbounded queue can grow without bound when arrival rate exceeds processing capacity; bounded queues require an explicit saturation strategy. ThreadPoolExecutor API.

Design inference: State a concurrency budget per constrained dependency, a queue limit, and what the caller sees when admission fails. Measure queue age alongside active workers and downstream saturation. A large queue can make submission look successful while requests expire before useful work begins.

Treat fallback execution carefully. Running rejected work on the caller may apply backpressure, but the caller could be a latency-sensitive request thread. Rejecting explicitly may be preferable when the deadline is already exhausted. Choose against the workload instead of presenting one rejection policy as universally best.

Official fact: Executor shutdown stops acceptance of new tasks according to the chosen shutdown method; it is not equivalent to awaiting termination. The API exposes termination waiting and describes the limits of attempted interruption. ExecutorService lifecycle.

The local exercises closed their executor and verified termination. A production design should similarly assign an owner to shutdown, in-flight work, and bounded cleanup. Otherwise, a correct future graph can still leave a service unable to stop cleanly.

Sources and Further Reading