Back to Blog
Java

Java CompletableFuture thenAccept: Syntax and Behavior

java completablefuture thenaccept: Learn how CompletableFuture.thenAccept works, how it differs from thenApply and thenRun, and when to use it for side effects.

CompletableFutureJava ConcurrencyAsync ProgrammingConsumerthenAccept
A diagram showing a CompletableFuture completing and passing its value into a thenAccept consumer that produces no result.

java completablefuture thenaccept requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

The thenAccept method on CompletableFuture is the standard way to consume a future's result without transforming it. In Java, CompletableFuture.thenAccept registers a Consumer that runs when the future completes normally, and it returns a new CompletableFuture<Void> that completes after the consumer finishes. The consumer receives the result of the original future, but the returned future carries no value.

CompletableFuture<String> future = CompletableFuture.completedFuture("order-123"); CompletableFuture<Void> result = future.thenAccept(orderId -> { System.out.println("Processed " + orderId); });

The consumer runs exactly once when the original future completes normally. If the original future never completes, the consumer never runs and the returned future never completes.

thenAccept vs thenApply vs thenRun

These three methods are often confused because they all register a dependent action. The difference is in the input and output.

MethodInputOutputPurpose
thenApplyFunction<T, R>CompletableFuture<R>Transform the value
thenAcceptConsumer<T>CompletableFuture<Void>Consume the value, no result
thenRunRunnableCompletableFuture<Void>Run an action, ignore the value

thenApply is for transforming a value into another value. thenAccept is for side effects — logging, writing to a store, sending a notification — where the result of the action does not matter to the rest of the chain. thenRun ignores the value entirely, which is useful when the action does not depend on the result of the original future.

Threading Behavior

thenAccept runs the consumer on the thread that completes the original future. If the future is already complete when thenAccept is called, the consumer runs immediately on the calling thread.

This matters in production. If the completing thread is a shared executor thread, a slow consumer blocks that thread and delays other tasks submitted to the same executor. When the consumer does significant work, thenAcceptAsync is the safer choice because it schedules the consumer on a separate executor.

CompletableFuture<Void> asyncResult = future.thenAcceptAsync(orderId -> { // Runs on the common ForkJoinPool or a supplied executor persist(orderId); }, executor);

The ordering guarantee is important: thenAccept preserves the completion order of the original future. If two futures complete in a specific order, their thenAccept consumers run in that same order when registered on the same future.

Error Handling

If the original future completes exceptionally, the consumer never runs. The returned CompletableFuture<Void> completes exceptionally with the same exception.

CompletableFuture<String> failed = new CompletableFuture<>(); failed.completeExceptionally(new RuntimeException("timeout")); CompletableFuture<Void> result = failed.thenAccept(value -> { System.out.println("This never runs"); }); result.exceptionally(ex -> { System.out.println("Caught: " + ex.getMessage()); return null; });

The exception propagates through the chain. You can attach exceptionally or handle to the returned future to recover. Note that the consumer itself can throw — if the consumer throws a runtime exception, the returned future completes exceptionally with that exception.

Chaining and Composition

thenAccept is often used at the end of a chain where the final result is not needed, or as an intermediate step that performs a side effect while the chain continues.

CompletableFuture.supplyAsync(() -> fetchOrder(orderId)) .thenApply(order -> enrich(order)) .thenAccept(order -> log(order)) .thenRun(() -> notify(orderId));

The thenRun after thenAccept runs after the consumer finishes, but it does not receive the consumer's result — because there is none. This is a common pattern for fire-and-forget side effects in a pipeline.

When to Use thenAccept

Use thenAccept when you need to react to a future's result without transforming it. Typical cases:

  • Logging the result
  • Writing to a cache or store
  • Sending a notification
  • Updating a UI or metric

Do not use thenAccept when you need to pass the value further down the chain — that is thenApply's job. Using thenAccept in the middle of a chain that needs the value forces you to recover it from an external variable, which breaks the functional style and introduces shared mutable state.

Production Considerations

The most common production issue with thenAccept is blocking the completing thread. A slow consumer on a shared executor can cause thread starvation. Measure the consumer's latency and choose thenAcceptAsync with a dedicated executor when the consumer performs I/O or CPU-heavy work.

Another issue is silent failure. If the consumer throws, the returned future completes exceptionally. If nothing observes that future, the exception is lost. Attach exceptionally or whenComplete to the returned future when the side effect must not fail silently.

Finally, remember that thenAccept returns a new future. If you discard that future, you lose the ability to observe the consumer's completion or failure. Keep a reference when the side effect is part of the application's correctness.

java completablefuture thenaccept: Practical Usage and Code | RYUSLOG DEV