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 onForkJoinPool.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 theRunnableon 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:
- 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 aRunnableonly after both futures complete, discarding their results.
- Awaiting the First Stage (OR / Race):
acceptEither(stage, Consumer<T>): Consumes the result of whichever future completes first (both futures must return a compatible typeT).runAfterEither(stage, Runnable): Triggers aRunnableas 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:
- Error Reaction:
thenAcceptis invoked only on successful completion. If the antecedent stage fails with an exception,thenAcceptis skipped.whenCompleteis invoked unconditionally — on both success and failure. - Return Type:
thenAcceptreturnsCompletableFuture<Void>(the previous value is discarded).whenCompletereturnsCompletableFuture<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; returnsCompletableFuture<Void>.thenRun(Runnable)takes no parameters and executes an action upon completion; returnsCompletableFuture<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
whenCompleteorhandle. - Multi-future variants include
thenAcceptBoth/runAfterBoth(both succeed) andacceptEither/runAfterEither(first to complete).
Common Interview Mistakes
- Assuming
thenRunacts like afinallyblock that always runs (it is bypassed if the previous stage fails). - Confusing
thenAcceptwithwhenComplete:thenAcceptresets the pipeline type toVoid, whilewhenCompletepreserves the original payload typeT. - Trying to pass an argument into
thenRun(it acceptsRunnable, which takes no arguments).