<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Haitam Elgharras — Engineering notes</title>
        <link>https://www.elhaitam.com/blog</link>
        <description>Notes on Java, Spring Boot, Kafka, React and TypeScript from production work.</description>
        <lastBuildDate>Sat, 12 Sep 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <image>
            <title>Haitam Elgharras — Engineering notes</title>
            <url>https://www.elhaitam.com/og-haitam-elgharras.png</url>
            <link>https://www.elhaitam.com/blog</link>
        </image>
        <copyright>© 2026 Haitam Elgharras</copyright>
        <atom:link href="https://www.elhaitam.com/rss.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Consumer lag hit 400k and the fix wasn't more partitions]]></title>
            <link>https://www.elhaitam.com/blog/kafka-consumer-lag-not-partitions</link>
            <guid isPermaLink="false">https://www.elhaitam.com/blog/kafka-consumer-lag-not-partitions</guid>
            <pubDate>Sat, 12 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Lag was growing on a healthy-looking cluster. Throughput was flat, CPU was idle, and the rebalance rate was the only metric moving. Partitions were never the problem.]]></description>
            <content:encoded><![CDATA[<p>A consumer group on our orders topic was 400,000 messages behind and losing ground every hour. The first suggestion in the room was to add partitions.</p>
<p>It is the obvious move. Partitions are the unit of parallelism in Kafka, lag means you are not keeping up, so more partitions means more consumers means more throughput. We did not add any, and the lag was back under two thousand by the end of the afternoon.</p>
<p>Here is what was actually wrong, and why the obvious move would have made it worse.</p>
<h2 id="partitions-buy-parallelism-not-speed">Partitions buy parallelism, not speed<a class="prose__anchor" aria-hidden="true" tabindex="-1" href="#partitions-buy-parallelism-not-speed">#</a></h2>
<p>Adding partitions raises the ceiling on how many consumers can work in parallel. It does nothing for how fast any single consumer gets through a batch, and it does nothing at all if your consumers cannot hold on to their assignment long enough to commit.</p>
<p>That second condition is the one people skip. A consumer group is only as fast as its ability to stay in the group. If members are being evicted and re-added, throughput collapses no matter how many partitions you have, because every eviction throws away in-flight work and hands those partitions to someone else who starts over.</p>
<p>So before touching the topic, the question worth answering is: are we slow, or are we unstable?</p>
<h2 id="the-rebalance-rate-answers-that">The rebalance rate answers that<a class="prose__anchor" aria-hidden="true" tabindex="-1" href="#the-rebalance-rate-answers-that">#</a></h2>
<p>Four consumer metrics tell you almost everything:</p>

























<div class="prose__table-scroll"><table><thead><tr><th>Metric</th><th>What it means</th></tr></thead><tbody><tr><td><code>records-lag-max</code></td><td>How far behind you are. The symptom, not the cause.</td></tr><tr><td><code>rebalance-rate-per-hour</code></td><td>How often the group reshuffles. Should be zero at steady state.</td></tr><tr><td><code>commit-rate</code></td><td>How often progress is actually recorded.</td></tr><tr><td><code>time-between-poll-avg</code></td><td>How long the consumer spends away from <code>poll()</code>.</td></tr></tbody></table></div>
<p>Ours looked like this:</p>

























<div class="prose__table-scroll"><table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody><tr><td><code>records-lag-max</code></td><td>~400,000 and climbing</td></tr><tr><td><code>rebalance-rate-per-hour</code></td><td>14</td></tr><tr><td><code>commit-rate</code></td><td>near zero</td></tr><tr><td><code>time-between-poll-avg</code></td><td>220s and spiking past 300s</td></tr></tbody></table></div>
<p>Fourteen rebalances an hour is not a cluster under load. That is a group that cannot stay together. And a commit rate near zero with healthy consumption means the same messages were being read over and over and never acknowledged.</p>
<blockquote>
<p>Lag tells you that you have a problem. The rebalance rate tells you whether it is a throughput problem or a stability problem. They have completely different fixes.</p>
</blockquote>
<h2 id="the-poll-loop-was-doing-network-io">The poll loop was doing network I/O<a class="prose__anchor" aria-hidden="true" tabindex="-1" href="#the-poll-loop-was-doing-network-io">#</a></h2>
<p>The listener looked like this:</p>
<pre class="shiki shiki-themes vitesse-light vesper" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">@</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">KafkaListener</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#99FFE4">topics</span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#99FFE4">orders</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4">"</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#99FFE4"> groupId</span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#99FFE4">order-processor</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4">"</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">public</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0"> void</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799"> handle</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#393A34;--shiki-dark:#A0A0A0">List</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">&#x3C;</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF">Order</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">></span><span style="--shiki-light:#393A34;--shiki-dark:#FFF"> orders</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">)</span><span style="--shiki-light:#999999;--shiki-dark:#FFF"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#A0A0A0">    for</span><span style="--shiki-light:#999999;--shiki-dark:#FFF"> (</span><span style="--shiki-light:#393A34;--shiki-dark:#A0A0A0">Order</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> order</span><span style="--shiki-light:#1E754F;--shiki-dark:#A0A0A0"> :</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF"> orders</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">)</span><span style="--shiki-light:#999999;--shiki-dark:#FFF"> {</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#A0A0A0">        Customer</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> customer</span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> customerClient</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">fetch</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF">order</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">customerId</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">());</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#FFF">        repository</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">save</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF">order</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">enrichWith</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF">customer</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">));</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">    }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">}</span></span></code></pre>
<p><code>customerClient.fetch</code> is a synchronous HTTP call. It averaged 450ms and its p99 was close to two seconds.</p>
<p>Now put that next to the defaults. <code>max.poll.records</code> is 500. <code>max.poll.interval.ms</code> is 300000, five minutes. That is the contract: after <code>poll()</code> returns, you have five minutes to come back before the broker decides you are dead.</p>
<p>Five hundred records at 450ms each is 225 seconds. That fits inside five minutes, which is exactly why this ran fine for months. But it leaves 75 seconds of headroom, and the moment the downstream service got slower, or a batch landed with a few p99 calls in it, we blew past the interval. The broker evicted the consumer, the group rebalanced, the partitions moved, the new owner started from the last commit, and the work was done again from scratch.</p>
<p>That is the feedback loop. Slow processing causes an eviction, the eviction causes rework, the rework makes processing look slower, and the lag climbs while CPU sits idle.</p>
<p><strong>Adding partitions here would have added more consumers to a group that was already failing to hold an assignment.</strong> More members means more rebalances, and every rebalance stops the whole group.</p>
<h2 id="two-changes-no-new-partitions">Two changes, no new partitions<a class="prose__anchor" aria-hidden="true" tabindex="-1" href="#two-changes-no-new-partitions">#</a></h2>
<p>The first was to stop asking for work we could not finish in time:</p>
<pre class="shiki shiki-themes vitesse-light vesper" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#FFC799">spring</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#FFC799">  kafka</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#FFC799">    consumer</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#FFC799">      max-poll-records</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#FFC799"> 50</span></span></code></pre>
<p>Fifty records at 450ms is 22 seconds against a five minute budget. Even a batch made entirely of p99 calls finishes with room to spare. This alone stopped the rebalances.</p>
<p>The second was to stop making one HTTP call per record:</p>
<pre class="shiki shiki-themes vitesse-light vesper" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">@</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">KafkaListener</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#99FFE4">topics</span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#99FFE4">orders</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4">"</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#99FFE4"> groupId</span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#99FFE4">order-processor</span><span style="--shiki-light:#B5695977;--shiki-dark:#99FFE4">"</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">public</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0"> void</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799"> handle</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#393A34;--shiki-dark:#A0A0A0">List</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">&#x3C;</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF">Order</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">></span><span style="--shiki-light:#393A34;--shiki-dark:#FFF"> orders</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">)</span><span style="--shiki-light:#999999;--shiki-dark:#FFF"> {</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#A0A0A0">    Set</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">&#x3C;</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">String</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">></span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> customerIds</span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> orders</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">stream</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">            .</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">map</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF">Order</span><span style="--shiki-light:#1E754F;--shiki-dark:#A0A0A0">::</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF">customerId</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">            .</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">collect</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">toSet</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">());</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#A0A0A0">    Map</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">&#x3C;</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0">String</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">,</span><span style="--shiki-light:#AB5959;--shiki-dark:#A0A0A0"> Customer</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">></span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> customers</span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> customerClient</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">fetchAll</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF">customerIds</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#FFF">    repository</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">saveAll</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF">orders</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">stream</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">            .</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">map</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#393A34;--shiki-dark:#FFF">order </span><span style="--shiki-light:#999999;--shiki-dark:#A0A0A0">-></span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF"> order</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">enrichWith</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF">customers</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">get</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#FFF">order</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">.</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">customerId</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">())))</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">            .</span><span style="--shiki-light:#59873A;--shiki-dark:#FFC799">toList</span><span style="--shiki-light:#999999;--shiki-dark:#FFF">());</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#FFF">}</span></span></code></pre>
<p>One call for the whole batch instead of fifty, and one bulk insert instead of fifty round trips. Batch time went from 22 seconds to under two.</p>
<h2 id="where-it-landed">Where it landed<a class="prose__anchor" aria-hidden="true" tabindex="-1" href="#where-it-landed">#</a></h2>






























<div class="prose__table-scroll"><table><thead><tr><th></th><th>Before</th><th>After</th></tr></thead><tbody><tr><td><code>records-lag-max</code></td><td>~400,000</td><td>~1,200</td></tr><tr><td><code>rebalance-rate-per-hour</code></td><td>14</td><td>0</td></tr><tr><td>Batch processing time</td><td>225s</td><td>1.8s</td></tr><tr><td>Partitions</td><td>12</td><td>12</td></tr></tbody></table></div>
<p>The lag drained over about forty minutes once the group stopped reshuffling.</p>
<h2 id="what-i-check-first-now">What I check first now<a class="prose__anchor" aria-hidden="true" tabindex="-1" href="#what-i-check-first-now">#</a></h2>
<p>When lag is climbing, in this order:</p>
<ol>
<li><strong>Is the rebalance rate zero?</strong> If not, nothing else matters. Fix stability first.</li>
<li><strong>What is</strong> <strong><code>time-between-poll-avg</code></strong> <strong>against</strong> <strong><code>max.poll.interval.ms</code>****?</strong> If you are using more than about half the budget, you are one bad afternoon from an incident.</li>
<li><strong>Is there network I/O inside the listener?</strong> Per-record calls in a batch listener are the single most common cause of this shape.</li>
<li><strong>Is the commit rate healthy?</strong> Consumption without commits means rework.</li>
<li><strong>Only then, is the partition count actually the ceiling?</strong> If every consumer is saturated and stable, and you have as many consumers as partitions, more partitions is the right answer.</li>
</ol>
<p>Partitions are a capacity lever. They are worth reaching for when you have measured that parallelism is the constraint. Reaching for them first, on a group that is quietly evicting its own members, just gives the instability more surface area to spread across.</p>]]></content:encoded>
            <author>Haitam Elgharras</author>
            <category>kafka</category>
            <category>java</category>
            <category>spring-boot</category>
            <enclosure url="https://www.elhaitam.com/blog-media/kafka-consumer-lag-not-partitions/38f01a6ee572.og.jpg" length="0" type="image/jpg"/>
        </item>
    </channel>
</rss>