Hello! Welcome to the next lesson in our module on Event-Driven Architecture with Kafka.
In our last session, we explored the different message delivery semantics and concluded that whether you choose Kafka's default "at-least-once" guarantee or the more complex "exactly-once" semantics, your consumer application must be prepared to handle duplicate messages. This is a critical aspect of building robust distributed systems.
Today, we move from the "why" to the "how." Your learning outcome is to implement an idempotent Kafka consumer to safely process duplicate messages. We will cover the core patterns and practical code examples you can use to make your services resilient to message duplication. Mastering this is a key differentiator in interviews for mid-to-senior roles, as it demonstrates a deep understanding of real-world system reliability.
1. The Inevitability of Duplicates
Before we dive into solutions, let's quickly recap why consumer-side idempotency is essential. Even with a perfectly configured producer, duplicates can appear in your system due to:
- Consumer Crashes: The most common cause. A consumer processes a message (e.g., writes to a database) but crashes before it can commit the offset. The next consumer to start will receive that message again.
- Consumer Group Rebalancing: When consumers join or leave a group, partitions are reassigned. This can sometimes cause the last few messages to be re-processed.
- Network Timeouts and Retries: A consumer might process a message but fail to commit the offset due to a network issue. The broker, not seeing the commit, will redeliver the message.
- Manual Replay: Sometimes, for operational reasons, you might need to "replay" messages from an earlier offset, which will naturally introduce duplicates of already-processed messages.
The core principle of idempotency is that an operation can be performed multiple times with the same result as if it were performed only once. Our goal is to design a consumer that upholds this principle.
2. The Core Pattern: Track, Check, and Skip
The fundamental logic behind any idempotent consumer is simple.

To implement this, we need two key components:
- A Unique Message Identifier: Every message must contain a unique key that identifies the business operation. This could be a UUID generated by the producer, an order ID, or a transaction ID. This ID must be stable across any retries.
- A Persistent Store: We need a reliable place to store the identifiers of messages we have already successfully processed.
The consumer's logic then becomes:
- Begin a transaction.
- Extract the unique message ID.
- Attempt to record this ID in our persistent store.
- If the recording succeeds, it's a new message. Proceed with the business logic.
- If the recording fails because the ID already exists, it's a duplicate. Skip the business logic.
- Commit the transaction.
Let's explore the most common ways to implement this pattern.
3. Pattern 1: Database-backed Idempotency
When your consumer's primary job is to write to a database, the database itself is the most reliable place to manage idempotency. This approach gives you strong transactional guarantees.

Let's see how to implement this in a Spring Boot application. We'll build a simple IdempotencyService that can be injected into any Kafka consumer.
Building Idempotent Microservices in Spring Boot
The following article, 'Building Idempotent Microservices in Spring Boot', provides a clean, step-by-step guide to creating a database-backed idempotency check. It aligns perfectly with your background in Spring Boot and JPA.
Please read the article from 'Step 1: Add an idempotency_key' through 'Step 5: Integrate with Kafka / Messaging'. Pay close attention to the code for the ProcessedEvent entity, the ProcessedEventRepository, the IdempotencyService, and finally, how it's used within the @KafkaListener.
Let's summarize the key components from the article into a complete, interview-ready example.
Step 1: The Event Payload
Your Kafka message payload must contain a unique key.
// Example: An event representing an order placement
public class OrderPlacedEvent {
private UUID eventId; // Our unique idempotency key
private String orderId;
private BigDecimal amount;
// Getters and setters
}
Step 2: The Database Table and JPA Entity
You need a table to store the processed event IDs. A UNIQUE constraint on the idempotency key is crucial.
CREATE TABLE processed_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
idempotency_key VARCHAR(36) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
The corresponding JPA entity:
@Entity
@Table(name = "processed_events")
public class ProcessedEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String idempotencyKey;
@CreationTimestamp
private LocalDateTime createdAt;
// Constructors, etc.
}
Step 3: The Idempotency Service
This service encapsulates the logic. We separate the "check" from the "mark" to handle transaction boundaries correctly.
@Service
@RequiredArgsConstructor
public class IdempotencyService {
private final ProcessedEventRepository repository;
// We make this transactional to ensure the save is committed.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markProcessed(String key) throws DataIntegrityViolationException {
// This will throw an exception if the key already exists due to the UNIQUE constraint.
repository.save(new ProcessedEvent(key));
}
}
Note: Using Propagation.REQUIRES_NEW creates a new, independent transaction for saving the key. This is an advanced topic, but in short, it ensures that even if the main consumer transaction rolls back later, the fact that we saw this event key can still be committed. A simpler approach is to perform the check and the business logic within the same transaction.
Step 4: The Idempotent Kafka Consumer
Finally, we tie it all together in our @KafkaListener.
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderConsumer {
private final IdempotencyService idempotencyService;
private final OrderRepository orderRepository;
@KafkaListener(topics = "orders", groupId = "order-processor")
@Transactional // The whole method is one database transaction
public void handleOrderPlacedEvent(OrderPlacedEvent event) {
try {
// 1. First, try to claim the event ID
idempotencyService.markProcessed(event.getEventId().toString());
// 2. If no exception, it's a new event. Process it.
log.info("Processing new event: {}", event.getEventId());
Order order = new Order(event.getOrderId(), event.getAmount());
orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
// 3. If we get this exception, the key already exists. It's a duplicate.
log.warn("Skipping duplicate event: {}", event.getEventId());
}
}
}
This pattern is robust, transactional, and easy to reason about, making it an excellent choice for most use cases involving a database.
4. Pattern 2: High-Throughput Deduplication with Redis
For very high-volume topics where hitting a relational database for every single message could become a performance bottleneck, using an in-memory store like Redis is a great alternative.
Building Reliable Event-Driven Architectures with Kafka
The article 'Building Reliable Event-Driven Architectures with Kafka' demonstrates this alternative approach.
Read just the short section '7.5 Redis-Based Fast Deduplication'. Focus on the SETNX command and the use of a TTL.
The logic relies on Redis's atomic SETNX (SET if Not eXists) command.
- The Logic: You attempt to set a key in Redis using the message's unique ID.
SETNXwill only succeed if the key does not already exist. - TTL is Critical: You must set a Time-To-Live (TTL) on the key. This ensures Redis doesn't fill up with event IDs forever. The TTL should be chosen based on the maximum expected time window in which a duplicate might arrive (e.g., a few hours or a day).
Here is a conceptual Java example using a library like Jedis or Lettuce:
public void processWithRedis(String messageId, Runnable businessLogic) {
// The key in Redis will be something like "processed_events:some_unique_id"
String redisKey = "processed_events:" + messageId;
// Attempt to set the key if it doesn't exist, with an expiry of 1 hour (3600 seconds)
Boolean wasSet = redisTemplate.opsForValue().setIfAbsent(redisKey, "processed", 1, TimeUnit.HOURS);
if (Boolean.TRUE.equals(wasSet)) {
// Key was set, so this is the first time we see this message
log.info("Processing new event with Redis lock: {}", messageId);
businessLogic.run();
} else {
// Key already existed, this is a duplicate
log.warn("Skipping duplicate event with Redis lock: {}", messageId);
}
}
Trade-offs:
- Pro: Extremely fast, low latency.
- Con: Less durable than a database. If Redis fails and loses data, you might re-process some messages. This is often an acceptable risk for the performance gain.
5. Handling External API Calls
What if your consumer's side effect isn't a database write, but a call to an external API (e.g., a payment gateway or a notification service)? Your idempotency check must coordinate with that service.
Many well-designed APIs support this via an Idempotency-Key HTTP header.
Building Reliable Event-Driven Architectures with Kafka
Let's revisit the first article to see how this works.
Read the section '7.4 External API Calls with Idempotency Keys'. This is a short but important concept for system design interviews.
The flow is:
- Your consumer receives a Kafka message with a unique
eventId. - When making the POST request to the external API, you pass this
eventIdin theIdempotency-Keyheader. - The external API is responsible for storing this key and ensuring that subsequent requests with the same key do not result in a duplicate action (e.g., it won't charge a credit card twice).
This demonstrates your awareness of end-to-end system design, a crucial skill for senior roles.
Test your understanding!
You are designing a system that processes events from a Kafka topic. For each of the following scenarios, which idempotency strategy (Database-backed or Redis-backed) would you choose and why?
- Order Service: Consumes
OrderPaidevents and updates theorderstable in a PostgreSQL database to set the status toPAID. Consistency is the absolute top priority. - Notification Service: Consumes
UserLoggedInevents and sends a real-time push notification to the user's device. The service handles millions of logins per hour, and it's acceptable if, in a rare failure scenario, a user gets a duplicate login notification.
Show answer
-
Order Service: The clear choice is the Database-backed strategy.
- Justification: The business logic (updating the order status) and the idempotency check both involve the same PostgreSQL database. By wrapping the
INSERTinto theprocessed_eventstable and theUPDATEon theorderstable in a single@Transactionalblock, we get atomic consistency. If either part fails, the whole operation rolls back. This is the most robust way to prevent inconsistent states, such as marking an event as processed without actually updating the order.
- Justification: The business logic (updating the order status) and the idempotency check both involve the same PostgreSQL database. By wrapping the
-
Notification Service: The best choice here is the Redis-backed strategy.
- Justification: The primary requirements are high throughput and low latency. Hitting a relational database for millions of events per hour would likely create a bottleneck. Redis's in-memory
SETNXoperation is orders of magnitude faster. The business impact of a duplicate notification is low, so the slightly lower durability of Redis (compared to a disk-backed RDBMS) is an acceptable trade-off for the massive performance gain.
- Justification: The primary requirements are high throughput and low latency. Hitting a relational database for millions of events per hour would likely create a bottleneck. Redis's in-memory
Conclusion
You've now learned the practical techniques required to build resilient Kafka consumers that can safely handle the duplicate messages inherent in distributed systems.
Key Takeaways:
- Idempotent consumers are a necessity when using "at-least-once" delivery, which is the default and most common choice in Kafka.
- The core pattern is to use a unique message ID and a persistent store to track processed messages.
- For most applications, a database-backed approach offers the best combination of reliability and transactional consistency, especially when the business logic already involves that database.
- For high-throughput, low-latency scenarios where absolute durability is less critical, a Redis-backed approach using
SETNXand a TTL is a powerful alternative. - When interacting with external systems, leverage the
Idempotency-Keyheader pattern to extend idempotency guarantees across service boundaries.
Preview of the Next Lesson:
We've just seen how Redis can be used for high-performance deduplication. However, the most common use of Redis in modern architectures is for caching. In the next lesson, we will explore this topic in depth: "Compare caching strategies (e.g., cache-aside, write-through) and implement caching with Spring Cache and Redis." This will build directly on your introduction to Redis today.