Kafka
CompletableFuture
ListenableFuture
Java
Coding Practices

Replacing ListenableFuture with CompletableFuture in Kafka producer/consumer

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When Kafka-related code still uses ListenableFuture, many teams want to move to CompletableFuture so they can compose asynchronous work with standard Java APIs. The migration is usually straightforward, but the important detail is to adapt callbacks without turning the code into blocking wrapper logic.

Why CompletableFuture Is Usually the Better Fit

ListenableFuture works, but CompletableFuture gives you richer composition primitives such as thenApply, thenCompose, handle, and whenComplete. That matters in Kafka applications because sending a record is often only one step in a longer workflow, such as logging, retry decisions, or publishing a follow-up event.

A bad migration pattern looks like this:

java
CompletableFuture<SendResult<String, String>> future =
    CompletableFuture.supplyAsync(() -> kafkaTemplate.send("orders", payload).get());

This technically returns a CompletableFuture, but it blocks a thread on .get(). You lose the main benefit of non-blocking composition and add unnecessary executor pressure.

Adapt an Existing ListenableFuture Without Blocking

If the Kafka API you are using still returns a Spring ListenableFuture, convert it by completing a CompletableFuture from callbacks.

java
1import java.util.concurrent.CompletableFuture;
2import org.springframework.util.concurrent.ListenableFuture;
3
4public final class Futures {
5    private Futures() {
6    }
7
8    public static <T> CompletableFuture<T> toCompletable(ListenableFuture<T> source) {
9        CompletableFuture<T> target = new CompletableFuture<>();
10        source.addCallback(target::complete, target::completeExceptionally);
11        return target;
12    }
13}

This adapter preserves asynchronous behavior. No thread is blocked waiting for completion, and the result can be composed using the standard CompletableFuture API.

Producer Example with Follow-Up Work

Once the send result is a CompletableFuture, downstream logic becomes clearer.

java
1import java.util.concurrent.CompletableFuture;
2import org.springframework.kafka.support.SendResult;
3
4public CompletableFuture<Void> sendOrder(String payload) {
5    CompletableFuture<SendResult<String, String>> sendFuture =
6        Futures.toCompletable(kafkaTemplate.send("orders", payload));
7
8    return sendFuture
9        .thenAccept(result -> {
10            long offset = result.getRecordMetadata().offset();
11            System.out.println("Sent at offset " + offset);
12        })
13        .exceptionally(ex -> {
14            System.err.println("Kafka send failed: " + ex.getMessage());
15            return null;
16        });
17}

The value here is not just syntax. The workflow is easier to extend. You can chain auditing, persistence, or another async call without nesting callback objects.

Consumer Code Needs a Different Mindset

The Kafka consumer API itself is not future-based in the same way as producer send operations. poll() remains a pull-based call. In consumer-side code, the usual migration is not "replace consumer futures," but rather wrap downstream async processing with CompletableFuture where it improves structure.

java
1import java.util.concurrent.CompletableFuture;
2
3public CompletableFuture<Void> processRecordAsync(String value) {
4    return CompletableFuture
5        .supplyAsync(() -> value.trim())
6        .thenApply(v -> v.toUpperCase())
7        .thenAccept(v -> System.out.println("Processed: " + v));
8}

That means producer migration is usually about adapting an existing future type, while consumer migration is more often about organizing async business logic around consumed records.

Migration Strategy for Existing Codebases

In an older codebase, a practical sequence is to add one adapter utility, update the call sites that currently register callbacks, and then simplify chains step by step. This keeps the migration incremental and reduces the chance of rewriting working Kafka logic all at once.

It also helps to standardize exception handling. CompletableFuture gives you one place to decide whether failures should be logged, transformed, retried, or propagated.

Common Pitfalls

  • Wrapping ListenableFuture.get() inside supplyAsync, which turns async code into blocking code.
  • Assuming the Kafka consumer polling API should also become a future-returning abstraction.
  • Migrating callbacks without deciding how exceptions should flow through the new chain.
  • Mixing custom executors and common-pool defaults without understanding thread ownership.
  • Replacing the future type while leaving deeply nested callback structure unchanged.

Summary

  • CompletableFuture is usually easier to compose than ListenableFuture in Kafka workflows.
  • The correct migration is callback-based adaptation, not blocking .get() wrappers.
  • Producer send code benefits most directly from the conversion.
  • Consumer migration usually means restructuring downstream processing, not replacing poll() itself.
  • A small adapter utility makes incremental migration safer and clearer.

Course illustration
Course illustration

All Rights Reserved.