<?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: Dev Bilaspure</title>
    <description>The latest articles on DEV Community by Dev Bilaspure (@devbilaspure).</description>
    <link>https://dev.to/devbilaspure</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%2F3986311%2F21a4c1b6-8068-4630-bbac-a92fd757802f.png</url>
      <title>DEV Community: Dev Bilaspure</title>
      <link>https://dev.to/devbilaspure</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devbilaspure"/>
    <language>en</language>
    <item>
      <title>My First Go + Kafka Project, and What It Actually Taught Me</title>
      <dc:creator>Dev Bilaspure</dc:creator>
      <pubDate>Mon, 17 Aug 2026 06:55:35 +0000</pubDate>
      <link>https://dev.to/devbilaspure/i-built-a-webhook-delivery-system-to-learn-go-and-kafka-heres-what-actually-taught-me-something-1aj</link>
      <guid>https://dev.to/devbilaspure/i-built-a-webhook-delivery-system-to-learn-go-and-kafka-heres-what-actually-taught-me-something-1aj</guid>
      <description>&lt;p&gt;&lt;em&gt;In this post: what building a reliable webhook delivery system in Go and Kafka, my first project with both, actually taught me. Skip to What Kafka taught me if you want the meatier part.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A few weeks ago I got tired of reading about Go and Kafka and decided to just build something with both. Not a todo app. Something with real failure modes baked into the requirements from day one.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/Dev-Bilaspure/webhook-delivery" rel="noopener noreferrer"&gt;&lt;code&gt;webhook-delivery&lt;/code&gt;&lt;/a&gt;: a service that accepts webhook events over HTTP and reliably delivers them to customer endpoints. Retries, exponential backoff, a dead-letter queue, per-endpoint ordering, idempotency, concurrency limits, per-host circuit breaking.&lt;/p&gt;

&lt;p&gt;This post isn't the happy-path story. It's the moments where my mental model was flat out wrong and the system, or the benchmark numbers, had to correct me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why webhook delivery
&lt;/h2&gt;

&lt;p&gt;On the surface it's "accept a POST, forward a POST." But say the word "reliably" and you inherit a whole distributed systems curriculum for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What happens when the customer's endpoint is down?&lt;/li&gt;
&lt;li&gt;What happens when it's slow instead of down?&lt;/li&gt;
&lt;li&gt;What happens when two events for the same customer arrive out of order?&lt;/li&gt;
&lt;li&gt;What happens when your own process crashes mid-delivery?&lt;/li&gt;
&lt;li&gt;How do you even know if a delivery failed, versus the response just got lost?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these have a clean answer. That's exactly what I wanted to sit inside for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the system
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /events ─► API ─► events ─► delivery workers ─► POST to endpoint
                                       │ ok   → commit
                                       └ fail → retries ─► retry worker ─► waits, redelivers
                                                              │ exhausted / bad data → dead-letter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API validates the request, publishes to a Kafka topic (&lt;code&gt;events&lt;/code&gt;), returns &lt;code&gt;202&lt;/code&gt;. Delivery workers consume that topic, group messages by an &lt;code&gt;orderingKey&lt;/code&gt; so a customer's events stay in order, and POST them out. Failures go to a &lt;code&gt;retries&lt;/code&gt; topic with exponential backoff. Permanent failures and events past a max age go straight to a dead-letter queue.&lt;/p&gt;

&lt;p&gt;That paragraph took about three weeks to actually get right. Almost all the real learning happened in the gap between "this looks correct" and "this is correct."&lt;/p&gt;

&lt;h2&gt;
  
  
  What Go taught me
&lt;/h2&gt;

&lt;p&gt;I'd read about goroutines and channels before. I hadn't had to reach for them under pressure, and that's a different kind of understanding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Channels as semaphores.&lt;/strong&gt; A &lt;code&gt;chan struct{}&lt;/code&gt; with capacity N is a free, composable concurrency limiter. I needed two levels of it, a global cap on in-flight deliveries and a per-host cap so one flaky endpoint couldn't eat the whole pool, and both turned out to be the same primitive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}{}&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&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;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;()&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;The part that didn't click until I wrote a test for it: drop the &lt;code&gt;ctx.Done()&lt;/code&gt; branch and a goroutine can block forever on a saturated semaphore, even after the parent context is cancelled. Nothing wakes it up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestDeliverGroupUnblocksFromSaturatedSemaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// ... a full host semaphore, then cancel the context ...&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deliverGroup returned nil for an undelivered group"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deliverGroup is stuck acquiring a saturated semaphore after cancellation"&lt;/span&gt;&lt;span class="p"&gt;)&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;&lt;strong&gt;Takeaway:&lt;/strong&gt; if a blocking channel op doesn't have a &lt;code&gt;ctx.Done()&lt;/code&gt; escape hatch, it's not a semaphore, it's a deadlock waiting for a bad day.&lt;/p&gt;

&lt;p&gt;The rest of the Go side was less about discovering new concepts and more about discovering how much they matter under real conditions. A few worth naming quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nested locking needs &lt;code&gt;defer&lt;/code&gt; discipline.&lt;/strong&gt; The delivery path has a per-host semaphore, a global semaphore, and a mutex around breaker state, all nested. Writing that as a flat sequence of &lt;code&gt;Lock&lt;/code&gt;/&lt;code&gt;Unlock&lt;/code&gt; with early returns is how you leak a lock. Wrapping each critical section in its own function literal gives &lt;code&gt;defer&lt;/code&gt; a clean scope, and I use this pattern everywhere now:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;hostChan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;perHostSem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hostChan&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;hostChan&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
  &lt;span class="p"&gt;}();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Interfaces make Kafka disappear from your test suite.&lt;/strong&gt; &lt;code&gt;Worker&lt;/code&gt; depends on a one-method &lt;code&gt;Publisher&lt;/code&gt; interface, not &lt;code&gt;*kafka.Producer&lt;/code&gt;. Every test uses a &lt;code&gt;recordingPublisher&lt;/code&gt; that just appends to a slice. That's the entire reason 45 tests run in about 3 seconds with &lt;code&gt;-race&lt;/code&gt; on and no Docker container in sight.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;errors.As&lt;/code&gt; over string matching.&lt;/strong&gt; Classifying a failure as retryable or permanent needed to see through wrapped errors:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;  &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;IsPermanent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;statusErr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;StatusError&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;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;statusErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;statusErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;statusErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;statusErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusRequestTimeout&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;statusErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusTooManyRequests&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small thing, but it's the difference between code that breaks the moment someone wraps the error with &lt;code&gt;fmt.Errorf("...: %w", err)&lt;/code&gt; and code that doesn't care how many layers of wrapping happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Kafka actually taught me
&lt;/h2&gt;

&lt;p&gt;This is where most of my wrong assumptions lived. Kafka's mental model is genuinely different from a queue, and I hit that difference in ways that only showed up under load.&lt;/p&gt;

&lt;h3&gt;
  
  
  An offset commit is a watermark, not a checklist
&lt;/h3&gt;

&lt;p&gt;The single biggest "oh" moment of the project. I assumed committing an offset meant "these specific messages are done," like checking items off a list. It doesn't. It means "everything up to and including this position is done."&lt;/p&gt;

&lt;p&gt;That distinction seems pedantic until you try committing per-key-group as each group finishes, which I did, on a bad first draft. Here's the failure mode: key A's group finishes fast, key B's group is sitting right behind it in the same batch and still retrying. Commit A's high offset and you've silently marked B's still-in-flight messages as done too, because the offset is a single number, not a set.&lt;/p&gt;

&lt;p&gt;My README has a note to self about this, because I don't trust myself to remember it on the next project:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The obvious fix for the batch barrier, committing per group as it finishes, is wrong. A Kafka offset commit is a watermark, not a set, so committing a fast group's highest offset marks a slower group's earlier messages as done while they are still in flight.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The actual design commits a whole batch only once every message in it reaches a terminal state. That has a real cost, the batch runs at the speed of its slowest destination, but at least it's not lying about what's durable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if you're building anything on Kafka that needs partial-batch semantics, assume the naive per-item commit is wrong until proven otherwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partition keys give you ordering, but only per key, and it's brittle
&lt;/h3&gt;

&lt;p&gt;Kafka guarantees order within a partition, and hashing the same key to the same partition consistently is what makes "order per customer" work as "use the customer as the partition key." Sounds straightforward. What I underestimated was how much care that guarantee then demands downstream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If one message in a customer's group fails, every message behind it in that batch has to be deferred too, or a later event delivers before an earlier one that's still retrying.&lt;/li&gt;
&lt;li&gt;The consumer batch fetch has to preserve order within a group when replaying it.&lt;/li&gt;
&lt;li&gt;None of this is enforced by Kafka. It's enforced by discipline in your own worker loop.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"endpoint received %d requests, want 1; messages behind the failure were delivered out of order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received&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;That assertion message is a note to my future self about a bug I actually shipped once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batching is invisible until it's the whole bottleneck
&lt;/h3&gt;

&lt;p&gt;I loaded the API at increasing concurrency and got a suspiciously perfect linear relationship: 20 concurrent submitters, 20 events/sec accepted. 60 submitters, 60/sec. That's not what a resource limit looks like, real capacity limits are noisy and plateau. A perfectly linear number is the signature of a fixed per-request delay.&lt;/p&gt;

&lt;p&gt;The cause: &lt;code&gt;kafka-go&lt;/code&gt;'s writer flushes a batch on &lt;code&gt;BatchSize&lt;/code&gt; or &lt;code&gt;BatchTimeout&lt;/code&gt;, whichever comes first, and &lt;code&gt;BatchTimeout&lt;/code&gt; defaults to one second. My API handler publishes exactly one message and blocks on the ack. The batch never filled by size, so every publish sat there for the full second before Kafka even saw it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;accept rate, concurrency 20:  20/s  →  1,350/s
accept rate, concurrency 60:  60/s  →  4,043/s
first-attempt p50:          1,085ms →  16ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One config line (&lt;code&gt;PRODUCER_BATCH_TIMEOUT=10ms&lt;/code&gt;) fixed a 67x throughput ceiling. &lt;code&gt;RequiredAcks: RequireAll&lt;/code&gt; and a synchronous write path meant durability was never in question, so this was purely a batching knob I didn't know existed. I would not have found it by reading the code. The code looked fine. It only showed up because the numbers were suspiciously round.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consumer groups don't coordinate with each other
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;events&lt;/code&gt; and &lt;code&gt;retries&lt;/code&gt; are drained by two independent consumer groups. That's the right design, but it means there's no ordering guarantee across the boundary between "things failing" and "things recovering." When a circuit breaker's cooldown ends and a bad host comes back healthy, fresh events sail straight through &lt;code&gt;events&lt;/code&gt; while much older events are still waiting out their backoff in &lt;code&gt;retries&lt;/code&gt;. The backoff ladder schedules the oldest deferred messages the furthest out, and the outage guarantees a backlog exists at exactly the moment direct delivery resumes.&lt;/p&gt;

&lt;p&gt;So the inversion isn't a bug to fix. It's a structural property of two independently scheduled paths. I measured it instead of asserting it away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chaos   inversions: 8 (range 0-34) across 400 keys, when hosts flap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test demanding zero here would be testing the wrong thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The connection pool is not sized for you
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;http.DefaultTransport&lt;/code&gt; defaults to 2 idle connections per host. My delivery worker was configured for a concurrency of 5 per host. Every request past the second one on a given host tore down its TCP connection instead of reusing it: handshake, discard, repeat. Sizing &lt;code&gt;MaxIdleConnsPerHost&lt;/code&gt; to match the configured concurrency cut discarded connections from 741 to 455 per 3,000 deliveries.&lt;/p&gt;

&lt;p&gt;The interesting part: on loopback, this produced zero latency improvement. A local handshake is essentially free, so the fix was invisible in my own numbers even though it was unambiguously correct. I almost cut it from the README because "no measurable delta" felt like a null result. I kept it in, because implying every optimization should show up as a number on my machine is its own kind of dishonesty. The saving only exists once there's real network latency in the loop, which docker-compose on a laptop doesn't give you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a correct fix with no visible benefit in your test environment isn't a wasted fix. It's a fix that's waiting for a more realistic environment to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The circuit breaker: small state machine, subtle interactions
&lt;/h2&gt;

&lt;p&gt;The breaker itself is genuinely small, three states, a failure counter, a cooldown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Breaker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&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;Sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;openedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cooldown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HalfOpen&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;HalfOpen&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&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;What made it interesting wasn't the state machine. It was deciding what doesn't count as a failure against it. An event past its max age gets dead-lettered, but that's my decision to give up, not the host's fault, so it must never trip the breaker or cost a retry. A permanent 4xx rejection shouldn't count against a host's health either, the payload is wrong, not the destination.&lt;/p&gt;

&lt;p&gt;Getting this wrong the first time meant a single customer sending malformed payloads could open a breaker and take down delivery to an otherwise healthy endpoint. Obvious once you say it out loud. Invisible while you're writing the happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Load testing found bugs that reading the code never would
&lt;/h2&gt;

&lt;p&gt;I built a side harness for this: a fault-injectable receiver (fixed status, hang for N seconds, flap between failing and succeeding on a cycle) plus a load generator with burst and steady profiles. Both real bugs above, the batch timeout and the connection pool, only showed up under sustained load, and only because I was looking at the &lt;em&gt;shape&lt;/em&gt; of the numbers, not just whether they were fast enough. A suspiciously linear accept rate and a suspiciously round connection count were the tells. Reading the worker loop in isolation, everything looked correct. And it was, locally, at low concurrency. Which is exactly the trap.&lt;/p&gt;

&lt;p&gt;If I could tell past-me one thing starting this project: write the load generator early, and look for numbers that are too clean, not just numbers that are too slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting their first Go + Kafka project
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the failure paths first.&lt;/strong&gt; Retry, DLQ, and breaker logic is where almost all the real design decisions live. The happy path is a rounding error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table-driven tests pay for themselves immediately&lt;/strong&gt; once you have more than two branches of behavior to verify, and in Go they're nearly free to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every blocking channel op needs an escape hatch.&lt;/strong&gt; No &lt;code&gt;ctx.Done()&lt;/code&gt; case means no semaphore, just a deadlock waiting for a bad day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kafka's guarantees are narrower than they sound.&lt;/strong&gt; Ordering is per partition, not global. A commit is a watermark, not a checklist. Two consumer groups don't know about each other. None of this is a flaw in Kafka, it's just not what "queue" implies if you're coming from somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark before you trust your own reasoning.&lt;/strong&gt; I was confident the code was correct in both bug cases above. It compiled, it passed tests, it looked right. It took real load and a suspiciously clean number to prove otherwise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo, including the benchmark harness, raw JSON results, and all the tests, is here: &lt;a href="https://github.com/Dev-Bilaspure/webhook-delivery" rel="noopener noreferrer"&gt;github.com/Dev-Bilaspure/webhook-delivery&lt;/a&gt;. If "reliable" is part of your spec, that requirement alone will teach you more than the framework docs ever will.&lt;/p&gt;

</description>
      <category>go</category>
      <category>kafka</category>
      <category>distributedsystems</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
