⏱️ Section 19 · Question #5

What Do thenAccept() and thenRun() Methods Do

The thenAccept() and thenRun() methods are terminal consumer callbacks in a CompletableFuture pipeline. They are invoked when earlier asynchronous computations finish and a fina...


🟢 Junior Level

The thenAccept() and thenRun() methods are terminal consumer callbacks in a CompletableFuture pipeline. They are invoked when earlier asynchronous computations finish and a final action or side effect needs to be executed.

The fundamental difference between them is whether they require access to the computation result:

  • thenAccept(Consumer<T> action): Receives the result of the previous stage and consumes it (e.g., printing to a log, sending an email, or saving the payload to a database).
  • thenRun(Runnable action): Executes an action upon completion of the previous stage without accessing its result.

Both methods return CompletableFuture<Void> because neither produces a new value.

Code Example

CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> database.findUser(42));

// 1. thenAccept: We NEED the computed result (the user)
userFuture.thenAccept(user -> {
    System.out.println("Found user: " + user.getName());
    emailService.sendWelcomeEmail(user.getEmail());
});

// 2. thenRun: We DO NOT need the result; only the completion event matters
userFuture.thenRun(() -> {
    metrics.incrementCounter("users.fetch.completed");
    System.out.println("Database lookup completed successfully.");
});

🟡 Middle Level

Functional Consumer Comparison

The CompletionStage interface provides a consistent hierarchy of methods depending on the functional interface contract:

Method Functional Interface Input Parameter Return Future Type Purpose
thenApply Function<T, U> Value $T$ CompletableFuture<U> Value transformation (map)
thenAccept Consumer<T> Value $T$ CompletableFuture<Void> Consume result (terminal sink)
thenRun Runnable None CompletableFuture<Void> Trigger side effect / notification

Continuing Chaining After Void

A common misconception is that a pipeline cannot be extended after thenAccept or thenRun:

CompletableFuture.supplyAsync(() -> "hello")
    .thenAccept(System.out::println) // Returns CompletableFuture<Void>
    .thenRun(() -> System.out.println("Pipeline continues!")) // Valid!
    .thenApply(v -> {
        // v is of type Void and is always null!
        return 123;
    })
    .thenAccept(System.out::println); // Prints 123

Even though the payload of thenAccept is null (Void), completion of this stage still triggers subsequent stages in the pipeline.

Asynchronous Execution Variants

Each method has corresponding *Async overloads:

  • thenAcceptAsync(Consumer<T> action): Executes the consumer on ForkJoinPool.commonPool().
  • thenAcceptAsync(Consumer<T> action, Executor executor): Executes the consumer on a dedicated thread pool (essential for blocking I/O like disk writes or socket calls).
  • thenRunAsync(Runnable action, Executor executor): Executes the Runnable on a dedicated thread pool.

🔴 Senior Level

Internal Architecture: UniAccept and UniRun Nodes

Under the hood, both methods register lightweight completion nodes pushed onto the Treiber stack of the source CompletableFuture:

// UniAccept extends UniCompletion<T, Void>
static final class UniAccept<T> extends UniCompletion<T, Void> {
    Consumer<? super T> fn;
    // In tryFire(mode), extracts src.result, casts to T, invokes fn.accept(t),
    // and sets its own result field to NIL (representing null).
}

// UniRun does not interact with the result payload at all
static final class UniRun<T> extends UniCompletion<T, Void> {
    Runnable fn;
    // In tryFire(mode), invokes fn.run(), ignoring src.result
    // (provided src did not complete exceptionally), and sets result to NIL.
}

Multi-Stage Family Combinators: *Both and *Either

The semantics of thenAccept and thenRun mirror across multi-stage combinators:

  1. Awaiting Both Stages (AND / Fan-in):
    • thenAcceptBoth(stage, BiConsumer<T, U>): Waits for both futures to complete and passes both results $(T, U)$ to the consumer.
    • runAfterBoth(stage, Runnable): Executes a Runnable only after both futures complete, discarding their results.
  2. Awaiting the First Stage (OR / Race):
    • acceptEither(stage, Consumer<T>): Consumes the result of whichever future completes first (both futures must return a compatible type T).
    • runAfterEither(stage, Runnable): Triggers a Runnable as soon as either future completes.
CompletableFuture<String> primaryServer = pingServer("10.0.0.1");
CompletableFuture<String> secondaryServer = pingServer("10.0.0.2");

// Consumes whichever server responds first:
primaryServer.acceptEither(secondaryServer, fastestIp -> 
    log.info("Fastest response from: {}", fastestIp)
);

4 Tricky Questions

1. What is the key difference between .thenAccept(Consumer<T>) and .whenComplete(BiConsumer<T, Throwable>)?

Answer:

  1. Error Reaction: thenAccept is invoked only on successful completion. If the antecedent stage fails with an exception, thenAccept is skipped. whenComplete is invoked unconditionally — on both success and failure.
  2. Return Type: thenAccept returns CompletableFuture<Void> (the previous value is discarded). whenComplete returns CompletableFuture<T>, preserving the original value (or exception) and functioning as a non-mutating observer.

2. What happens if an unhandled runtime exception is thrown inside the lambda of thenAccept()?

Answer: The exception is caught by CompletableFuture’s internal completion mechanism and wrapped in a CompletionException. The CompletableFuture<Void> instance returned by thenAccept transitions to an exceptional state. Subsequent stages chained to this returned future are bypassed until an error handler (exceptionally or handle) is reached. Crucially, the original parent future remains successfully completed with its original value.

3. If .thenApply(v -> ...) is chained directly after .thenAccept(...), what value does v receive?

Answer: Parameter v is guaranteed to be null. The return type of thenAccept is CompletableFuture<Void>. In Java, Void has no instances other than the literal null. Calling any method on v (e.g., v.hashCode()) will immediately result in a NullPointerException.

4. If the antecedent future completes exceptionally, does the Runnable inside .thenRun() execute?

Answer: No, it does not execute. thenRun(Runnable) is triggered only upon successful completion of the preceding stage. If an exception occurs, the UniRun completion stage propagates that exception downstream without executing Runnable.run(). To ensure cleanup logic executes regardless of success or failure, use whenComplete((res, ex) -> cleanup()) or handle((res, ex) -> { cleanup(); return null; }).


🎯 Interview Cheat Sheet

Summary in 60 Seconds

  • thenAccept(Consumer<T>) receives and consumes the computation result; returns CompletableFuture<Void>.
  • thenRun(Runnable) takes no parameters and executes an action upon completion; returns CompletableFuture<Void>.
  • Both methods are skipped if the preceding stage completes exceptionally.
  • For side effects that must run regardless of success or failure (like resource cleanup), use whenComplete or handle.
  • Multi-future variants include thenAcceptBoth / runAfterBoth (both succeed) and acceptEither / runAfterEither (first to complete).

Common Interview Mistakes

  • Assuming thenRun acts like a finally block that always runs (it is bypassed if the previous stage fails).
  • Confusing thenAccept with whenComplete: thenAccept resets the pipeline type to Void, while whenComplete preserves the original payload type T.
  • Trying to pass an argument into thenRun (it accepts Runnable, which takes no arguments).