<?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: Denis Toropov</title>
    <description>The latest articles on DEV Community by Denis Toropov (@denis_toropov_41dbbe80185).</description>
    <link>https://dev.to/denis_toropov_41dbbe80185</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951276%2F81e2d775-315a-4bbf-b424-898ec9b14e2f.png</url>
      <title>DEV Community: Denis Toropov</title>
      <link>https://dev.to/denis_toropov_41dbbe80185</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/denis_toropov_41dbbe80185"/>
    <language>en</language>
    <item>
      <title>Transactional Outbox with Kafka: How to Stop Losing Events When Syncing Databases</title>
      <dc:creator>Denis Toropov</dc:creator>
      <pubDate>Thu, 11 Jun 2026 19:07:00 +0000</pubDate>
      <link>https://dev.to/denis_toropov_41dbbe80185/transactional-outbox-with-kafka-how-to-stop-losing-events-when-syncing-databases-4nbm</link>
      <guid>https://dev.to/denis_toropov_41dbbe80185/transactional-outbox-with-kafka-how-to-stop-losing-events-when-syncing-databases-4nbm</guid>
      <description>&lt;h1&gt;
  
  
  Transactional Outbox with Kafka: How to Stop Losing Events When Syncing Databases
&lt;/h1&gt;

&lt;p&gt;When you sync data between services (or databases) through Kafka, the classic failure looks like this: &lt;strong&gt;the database transaction commits, but the Kafka message never gets published&lt;/strong&gt; (crash, network issue, timeout). Your systems diverge silently, and you only discover it when a user reports incorrect data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious fixes don’t work
&lt;/h2&gt;

&lt;p&gt;Wrapping Kafka send in retries doesn’t guarantee delivery if the process dies after the DB commit. Sending to Kafka &lt;em&gt;before&lt;/em&gt; committing the DB creates “phantom events” about changes that never made it into the database. Two-phase commit is usually too complex operationally and doesn’t fit Kafka in a clean, universal way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key idea
&lt;/h2&gt;

&lt;p&gt;Make the critical operation &lt;strong&gt;one atomic DB transaction&lt;/strong&gt;: write the business data &lt;em&gt;and&lt;/em&gt; the event to an &lt;code&gt;outbox&lt;/code&gt; table in the same commit. If the update exists, the event exists too.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;In one DB transaction: update domain tables + &lt;code&gt;INSERT&lt;/code&gt; into &lt;code&gt;outbox&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Later: a separate mechanism reads &lt;code&gt;outbox&lt;/code&gt; and publishes to Kafka&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This makes delivery retryable because the event is durably stored&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two ways to deliver events from the outbox
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Polling Relay
&lt;/h3&gt;

&lt;p&gt;A background worker polls unsent rows (&lt;code&gt;sent_at IS NULL&lt;/code&gt;), publishes them to Kafka, then marks them as sent. It’s simple, reliable, and often enough when sub-second latency isn’t a strict requirement. If you run multiple workers, you must ensure each event is claimed once (typically via row locking / “skip locked” patterns).&lt;/p&gt;

&lt;h3&gt;
  
  
  2) CDC via Debezium
&lt;/h3&gt;

&lt;p&gt;Instead of polling, Debezium streams inserts from the database transaction log (WAL) to Kafka. This reduces latency and removes the need for a poller, but adds infrastructure/ops complexity (Debezium + Kafka Connect).&lt;/p&gt;

&lt;h2&gt;
  
  
  Don’t forget: duplicates will happen
&lt;/h2&gt;

&lt;p&gt;Outbox pipelines are typically &lt;strong&gt;at-least-once&lt;/strong&gt;, so consumers must be idempotent. The most robust approach is an &lt;code&gt;inbox&lt;/code&gt; table with a &lt;strong&gt;unique constraint on &lt;code&gt;event_id&lt;/code&gt;&lt;/strong&gt;: first insert the event id; if it already exists, skip processing. This avoids race conditions that occur with “check then insert”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;If you run the relay inside every API instance, scaling your API can accidentally scale polling load and hammer the DB. Also, outbox/inbox tables will grow—plan retention/cleanup. Finally, monitor lag: the count of unsent events and the age of the oldest unsent event are simple, high-signal metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Transactional Outbox&lt;/strong&gt; prevents “DB committed but event lost” by making event creation part of the DB transaction, then reliably delivering events to Kafka via polling or CDC—while consumers protect themselves with idempotency.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>distributedsystems</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
