Skip to content

Build1 publisher3 min readPublished

Kafka's exactly-once transaction ends where your consumer calls the payment gateway

A dev.to post argues that processing.guarantee=exactly_once_v2 charges coordination overhead to every message and still leaves external side effects for you to deduplicate by hand. The five-line alternative it offers has a gap of its own.

The Engineer · Build desk

Illustration accompanying Kafka's exactly-once transaction ends where your consumer calls the payment gateway

What happened

  • A dev.to post argues that most teams switching on Kafka's exactly-once never needed it, and its author reports reaching for the feature zero times in three years of production consumers.
  • The alternative offered is a handler that returns early when a processed-events table already holds the event ID, calls charge(), then saves the ID under a local database transaction.
  • Kafka documents its guarantees as at-most-once, at-least-once and exactly-once, which the post says looks like a difficulty ladder with exactly-once as the top rung.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Any consumer that charges a card or writes to another datastore still needs its own dedup path after the flag is on, so the mode adds subsystems without retiring the code it was supposed to replace.
  • cost The coordination overhead is billed against every message on the topic, including the ones that would never have been redelivered, and the post does not quantify it, so the figure has to be measured on your own rate before it can be weighed.
  • contradiction The sample handler calls the gateway before committing the row that records the call, so it leaves open the same outside-Kafka window that the post uses to argue against exactly-once.
  • decision The choice on the table is where idempotency is enforced: once in a transport-wide mode, or in each handler that touches an external system, with a stable event ID and a table of what has been seen.

Under `@Transactional`, the handler checks `processed.existsById(event.getId())`, returns early if the row is there, calls `charge(event)`, then saves a `ProcessedEvent` with the same ID [10]. The annotation covers the database rows. It does not cover the gateway. If the process dies after `charge()` returns and before the transaction commits, the row never lands, the redelivery finds an empty table, and the customer pays twice [14].

That is the same boundary the post identifies in `exactly_once_v2`, one layer down. Kafka's guarantee covers read, process and write while everything stays inside Kafka [5]. A payment gateway call, a write to a separate database or an email is outside the transaction, and the post says exactly-once will not protect it [6]. The local dedup table moves that decision into the handler, and the handler still has to carry an idempotency key to the payment provider, or write its intent row before the call and reconcile afterwards. The post presents the pattern as five lines and says the event can arrive ten times with the customer charged once [11].

The performance half of the case is asserted rather than measured. Transactions add coordination overhead on every message, and you pay in throughput and latency, according to the post [3]. The post gives no figure [15]. For that number to decide anything on your topic, you would need the per-message overhead at your actual message rate, set against your latency budget. In my view it is invisible at a few hundred messages a second and starts to matter in the hundreds of thousands.

The complexity half is concrete, and checkable against the docs. Switching the flag on composes three subsystems: an idempotent producer so retries do not create duplicates, transactional writes, and a transactional consumer that ties the read and the write into one unit [4][13]. You inherit all three sets of failure modes, and the post notes that every extra moving part "is a part that can break at 2 a.m." [18]. The dedup table has one failure mode and one growth curve: a row per handled event [17]. Pruning goes unmentioned.

The post's own summary of the trap is that you end up writing the dedup logic anyway: "You paid twice and solved the problem once," its author wrote [7]. That holds for any consumer whose side effects leave Kafka. A stream job whose only output is another Kafka topic is a different case, because there the transaction does cover the write [5]. For that consumer the objection comes down to throughput cost, still unmeasured [15].

The post gets scale right. The discipline is per handler, so a single service and a twelve-service saga need the same property: every step safe to retry [12]. A pipeline-wide mode gives you a guarantee whose edge sits at the topic boundary; a per-handler check gives you one that sits wherever you put the check. The author reports using exactly-once zero times in three years of production Kafka consumers [2].

What to watch

  • A measured comparison of exactly_once_v2 against at-least-once on the same topic and message rate would settle the cost half of the argument.
  • A revised handler that commits the dedup row before calling the gateway, or passes an idempotency key to it, would close the window between the charge and its record.
  • Guidance on pruning the processed-events table, whose size grows with every event handled.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories