<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nazrin Suleymanli</title>
    <description>The latest articles on DEV Community by Nazrin Suleymanli (@nazrinsuleymanli).</description>
    <link>https://dev.to/nazrinsuleymanli</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4133076%2F54ac8a7b-91dc-40fa-952c-a262e83df24f.jpg</url>
      <title>DEV Community: Nazrin Suleymanli</title>
      <link>https://dev.to/nazrinsuleymanli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nazrinsuleymanli"/>
    <language>en</language>
    <item>
      <title>Your Kafka Consumer Will See Every Event Twice - Here's How to Handle It</title>
      <dc:creator>Nazrin Suleymanli</dc:creator>
      <pubDate>Sat, 19 Sep 2026 15:52:30 +0000</pubDate>
      <link>https://dev.to/nazrinsuleymanli/your-kafka-consumer-will-see-every-event-twice-heres-how-to-handle-it-1phk</link>
      <guid>https://dev.to/nazrinsuleymanli/your-kafka-consumer-will-see-every-event-twice-heres-how-to-handle-it-1phk</guid>
      <description>&lt;p&gt;It's 2 a.m., and your on-call phone buzzes. A customer was charged twice for the same order. You dig into the logs - the &lt;code&gt;OrderCreated&lt;/code&gt; event was processed, then processed again seconds later. Nothing crashed. No exception was thrown. Kafka did exactly what it promised: it delivered the message at least once. The bug isn't in Kafka. It's an assumption that your consumer would ever see each event only once.&lt;/p&gt;

&lt;p&gt;In this tutorial, we'll fix that assumption: why duplicates are normal, why the obvious consumer code fails silently, and how to make a Spring Boot Kafka consumer &lt;strong&gt;idempotent&lt;/strong&gt; - safe to run any number of times with the same result as running it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does this happen?
&lt;/h2&gt;

&lt;p&gt;Kafka gives you an &lt;strong&gt;at-least-once&lt;/strong&gt; delivery guarantee. Not exactly once - &lt;em&gt;at least&lt;/em&gt; once. That word "least" is doing a lot of work, and it's the root of our problem.&lt;/p&gt;

&lt;p&gt;Here's the normal flow of a consumer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Kafka hands your consumer a message.&lt;/li&gt;
&lt;li&gt;Your code processes it (charges the customer, writes to the DB).&lt;/li&gt;
&lt;li&gt;Your consumer commits the offset - essentially a bookmark saying "I've handled everything up to here."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trouble lies in the gap between steps 2 and 3. If your service crashes, is redeployed, or the consumer group rebalances after the work is done but before the offset is committed, Kafka has no idea step 2 ever happened. So when the consumer restarts, it reads from the last committed offset and delivers that same &lt;code&gt;OrderCreated&lt;/code&gt; event again.&lt;/p&gt;

&lt;p&gt;Think of the offset as a bookmark in a book. You read a page (process the event), then move the bookmark forward (commit the offset). Now imagine your phone dies after you read the page but before you move the bookmark. When you reopen the book, you start from the old bookmark and read that page again.&lt;/p&gt;

&lt;p&gt;Nothing is broken. Kafka is doing exactly what it promised. The double charge comes from our code assuming each event arrives once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rebalancing makes this worse in production.&lt;/strong&gt; Every deploy, every scale-up, every pod restart in Kubernetes triggers a rebalance, so the more you scale, the more often you hit this window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach (and why it fails)
&lt;/h2&gt;

&lt;p&gt;Most consumers start out looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"billing-service"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handleOrderCreated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderCreatedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Charged customer {} for order {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrderId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is clean, readable, and works perfectly in every demo. Ship it, and it will happily process thousands of orders without complaint.&lt;/p&gt;

&lt;p&gt;Then one day, a pod restarts mid-processing, Kafka redelivers the last event, and &lt;code&gt;paymentService.charge(...)&lt;/code&gt; runs a second time. There's no error, no stack trace, just a customer with two charges and a support ticket.&lt;/p&gt;

&lt;p&gt;The reason this is so easy to miss is that the code is correct in isolation. The bug isn't in any single line. It's in the assumption baked into the whole method: that it will run only once per order. Kafka never promised that.&lt;/p&gt;

&lt;p&gt;So the fix isn't "handle the exception" or "add a try/catch." There's nothing to catch. The fix is to make the operation &lt;strong&gt;idempotent&lt;/strong&gt;, so it is safe to run any number of times with the same result as running it once.&lt;/p&gt;

&lt;p&gt;A quick way to picture idempotency: pressing an elevator call button once and pressing it five times gives the same result. The elevator still comes once. A light switch is not idempotent: every press changes the state. Our &lt;code&gt;charge()&lt;/code&gt; method is currently a light switch. We're going to turn it into an elevator button.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: an idempotency key + a dedup store
&lt;/h2&gt;

&lt;p&gt;The idea is to give every event a stable identity and remember which ones we've already handled. Kafka can redeliver all it wants. We just refuse to act on the same ID twice.&lt;/p&gt;

&lt;p&gt;One thing to get right before any code: the event ID must be a stable ID that travels in the payload (an &lt;code&gt;eventId&lt;/code&gt; or &lt;code&gt;orderId&lt;/code&gt; that the producer sets), not a &lt;code&gt;UUID.randomUUID()&lt;/code&gt; you generate at the time of consumption. If you mint a fresh ID on each consume, every redelivery looks "new" and the whole scheme is pointless.&lt;/p&gt;

&lt;p&gt;First, a table whose only job is to remember processed events. The &lt;code&gt;event_id&lt;/code&gt; is the primary key, so the database itself guarantees we can never store the same one twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;     &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"processed_events"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessedEvent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;processedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// constructors, getters&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the consumer. Two things must happen together, or not at all: recording the event as processed, and doing the actual work. So we wrap them in a single &lt;code&gt;@Transactional&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"billing-service"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handleOrderCreated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderCreatedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processedEvents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEventId&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Duplicate event {} — skipping"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEventId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;processedEvents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEventId&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the single transaction matters: the charge and the "I've processed this" marker now commit as one unit. If &lt;code&gt;charge()&lt;/code&gt; throws halfway through, the transaction rolls back the marker as well, so Kafka will redeliver and we'll retry cleanly. And if a rare concurrent duplicate slips past the &lt;code&gt;existsById&lt;/code&gt; check, the primary key on &lt;code&gt;event_id&lt;/code&gt; rejects the second insert, the transaction fails, and no second charge is committed.&lt;/p&gt;

&lt;p&gt;So there are really two layers here: the &lt;code&gt;existsById&lt;/code&gt; check is for speed (skip the obvious duplicates cheaply), and the primary key is for correctness (the database is the last line of defense when two duplicates race). They work together.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Redis?
&lt;/h2&gt;

&lt;p&gt;Redis is a popular, faster alternative - a single atomic &lt;code&gt;SET billing:event:&amp;lt;eventId&amp;gt; 1 NX EX 86400&lt;/code&gt; does the "write only if absent" check (&lt;code&gt;NX&lt;/code&gt;) and auto-expires the key after a day (&lt;code&gt;EX&lt;/code&gt;). But Redis can't join your database transaction: if you mark the event seen in Redis and then the DB write fails, you're left with a "processed" marker and no work done, and the redelivery gets skipped too. So use Redis when the side effect isn't critical (send a notification once, dedupe analytics), and keep the event ID in the same database as your work when correctness really matters, like billing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases worth knowing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Offset commit strategy
&lt;/h3&gt;

&lt;p&gt;A nice consequence of the dedup store: you no longer need heroics around offset committing. Even if the offset commit fails after your transaction commits, the redelivered event just hits the dedup check and gets skipped.&lt;/p&gt;

&lt;p&gt;That said, don't leave Spring Kafka on naive auto-commit (&lt;code&gt;enable.auto.commit=true&lt;/code&gt;), which commits offsets on a timer regardless of whether your work has finished. If the consumer dies after a commit but before the work completes, the event is lost. Instead, commit after the listener succeeds. The simplest way is &lt;code&gt;AckMode.RECORD&lt;/code&gt;: Spring commits each record's offset automatically when the listener method returns without throwing an exception, with no extra code on your part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContainerProperties&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setAckMode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AckMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RECORD&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want finer control, switch to manual acks (&lt;code&gt;AckMode.MANUAL_IMMEDIATE&lt;/code&gt;) and acknowledge explicitly at the point you decide the work is done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"billing-service"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderCreatedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Acknowledgment&lt;/span&gt; &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// dedup check + charge ...&lt;/span&gt;
    &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acknowledge&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// only now: "this offset may be committed"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way, the principle is the same: commit the offset after the work, never before. The dedup store is your safety net against redelivery, not an excuse to commit early.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dedup table grows forever
&lt;/h3&gt;

&lt;p&gt;Duplicates only ever arrive within a short window (a rebalance, a restart), so you don't need to keep event IDs around for months. Add a small scheduled job that deletes rows older than a few days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Duplicates on the producing side
&lt;/h3&gt;

&lt;p&gt;This tutorial fixes the consumer. If your service also publishes events while writing to its own database, you have a related-but-separate problem: the DB write can succeed while the Kafka publish fails, or vice versa. The standard fix there is the transactional outbox pattern: write the event to an outbox table in the same transaction as your data, and relay it to Kafka separately. Worth a follow-up read once the consumer side is solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need this and when you don't
&lt;/h2&gt;

&lt;p&gt;Reach for a dedup store whenever the side effect isn't naturally idempotent: charging money, sending an email, incrementing a counter, or calling an external API that performs an action. These are the operations that hurt when they run twice.&lt;/p&gt;

&lt;p&gt;You may not need one when the operation is inherently idempotent. If your consumer just sets the absolute state &lt;code&gt;UPDATE orders SET status = 'PAID' WHERE id = ?&lt;/code&gt; running it twice changes nothing the second time. In those cases, making the write itself idempotent (an upsert keyed by ID, setting absolute rather than relative values) can be enough on its own.&lt;/p&gt;

&lt;p&gt;The mental shift is the whole point: stop trying to make Kafka deliver exactly once; it won't. Instead, make your consumer not care how many times it's called. Once each event carries a stable ID and your side effects are idempotent, that 2 a.m. double charge simply can't happen. The redelivery still arrives. Your code just shrugs and moves on.&lt;/p&gt;

</description>
      <category>java</category>
      <category>kafka</category>
      <category>microservices</category>
      <category>springboot</category>
    </item>
  </channel>
</rss>
