<?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>Does finally Run Before return? What javac Actually Does</title>
      <dc:creator>Nazrin Suleymanli</dc:creator>
      <pubDate>Wed, 23 Sep 2026 14:49:47 +0000</pubDate>
      <link>https://dev.to/nazrinsuleymanli/does-finally-run-before-return-what-javac-actually-does-58hd</link>
      <guid>https://dev.to/nazrinsuleymanli/does-finally-run-before-return-what-javac-actually-does-58hd</guid>
      <description>&lt;p&gt;Here's a small Java snippet. Before reading on, decide what it returns:&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="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;a&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&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;If you said &lt;code&gt;99&lt;/code&gt;, you're in good company. I used to think the same thing: "&lt;code&gt;finally&lt;/code&gt; runs before &lt;code&gt;return&lt;/code&gt;, so &lt;code&gt;x&lt;/code&gt; becomes 99 first, then gets returned."&lt;/p&gt;

&lt;p&gt;The actual answer is &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;finally&lt;/code&gt; does run before the method exits. But that's not the same as running before &lt;code&gt;return&lt;/code&gt;. Let's see why.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;return&lt;/code&gt; is two steps, not one
&lt;/h2&gt;

&lt;p&gt;When the JVM executes &lt;code&gt;return x;&lt;/code&gt; inside a &lt;code&gt;try&lt;/code&gt; that has a &lt;code&gt;finally&lt;/code&gt;, it does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Evaluate the expression (&lt;code&gt;x&lt;/code&gt;) and &lt;strong&gt;store the result&lt;/strong&gt; in a hidden local variable&lt;/li&gt;
&lt;li&gt;Run the &lt;code&gt;finally&lt;/code&gt; block&lt;/li&gt;
&lt;li&gt;Return the &lt;strong&gt;stored&lt;/strong&gt; value&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So conceptually, the compiler turns our method into 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="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;a&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 1. value captured: tmp = 1&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 2. finally runs: only x changes&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 3. returns 1&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time &lt;code&gt;finally&lt;/code&gt; touches &lt;code&gt;x&lt;/code&gt;, the return value has already been copied somewhere else. It's no different from:&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// tmp is still 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Don't trust me, trust the bytecode
&lt;/h2&gt;

&lt;p&gt;Compile the class and run &lt;code&gt;javap -c&lt;/code&gt;. The relevant part looks like this (slightly trimmed):&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="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;iconst_1&lt;/span&gt;
 &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;istore_0&lt;/span&gt;      &lt;span class="c1"&gt;// x = 1&lt;/span&gt;
 &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;iload_0&lt;/span&gt;
 &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;istore_1&lt;/span&gt;      &lt;span class="c1"&gt;// tmp = x   &amp;lt;-- the hidden variable (slot 1)&lt;/span&gt;
 &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bipush&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;
 &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;istore_0&lt;/span&gt;      &lt;span class="c1"&gt;// x = 99    &amp;lt;-- finally&lt;/span&gt;
 &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;iload_1&lt;/span&gt;
 &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ireturn&lt;/span&gt;       &lt;span class="c1"&gt;// return tmp&lt;/span&gt;
 &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;astore_2&lt;/span&gt;      &lt;span class="c1"&gt;// exception path starts here&lt;/span&gt;
&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bipush&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;
&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;istore_0&lt;/span&gt;      &lt;span class="c1"&gt;// x = 99    &amp;lt;-- finally, again&lt;/span&gt;
&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;aload_2&lt;/span&gt;
&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;athrow&lt;/span&gt;
&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="nl"&gt;table:&lt;/span&gt;
   &lt;span class="n"&gt;from&lt;/span&gt;  &lt;span class="n"&gt;to&lt;/span&gt;  &lt;span class="n"&gt;target&lt;/span&gt;  &lt;span class="n"&gt;type&lt;/span&gt;
     &lt;span class="mi"&gt;2&lt;/span&gt;    &lt;span class="mi"&gt;4&lt;/span&gt;     &lt;span class="mi"&gt;9&lt;/span&gt;    &lt;span class="n"&gt;any&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;istore_1&lt;/code&gt; copies the value of &lt;code&gt;x&lt;/code&gt; into a separate slot. &lt;code&gt;finally&lt;/code&gt; writes to slot 0 (&lt;code&gt;x&lt;/code&gt;), and &lt;code&gt;ireturn&lt;/code&gt; reads from slot 1. They never meet.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;finally&lt;/code&gt; code (&lt;code&gt;bipush 99; istore_0&lt;/code&gt;) appears &lt;strong&gt;twice&lt;/strong&gt;. There's no "finally instruction" in the JVM. &lt;code&gt;javac&lt;/code&gt; simply copies the finally block in front of every exit point: once for the normal &lt;code&gt;return&lt;/code&gt;, once for the exception path (the &lt;code&gt;any&lt;/code&gt; handler in the exception table).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  With catch, it's the same rule
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;demo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&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;fail&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&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;Roughly desugared:&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="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;demo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&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;fail&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// finally copy #1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 1&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// finally copy #2&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 2&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// finally copy #3&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;;&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;&lt;code&gt;demo(false)&lt;/code&gt; returns &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;demo(true)&lt;/code&gt; returns &lt;code&gt;2&lt;/code&gt;. &lt;code&gt;finally&lt;/code&gt; runs on every path and changes the result on none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Objects behave differently (sort of)
&lt;/h2&gt;

&lt;p&gt;The hidden variable stores whatever &lt;code&gt;return&lt;/code&gt; evaluates to. For objects, that's a &lt;strong&gt;reference&lt;/strong&gt;, not a copy of the object:&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="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;StringBuilder&lt;/span&gt; &lt;span class="nf"&gt;b&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;StringBuilder&lt;/span&gt; &lt;span class="n"&gt;sb&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;StringBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hi"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"!"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// returns "hi!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tmp&lt;/code&gt; and &lt;code&gt;sb&lt;/code&gt; point to the same object, so mutating it in &lt;code&gt;finally&lt;/code&gt; is visible to the caller.&lt;/p&gt;

&lt;p&gt;But reassigning the variable is not:&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="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&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;StringBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"new"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// still returns "hi" (tmp points to the old object)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the same rule as the &lt;code&gt;int&lt;/code&gt; case. Only the variable changes, not what was already captured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that actually bites: &lt;code&gt;return&lt;/code&gt; inside &lt;code&gt;finally&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;c&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"important!"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"finally"&lt;/span&gt;&lt;span class="o"&gt;;&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 returns &lt;code&gt;"finally"&lt;/code&gt;, and the exception &lt;strong&gt;disappears&lt;/strong&gt;. No stack trace, no log, nothing. A &lt;code&gt;return&lt;/code&gt; in &lt;code&gt;finally&lt;/code&gt; overrides both the try's return value and any exception in flight.&lt;/p&gt;

&lt;p&gt;In production code, this means silently swallowed errors. Most static analysis tools flag it (Sonar rule &lt;code&gt;java:S1143&lt;/code&gt;), and you should treat it as a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this matters in real code
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Cleanup that must always happen
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;updateBalance&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// might throw&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unlock&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;If &lt;code&gt;updateBalance()&lt;/code&gt; throws and &lt;code&gt;unlock()&lt;/code&gt; is not in &lt;code&gt;finally&lt;/code&gt;, the lock is never released, and every other thread waiting on it blocks forever. &lt;code&gt;finally&lt;/code&gt; fixes this because javac copies it onto every exit path, as we saw in the bytecode.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. try-with-resources: Java 7's fix for resource leaks
&lt;/h3&gt;

&lt;p&gt;Before Java 7, closing a resource correctly meant writing the &lt;code&gt;finally&lt;/code&gt; yourself:&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="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getConnection&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&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;It was easy to forget, and with several resources, it quickly turned into nested try/finally blocks. Forgotten &lt;code&gt;close()&lt;/code&gt; calls meant leaked connections and file handles.&lt;/p&gt;

&lt;p&gt;Java 7 introduced try-with-resources (and the &lt;code&gt;AutoCloseable&lt;/code&gt; interface), so the compiler writes this for you:&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="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getConnection&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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;Under the hood, it's the same &lt;code&gt;finally&lt;/code&gt; we've been looking at, with the same order of operations:&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 1. result captured while the connection is still open&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// 2. connection closed&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// 3. result returned&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's why returning a query result from inside try-with-resources is safe.&lt;/p&gt;

&lt;p&gt;As a bonus, if both &lt;code&gt;query(c)&lt;/code&gt; and &lt;code&gt;close()&lt;/code&gt; throw, try-with-resources keeps the original exception and attaches the &lt;code&gt;close()&lt;/code&gt; failure to it as a &lt;em&gt;suppressed&lt;/em&gt; exception. A hand-written &lt;code&gt;finally&lt;/code&gt; would let the &lt;code&gt;close()&lt;/code&gt; exception replace the original one.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Beyond a single method: Spring's &lt;code&gt;@PreDestroy&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;finally&lt;/code&gt; only covers the lifetime of one method call. Some resources persist for the lifetime of the application, such as a Kafka producer or a connection pool. Those need to be cleaned up when the application shuts down, not when a method returns.&lt;/p&gt;

&lt;p&gt;Spring handles this with &lt;code&gt;@PreDestroy&lt;/code&gt;. When the application context closes, Spring destroys its beans and calls their &lt;code&gt;@PreDestroy&lt;/code&gt; methods:&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="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;jakarta.annotation.PreDestroy&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Component&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;EventPublisher&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;KafkaProducer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;producer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;...;&lt;/span&gt;

    &lt;span class="nd"&gt;@PreDestroy&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;cleanup&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// send anything still buffered&lt;/span&gt;
        &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&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;Same idea as &lt;code&gt;finally&lt;/code&gt;, "always clean up", just at a different scope: per method vs. per application.&lt;/p&gt;

&lt;p&gt;Note that neither of these runs if the JVM is killed outright (for example, &lt;code&gt;System.exit()&lt;/code&gt; skips &lt;code&gt;finally&lt;/code&gt;, and &lt;code&gt;kill -9&lt;/code&gt; skips everything). "Finally always runs" holds as long as the method actually exits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second interview answer
&lt;/h2&gt;

&lt;p&gt;If you get this question in an interview, here's a compact answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The return value is evaluated and stored before &lt;code&gt;finally&lt;/code&gt; runs, so reassigning a primitive in &lt;code&gt;finally&lt;/code&gt; doesn't affect it. Mutating an object does, because only the reference was captured. Under the hood, javac inlines the finally block before every exit point. And a &lt;code&gt;return&lt;/code&gt; inside &lt;code&gt;finally&lt;/code&gt; overrides everything, including exceptions, which is why it's considered a bug."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In &lt;code&gt;finally&lt;/code&gt; you...&lt;/th&gt;
&lt;th&gt;Effect on the returned value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;reassign a primitive (&lt;code&gt;x = 99&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;none, value already captured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mutate an object (&lt;code&gt;sb.append("!")&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;visible, same object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reassign a reference (&lt;code&gt;sb = new ...&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;none, old reference captured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;return&lt;/code&gt; something&lt;/td&gt;
&lt;td&gt;overrides everything, swallows exceptions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;return&lt;/code&gt; captures its value &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;finally&lt;/code&gt;, and the method exits &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;finally&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the formal version, see &lt;a href="https://docs.oracle.com/javase/specs/jls/se21/html/jls-14.html#jls-14.20.2" rel="noopener noreferrer"&gt;JLS §14.20.2: Execution of try-finally and try-catch-finally&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>java</category>
      <category>jvm</category>
      <category>interview</category>
      <category>programming</category>
    </item>
    <item>
      <title>Local Transactions Aren't Enough: The Dual-Write Problem in Microservices</title>
      <dc:creator>Nazrin Suleymanli</dc:creator>
      <pubDate>Mon, 21 Sep 2026 18:59:08 +0000</pubDate>
      <link>https://dev.to/nazrinsuleymanli/local-transactions-arent-enough-the-dual-write-problem-in-microservices-4o5i</link>
      <guid>https://dev.to/nazrinsuleymanli/local-transactions-arent-enough-the-dual-write-problem-in-microservices-4o5i</guid>
      <description>&lt;p&gt;Our system shared a lot of state with an external BPMN process engine. The engine decided which flow a user should go through, and to make that decision it needed the user and the user type to exist on &lt;em&gt;both&lt;/em&gt; sides: in our database and in the engine.&lt;/p&gt;

&lt;p&gt;That "on both sides" turned out to be harder than it sounds. We'd create a user in our own database, then push it to the engine, and the two would quietly drift apart. Sometimes the engine already had the user and rejected our save with an "already exists" error. Sometimes our write succeeded and the push didn't, so the engine never heard about a user we thought we'd shared. Two systems, no shared transaction, and no reliable answer to a simple question: &lt;em&gt;does the other side actually know about this user?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The dual-write problem
&lt;/h2&gt;

&lt;p&gt;What we hit has a name: the dual-write problem. It shows up any time a single operation has to write to two systems that don't share a transaction. In our case that was our PostgreSQL database and the process engine, but it's the same story with a database and Kafka, a database and a search index, or any two systems you update together.&lt;/p&gt;

&lt;p&gt;Here's why it's unavoidable. A database transaction can only protect writes &lt;em&gt;inside that database&lt;/em&gt;. When your method does two things:&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;@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;createUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;userRepository&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="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// writes to our database&lt;/span&gt;
    &lt;span class="n"&gt;engineClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// writes to the engine&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the &lt;code&gt;@Transactional&lt;/code&gt; annotation only covers the first line. The engine call is a network request to a completely separate system, and it has no idea our database transaction exists. So the two writes can't succeed or fail together. One of them can land while the other doesn't.&lt;/p&gt;

&lt;p&gt;And once you accept that, you realise there's no ordering that saves you. Whichever line you put first, there's a moment between the two where a crash, a timeout, or a network blip leaves the two systems disagreeing. The gap is small, but in production small gaps get hit thousands of times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes that don't work
&lt;/h2&gt;

&lt;p&gt;Before reaching for a pattern, we tried the obvious things. Each one looks reasonable and each one leaves a hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database first, then the engine.&lt;/strong&gt; Save the user locally, then push to the engine. If the engine call fails, we've got a user in our database the engine never heard about. The two are already out of sync, and now we need cleanup logic to find and retry those orphaned users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engine first, then the database.&lt;/strong&gt; Flip the order. Now if our local save fails after the engine already accepted the user, the engine knows about a user our own system has no record of. Same problem, opposite direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrap both and roll back manually.&lt;/strong&gt; "Fine, if the second write fails, I'll undo the first." This is where people write a try/catch that deletes the user when the engine call throws. But the compensating delete can fail too, or the service can crash &lt;em&gt;between&lt;/em&gt; the failed engine call and the cleanup. You're now writing error-handling for your error-handling, and there's still a gap.&lt;/p&gt;

&lt;p&gt;The pattern underneath all three is the same: as long as the two writes are independent, there is always a moment where one has happened and the other hasn't. You can shrink that window, but you can't close it. What we actually need is a way to make "save the user" and "remember to tell the engine" part of the &lt;em&gt;same&lt;/em&gt; transaction, so they can never come apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: the transactional outbox
&lt;/h2&gt;

&lt;p&gt;The idea is small once it clicks. Instead of calling the engine inside your method, you write a row into an &lt;code&gt;outbox&lt;/code&gt; table in your own database, in the same transaction as the user. Two writes, one database, one transaction. They commit together or not at all.&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;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;BIGSERIAL&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;event_type&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;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- e.g. "USER_REGISTERED"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;      &lt;span class="n"&gt;JSONB&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- the user data to send&lt;/span&gt;
    &lt;span class="n"&gt;status&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;20&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_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;p&gt;Now the method changes. There's no engine call here anymore, just two local writes that live or die together:&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;@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;createUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;userRepository&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="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;outboxRepository&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="nf"&gt;OutboxEntry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USER_REGISTERED"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;))&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;That's the whole trick. If this transaction commits, both the user and the intent to notify the engine are safely stored. If anything throws, both roll back, and there's no half-done state to clean up. The engine hasn't been called yet, but we've made a durable promise to call it, and that promise is sitting in the same database as the data it's about.&lt;/p&gt;

&lt;p&gt;The network call still has to happen, of course. It just doesn't happen here. We've turned an unreliable "write to two systems at once" into a reliable "write to one system," and moved the risky part somewhere we can retry it safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it to the engine: the relay
&lt;/h2&gt;

&lt;p&gt;The outbox table is now a to-do list of things that need to reach the engine. Something has to read that list and actually make the calls. That something is the relay.&lt;/p&gt;

&lt;p&gt;The simplest version is polling. A scheduled job wakes up every few seconds, grabs the pending rows, sends each one to the engine, and marks it 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;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&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;publishOutbox&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxEntry&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;outboxRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PENDING"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;engineClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPayload&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SENT"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;outboxRepository&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="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;);&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 what I built first, and it worked. It isn't the fanciest option, but it's easy to reason about and easy to debug, which counts for a lot in production. A more advanced setup would drive the relay from the database changelog instead of polling (change data capture, with a tool like Debezium), and that's the direction I'd take it next. But polling every few seconds was more than enough to keep the two sides in sync, and I'd rather ship the simple version that works than the clever one that might.&lt;/p&gt;

&lt;p&gt;There's one catch, and it's exactly where our original problem came from. The relay is at-least-once: if the service restarts after the engine call but before the row is marked &lt;code&gt;SENT&lt;/code&gt;, that row gets sent again. So the engine gets the same user twice and throws the "already exists" error.&lt;/p&gt;

&lt;p&gt;In a perfect world you'd fix this on the engine side, make it treat a repeat registration as a no-op. But I couldn't touch the engine; it's an external system I don't control. So I handled it on my side instead: the relay treats "already exists" as a success. If the engine says the user is already there, that's fine, that's the state I wanted anyway. I mark the row &lt;code&gt;SENT&lt;/code&gt; and move on.&lt;/p&gt;

&lt;p&gt;It's not the textbook answer, but it's the realistic one when you own only one side of the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you actually need this
&lt;/h2&gt;

&lt;p&gt;The outbox pattern isn't free. You're adding a table, a relay, and a bit of operational surface to keep an eye on. So it's worth being honest about when it earns that cost.&lt;/p&gt;

&lt;p&gt;You need it when two systems must agree and you can't lose the update between them: a user that has to exist on both sides, an order that must reach billing, a record that has to land in another service no matter what. Anywhere a lost write leaves two systems quietly disagreeing, the outbox is the pattern that closes the gap.&lt;/p&gt;

&lt;p&gt;You can skip it when the second write doesn't have to be reliable. If it's fine to miss the occasional update, or you can recompute the other side later, a direct call with some retries is simpler and good enough. Not every integration deserves an outbox, and reaching for it everywhere just moves the complexity around.&lt;/p&gt;

&lt;p&gt;For us it was the right call because the whole system depended on both sides knowing about the same users. Once the outbox was in place, the "already exists" errors and the silent drift just stopped. The two sides stayed in sync, and I stopped getting paged about users that existed in one place but not the other.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>microservices</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Stop Chasing Exactly-Once. Your Kafka Consumer Just Needs to Not Care</title>
      <dc:creator>Nazrin Suleymanli</dc:creator>
      <pubDate>Sun, 20 Sep 2026 14:47:11 +0000</pubDate>
      <link>https://dev.to/nazrinsuleymanli/stop-chasing-exactly-once-your-kafka-consumer-just-needs-to-not-care-1j5m</link>
      <guid>https://dev.to/nazrinsuleymanli/stop-chasing-exactly-once-your-kafka-consumer-just-needs-to-not-care-1j5m</guid>
      <description>&lt;p&gt;Every few months a team I talk to decides they need exactly-once delivery in Kafka. They read the docs, discover &lt;code&gt;processing.guarantee=exactly_once_v2&lt;/code&gt;, wire up transactional producers, and pat themselves on the back for building something bulletproof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most of them never needed it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Exactly-once is one of the most over-reached-for features in event-driven systems. It sounds like the obvious, responsible choice (who &lt;em&gt;wants&lt;/em&gt; duplicates?) but in practice it buys a lot of complexity and performance cost to solve a problem that a much simpler pattern usually solves better. In three years of building Kafka consumers in production, I've reached for it exactly zero times. Here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why exactly-once is so tempting
&lt;/h2&gt;

&lt;p&gt;The pull toward exactly-once is completely understandable. "Duplicates" sounds like a bug, and &lt;em&gt;exactly-once&lt;/em&gt; sounds like the grown-up, production-ready answer. When you're staring at a double-charged customer, a config flag that promises the problem simply won't happen is the most attractive thing in the world.&lt;/p&gt;

&lt;p&gt;There's also the framing in the docs. Kafka lists its guarantees as at-most-once, at-least-once, and exactly-once, and read top to bottom, it looks like a difficulty ladder where exactly-once is the top rung, the "best" one. Nobody wants to build the &lt;em&gt;lesser&lt;/em&gt; system, so teams reach for the rung labelled strongest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guarantee&lt;/th&gt;
&lt;th&gt;Duplicates?&lt;/th&gt;
&lt;th&gt;Data loss?&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;at-most-once&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;td&gt;Cheap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at-least-once&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Cheap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exactly-once&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Expensive, and Kafka-only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;But that ladder is misleading. These aren't "worse" and "better" versions of the same thing, they're different trade-offs, and the cheap middle option is the one most systems actually want.&lt;/p&gt;

&lt;p&gt;And to be fair, the instinct isn't wrong. Duplicates &lt;em&gt;are&lt;/em&gt; a real problem. Left unhandled, they double-charge customers, send emails twice, and corrupt counters. The disagreement isn't about whether duplicates matter. It's about &lt;em&gt;where you solve them&lt;/em&gt;. Exactly-once tries to solve them at the transport layer, before your code ever sees them, and that's where the hidden costs start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost
&lt;/h2&gt;

&lt;p&gt;Exactly-once isn't free, and the price shows up in three places.&lt;/p&gt;

&lt;p&gt;First, performance. Exactly-once in Kafka is built on transactions, and transactions add coordination overhead on every message. You pay for it in throughput and latency. For a lot of systems that's an acceptable trade, but you're paying it whether or not you actually had a duplicate problem worth solving.&lt;/p&gt;

&lt;p&gt;Second, complexity. Turning on &lt;code&gt;exactly_once_v2&lt;/code&gt; isn't a single switch either. Under the hood it's really several moving parts working together: an idempotent producer so retries don't create duplicates, transactional writes, and a transactional consumer to tie the read and write into one unit. You inherit all of that coupling and complexity, and every extra moving part is a part that can break at 2 a.m.&lt;/p&gt;

&lt;p&gt;Third, and this is the one that changes the whole argument: Kafka's exactly-once only works Kafka to Kafka. It covers the read, process, and write cycle when everything stays inside Kafka. The moment your consumer does something to the outside world, calling a payment gateway, writing to a separate database, sending an email, that action is outside the transaction. Exactly-once will not protect it.&lt;/p&gt;

&lt;p&gt;So if you're charging a customer, you still have to make that charge idempotent yourself. Which means you did all the work of setting up exactly-once, and you &lt;em&gt;still&lt;/em&gt; wrote the dedup logic you were trying to avoid. &lt;strong&gt;You paid twice and solved the problem once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;p&gt;Start from the other end. Assume duplicates will happen, because with at-least-once they will, and make your consumer not care.&lt;/p&gt;

&lt;p&gt;In practice that means one thing: make your side effects idempotent. Give every event a stable ID, keep track of the ones you've already handled, and skip anything you've seen before. The charge runs once no matter how many times the event arrives. That's it. No transactions, no coupling, no throughput tax. Just a consumer that shrugs at a redelivery and moves on.&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;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="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;processed&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;getId&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="c1"&gt;// seen it, skip&lt;/span&gt;
    &lt;span class="n"&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="n"&gt;processed&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;getId&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;Five lines. No pipeline-wide mode, no transactional producer, nothing to reason about at 2 a.m. The event can arrive ten times and the customer is charged once.&lt;/p&gt;

&lt;p&gt;The nice part is that this scales down as well as up. A tiny service and a twelve-service saga need the exact same discipline: every step has to be safe to retry. Idempotency is a property you build into each handler, so it composes naturally as your system grows, while exactly-once is a mode you switch on for a whole pipeline and hope nobody steps outside of.&lt;/p&gt;

&lt;p&gt;So my rule of thumb is simple. Reach for idempotency first. It solves the duplicate problem where the duplicates actually cause damage, in your code, right next to the thing that touches the outside world.&lt;/p&gt;

&lt;p&gt;If you want the concrete implementation, with the dedup table and the Spring Boot consumer, I wrote up the full how-to here:&lt;/p&gt;


&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/nazrinsuleymanli/your-kafka-consumer-will-see-every-event-twice-heres-how-to-handle-it-1phk" class="crayons-story__hidden-navigation-link"&gt;Your Kafka Consumer Will See Every Event Twice - Here's How to Handle It&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/nazrinsuleymanli" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4133076%2F54ac8a7b-91dc-40fa-952c-a262e83df24f.jpg" alt="nazrinsuleymanli profile" class="crayons-avatar__image" width="800" height="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/nazrinsuleymanli" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Nazrin Suleymanli
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Nazrin Suleymanli
                
                
              
              &lt;div id="story-author-preview-content-4695007" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/nazrinsuleymanli" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4133076%2F54ac8a7b-91dc-40fa-952c-a262e83df24f.jpg" class="crayons-avatar__image" alt="" width="800" height="800"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Nazrin Suleymanli&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/nazrinsuleymanli/your-kafka-consumer-will-see-every-event-twice-heres-how-to-handle-it-1phk" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Sep 19&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/nazrinsuleymanli/your-kafka-consumer-will-see-every-event-twice-heres-how-to-handle-it-1phk" id="article-link-4695007"&gt;
          Your Kafka Consumer Will See Every Event Twice - Here's How to Handle It
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/java"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;java&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/kafka"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;kafka&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/microservices"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;microservices&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/springboot"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;springboot&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/nazrinsuleymanli/your-kafka-consumer-will-see-every-event-twice-heres-how-to-handle-it-1phk#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;


&lt;h2&gt;
  
  
  When exactly-once actually earns its place
&lt;/h2&gt;

&lt;p&gt;None of this means exactly-once is useless. It's a real feature that solves a real problem, and there's a case where it genuinely fits: pure Kafka to Kafka pipelines with no outside world involved. If you're reading from a topic, transforming, and writing back to another topic, and correctness of the result matters, like a running aggregation or a stateful stream processor, exactly-once is doing exactly what it was designed for. There's no external side effect to make idempotent, so the transactional model covers the whole job.&lt;/p&gt;

&lt;p&gt;The point isn't that the feature is bad. It's that it's a specialized tool, not the default. The moment a real side effect enters the picture, and in most business systems it does almost immediately, idempotency is the pattern carrying the weight, with or without exactly-once on top.&lt;/p&gt;

&lt;p&gt;So before you turn it on, ask one question: does my consumer actually touch anything outside Kafka? If the answer is yes, and it usually is, start with idempotency. Make your consumer not care how many times an event arrives, and the duplicate problem quietly disappears where it was actually going to hurt you.&lt;/p&gt;

</description>
      <category>java</category>
      <category>microservices</category>
      <category>kafka</category>
      <category>springboot</category>
    </item>
    <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>
