<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Kafka Internals on Tushar Choudhary</title><link>https://tushar-c23.github.io/posts/kafka/</link><description>Recent content in Kafka Internals on Tushar Choudhary</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Sun, 20 Sep 2026 19:30:00 +0530</lastBuildDate><atom:link href="https://tushar-c23.github.io/posts/kafka/index.xml" rel="self" type="application/rss+xml"/><item><title>The batch is the unit: what Kafka actually writes to disk</title><link>https://tushar-c23.github.io/posts/kafka/batch-is-the-unit/</link><pubDate>Sun, 20 Sep 2026 19:30:00 +0530</pubDate><guid>https://tushar-c23.github.io/posts/kafka/batch-is-the-unit/</guid><description>&lt;p&gt;&lt;em&gt;First in the &lt;a href="https://tushar-c23.github.io/posts/kafka/"&gt;Kafka Internals&lt;/a&gt; series: reading the design doc against the actual source (Kafka trunk, 4.5.0-SNAPSHOT, KRaft-only). Every number below came off a single-node broker running on my laptop, and you can reproduce all of them.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Kafka&amp;rsquo;s API is message-shaped. You call &lt;code&gt;producer.send(record)&lt;/code&gt; with one record. You call &lt;code&gt;consumer.poll()&lt;/code&gt; and iterate &lt;code&gt;ConsumerRecord&lt;/code&gt;s one at a time. The docs say &amp;ldquo;message&amp;rdquo; everywhere. So the obvious mental model is: a message goes in, a message sits on disk, a message comes out.&lt;/p&gt;</description><content type="html"><![CDATA[<p><em>First in the <a href="/posts/kafka/">Kafka Internals</a> series: reading the design doc against the actual source (Kafka trunk, 4.5.0-SNAPSHOT, KRaft-only). Every number below came off a single-node broker running on my laptop, and you can reproduce all of them.</em></p>
<p>Kafka&rsquo;s API is message-shaped. You call <code>producer.send(record)</code> with one record. You call <code>consumer.poll()</code> and iterate <code>ConsumerRecord</code>s one at a time. The docs say &ldquo;message&rdquo; everywhere. So the obvious mental model is: a message goes in, a message sits on disk, a message comes out.</p>
<p>All three of those are wrong, and they&rsquo;re wrong in the same way. <strong>Kafka&rsquo;s unit is the batch.</strong> The producer builds batches, the broker stores batches without ever looking inside them, the index points only at batch boundaries, and the consumer is handed whole batches and throws away the records it didn&rsquo;t ask for.</p>
<p>That isn&rsquo;t an optimisation layered on top of a message store. It&rsquo;s the shape of the thing.</p>
<p><img src="bakery-batching.png" alt="Four-panel bakery comic: individual bread slices arrive on a producer conveyor, get squeezed into a compressed loaf, the loaf is placed on a partition-log shelf, and a customer asking for slice #5 is handed the entire loaf to pick from"></p>
<p>I thought of this corollary while reading through the batching flow, and generated the graphic above with NotebookLM to make it simpler to hold on to. (It really did stick in my head, lol. NotebookLM for the win, lessgo.) Hope it helps you as much as it helped me.</p>
<h2 id="a-single-message-costs-79-bytes">A single message costs 79 bytes</h2>
<p>Start with the smallest thing you can put in Kafka. One topic, one partition, one record with an 11-byte payload and no key:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>printf <span style="color:#e6db74">&#39;hello-kafka\n&#39;</span> | bin/kafka-console-producer.sh --topic m1.one --bootstrap-server $B
</span></span><span style="display:flex;"><span>stat -f <span style="color:#e6db74">&#34;%z bytes&#34;</span> $LOG_DIR/m1.one-0/00000000000000000000.log
</span></span></code></pre></div><pre tabindex="0"><code>79 bytes
</code></pre><p>Seventy-nine bytes for eleven bytes of payload. Where did the other 68 go? Dump the segment and the shape appears:</p>
<pre tabindex="0"><code>baseOffset: 0 lastOffset: 0 count: 1 baseSequence: 0 lastSequence: 0 producerId: 0
producerEpoch: 0 partitionLeaderEpoch: 0 isTransactional: false isControl: false
position: 0 CreateTime: 1789659819234 size: 79 magic: 2 compresscodec: none
crc: 3458659970 isvalid: true
| offset: 0 CreateTime: 1789659819234 keySize: -1 valueSize: 11 sequence: 0 payload: hello-kafka
</code></pre><p>Two levels, and the tool renders them differently on purpose. The unindented line is a <strong>RecordBatch</strong>. The <code>|</code>-prefixed line under it is a <strong>Record</strong>. There is no format in which the record appears on its own. Even a single message gets a batch wrapped around it.</p>
<p>The batch header is fixed-width, and you can add it up from <code>DefaultRecordBatch</code>:</p>
<pre tabindex="0"><code>BaseOffset           Int64    8
Length               Int32    4
PartitionLeaderEpoch Int32    4
Magic                Int8     1
CRC                  Uint32   4
Attributes           Int16    2
LastOffsetDelta      Int32    4
BaseTimestamp        Int64    8
MaxTimestamp         Int64    8
ProducerId           Int64    8
ProducerEpoch        Int16    2
BaseSequence         Int32    4
RecordsCount         Int32    4
                          = 61 bytes
</code></pre><p>That&rsquo;s <code>RECORD_BATCH_OVERHEAD</code>, and it&rsquo;s 61 whether the batch holds one record or ten thousand. 61 + 18 bytes of record = 79.</p>
<p>So the per-message overhead isn&rsquo;t 68 bytes. It&rsquo;s 61 bytes <strong>per batch</strong>, which becomes 68 bytes per message only if you&rsquo;re pathological enough to put one message in each.</p>
<h2 id="records-are-deltas-which-is-why-they-shrink">Records are deltas, which is why they shrink</h2>
<p>Look at what the inner record <em>doesn&rsquo;t</em> store:</p>
<pre tabindex="0"><code>Length         Varint
Attributes     Int8
TimestampDelta Varlong
OffsetDelta    Varint
KeyLength      Varint
Key            Bytes
ValueLength    Varint
Value          Bytes
HeadersCount   Varint
Headers        [Header]
</code></pre><p>No absolute offset. No absolute timestamp. No producer id, no epoch, no sequence number. Every one of those lives exactly once, in the batch header, and each record carries only a <strong>delta</strong> from it, varint-encoded, so small deltas cost one byte.</p>
<p>This is the second half of the batching payoff. The first half is amortising a 61-byte header. The second is that being inside a batch is what makes a record cheap: it gets to describe itself relative to its neighbours.</p>
<p>Produce the same 11-byte payload 1, 10, and 1000 times into fresh topics and the effect is brutal:</p>
<table>
	<thead>
			<tr>
					<th>records</th>
					<th>batches</th>
					<th>bytes on disk</th>
					<th>bytes/record</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>1</td>
					<td>79</td>
					<td>79.0</td>
			</tr>
			<tr>
					<td>10</td>
					<td>1</td>
					<td>241</td>
					<td>24.1</td>
			</tr>
			<tr>
					<td>1000</td>
					<td>2</td>
					<td>18,994</td>
					<td>19.0</td>
			</tr>
	</tbody>
</table>
<p>The per-record cost falls from 79 bytes to 19: the same payload, a 4x difference in what it costs to store, decided entirely by how many neighbours it arrived with.</p>
<p>The numbers are also exactly predictable, which is the part I enjoyed most:</p>
<pre tabindex="0"><code>size = 61 + min(n, 64)×18 + max(n − 64, 0)×19
</code></pre><p>Exact for every row. Two things worth pulling out of it.</p>
<p><strong>Why 18 bytes per record.</strong> Length 1, Attributes 1, TimestampDelta 1, OffsetDelta 1, KeyLength 1 (zigzag −1, meaning null), ValueLength 1, Value 11, HeadersCount 1.</p>
<p><strong>Why it steps to 19 at record 65.</strong> <code>OffsetDelta</code> is a zigzag varint. <code>zigzag(63) = 126</code>, which fits in a single byte; <code>zigzag(64) = 128</code>, which doesn&rsquo;t. So the 65th record in every batch is one byte more expensive than the 64th, forever. Records in a batch are not uniformly sized, and the size depends on <em>position</em>.</p>
<p><strong>Why 1000 records made two batches.</strong> <code>batch.size</code> defaults to 16384. Batch one closed at 16,375 bytes holding 862 records, because the 863rd would have taken it to 16,394. The remaining 138 went into a second batch of 2,619. 16,375 + 2,619 = 18,994, which is the number on disk.</p>
<p>Nothing here is estimated. The format is tight enough that you can predict the byte count of a segment before you write it.</p>
<h2 id="compression-happens-to-the-batch-not-the-message">Compression happens to the batch, not the message</h2>
<p>Attributes bits 0–2 hold the compression codec. When it&rsquo;s set, everything after <code>RecordsCount</code> (the whole record array) becomes <strong>one compressed blob</strong>. Not one compressed field per record. One blob per batch.</p>
<p>That matters because 1000 near-identical records compress against <em>each other</em>. Same 1000 records, four ways:</p>
<table>
	<thead>
			<tr>
					<th>setup</th>
					<th>codec</th>
					<th>batches</th>
					<th>bytes</th>
					<th>bytes/record</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>default batching</td>
					<td>none</td>
					<td>2</td>
					<td>18,994</td>
					<td>19.0</td>
			</tr>
			<tr>
					<td>default batching</td>
					<td>gzip</td>
					<td>2</td>
					<td><strong>2,092</strong></td>
					<td>2.1</td>
			</tr>
			<tr>
					<td><code>batch.size=0</code></td>
					<td>none</td>
					<td>1000</td>
					<td>79,000</td>
					<td>79.0</td>
			</tr>
			<tr>
					<td><code>batch.size=0</code></td>
					<td>gzip</td>
					<td>1000</td>
					<td><strong>99,000</strong></td>
					<td>99.0</td>
			</tr>
	</tbody>
</table>
<p>Read the last two rows twice. With batching turned off, <strong>gzip made the data 25% bigger</strong>. Every record became its own batch, so every record got its own gzip stream, and a gzip header and trailer wrapped around 18 bytes of payload costs more than the payload.</p>
<p>Compression isn&rsquo;t a property of your data in Kafka. It&rsquo;s a property of how your data was grouped before it got compressed, and if you&rsquo;ve disabled batching you have disabled compression&rsquo;s ability to do anything except add overhead.</p>
<p>The other half of the design is what the broker does with that blob: <strong>nothing</strong>. It stores the compressed bytes exactly as the producer sent them, and serves them to the consumer exactly as it stored them. The broker never decompresses to serve a fetch. The producer compresses, the consumer decompresses, and the bytes in between are untouched by anyone.</p>
<aside class="analogy" aria-label="Analogy">
  <span class="analogy-label">Analogy</span>
  <p>The baker doesn&rsquo;t slice the loaf to shelve it, and doesn&rsquo;t slice it to sell it either. It&rsquo;s squeezed once, on the way in, and unwrapped once, by whoever eats it. Every hop in between handles the same sealed loaf.</p>
<p>Which is the whole reason the shelf can be as fast as it is. A shelf that had to unwrap and re-wrap every loaf to check what&rsquo;s inside would be doing real work per loaf. This one just moves loaves.</p>

</aside>

<h2 id="where-the-batches-land">Where the batches land</h2>
<p>So a batch is the thing that gets written. Written <em>where</em>, exactly?</p>
<p>The storage layout is three nouns and no surprises:</p>
<ul>
<li><strong>A partition is a directory.</strong> <code>&lt;topic&gt;-&lt;partition&gt;</code>, e.g. <code>m2.log-0</code>.</li>
<li><strong>A segment is a file</strong> inside it, named by the <strong>base offset</strong> of the first record it holds, zero-padded to 20 digits.</li>
<li><strong>A segment file is batches, back to back</strong>, with nothing between them. No framing, no separators, no per-file header. The batch&rsquo;s own <code>Length</code> field at byte 8 is how you find the next one.</li>
</ul>
<p>Here&rsquo;s a real partition directory (5000 records, segments forced small so it rolls often):</p>
<pre tabindex="0"><code>00000000000000000000.index          24
00000000000000000000.log        65,400
00000000000000000000.timeindex      36
00000000000000000316.index          24
00000000000000000316.log        65,400
00000000000000000316.timeindex      24
...
00000000000000004740.index  10,485,760
00000000000000004740.log        53,849
00000000000000004740.timeindex 10,485,756
</code></pre><p>Sixteen segments. The filename <em>is</em> the addressing scheme: to find the segment holding offset 1000, take the greatest base offset ≤ 1000. That&rsquo;s <code>LogSegments.floorSegment</code>, a <code>ConcurrentNavigableMap.floorEntry</code> underneath. No scan, no metadata file, not even a directory listing at read time.</p>
<p>Segments exist for one reason: <strong>deletion granularity</strong>. Retention can&rsquo;t cheaply truncate the head of a file, but it can <code>unlink</code> one. So the log rolls when the active segment exceeds <code>segment.bytes</code>, or <code>segment.ms</code> elapses (default 7 days), or the index fills. Only the last segment is writable; everything before it is immutable, which is what makes a lot of later machinery legal.</p>
<p>There&rsquo;s a fun consequence of that hiding in plain sight: <strong>a message can outlive its own retention.</strong></p>
<p>Deletion works on whole files, so Kafka has to pick one timestamp per segment to judge it by, and it picks the newest one. From <code>UnifiedLog.deleteRetentionMsBreachedSegments</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#66d9ef">long</span> anchorTimestamp <span style="color:#f92672">=</span> segment.<span style="color:#a6e22e">largestTimestamp</span>();
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ...</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">boolean</span> delete <span style="color:#f92672">=</span> startMs <span style="color:#f92672">-</span> anchorTimestamp <span style="color:#f92672">&gt;</span> retentionMs;
</span></span></code></pre></div><p><code>largestTimestamp()</code> is the youngest record in the segment. So a segment only becomes eligible for deletion once its <em>newest</em> record is past retention, and every older record sharing that file rides along for free.</p>
<p>On a busy topic nobody notices, because segments fill and roll in minutes. On a low-traffic topic it&rsquo;s very visible. Say a message lands on Monday, the topic then does almost nothing, and a second message trickles in six days later. That second message has just reset the clock for the entire file. The Monday message is now past a 7-day retention and still sitting on disk, fully readable, purely because of which file it happens to share.</p>
<p>The ceiling is <code>segment.ms</code>, since a segment stops accepting records once it rolls. On defaults both <code>retention.ms</code> and <code>segment.ms</code> are 7 days, so on a quiet topic a record can legitimately survive close to 14 days. Retention is a floor, not a deadline, which is worth knowing if you&rsquo;ve ever pointed at <code>retention.ms</code> to argue that data is gone.</p>
<p>Back to the bakery: you don&rsquo;t bin one slice, you bin the loaf, and the loaf only goes out when its freshest slice has turned.</p>
<p>Now look at that last stanza again. The active segment&rsquo;s <code>.index</code> is <strong>10 MB</strong> while every rolled one is 24 bytes. Indexes are memory-mapped, and growing a mapped file means unmapping, resizing and remapping under a write lock, so Kafka buys that away by preallocating the full <code>segment.index.bytes</code> (10 MB default) at birth and trimming it down to <code>entrySize × entries</code> when the segment rolls. The oversized index is how you spot the active segment from an <code>ls</code>.</p>
<h2 id="the-index-points-at-batches-never-at-records">The index points at batches, never at records</h2>
<p>Each <code>.index</code> entry is 8 bytes: a <strong>relative</strong> offset (4 bytes, relative to the segment&rsquo;s base offset, which is where the saving comes from) and a file position (4 bytes). It&rsquo;s sparse, and the sparseness is the design.</p>
<p>The five lines that build it, from <code>LogSegment.append</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (bytesSinceLastIndexEntry <span style="color:#f92672">&gt;</span> indexIntervalBytes) {
</span></span><span style="display:flex;"><span>    offsetIndex().<span style="color:#a6e22e">append</span>(batchLastOffset, physicalPosition);
</span></span><span style="display:flex;"><span>    timeIndex().<span style="color:#a6e22e">maybeAppend</span>(maxTimestampSoFar(), shallowOffsetOfMaxTimestampSoFar());
</span></span><span style="display:flex;"><span>    bytesSinceLastIndexEntry <span style="color:#f92672">=</span> 0;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> sizeInBytes <span style="color:#f92672">=</span> batch.<span style="color:#a6e22e">sizeInBytes</span>();
</span></span><span style="display:flex;"><span>physicalPosition <span style="color:#f92672">+=</span> sizeInBytes;
</span></span><span style="display:flex;"><span>bytesSinceLastIndexEntry <span style="color:#f92672">+=</span> sizeInBytes;
</span></span></code></pre></div><p>The loop is over <strong>batches</strong>. <code>batchLastOffset</code>, <code>batch.sizeInBytes()</code>. An index entry can never point into the middle of a batch, because the code that writes entries never sees the middle of a batch.</p>
<p>Here&rsquo;s that segment&rsquo;s index, dumped:</p>
<pre tabindex="0"><code>offset: 473 position: 16350
offset: 552 position: 32700
offset: 631 position: 49050
</code></pre><p>And the batches in the file it indexes:</p>
<pre tabindex="0"><code>baseOffset: 316 lastOffset: 394 count: 79    (position 0)
baseOffset: 395 lastOffset: 473 count: 79    (position 16350)
baseOffset: 474 lastOffset: 552 count: 79    (position 32700)
baseOffset: 553 lastOffset: 631 count: 79    (position 49050)
</code></pre><p>Every index entry is a batch&rsquo;s <strong>last</strong> offset pointing at where that batch <strong>starts</strong>. Three observations fall out, and each one contradicts a reasonable guess:</p>
<p><strong>The entries are 16,350 bytes apart, but <code>index.interval.bytes</code> is 4096.</strong> The interval is a floor, not a spacing. Each batch here is 16,350 bytes, bigger than the interval on its own, so every batch trips the check. You cannot get entries closer together than one per batch no matter how small you set the interval.</p>
<p><strong>Four batches, three entries.</strong> The check is <code>&gt;</code> and it runs <em>before</em> the counter is incremented, so the first batch of a segment is never indexed. A lookup below the first indexed offset falls back to position 0 and scans from the top of the file.</p>
<p><strong>The segment is 65,400 bytes = 4 × 16,350.</strong> Every number in that directory listing is downstream of the batch size.</p>
<p>One more thing about the index, which I found genuinely surprising: it has <strong>no checksum</strong>. From the javadoc: <em>&ldquo;No attempt is made to checksum the contents of this file, in the event of a crash it is rebuilt.&rdquo;</em> The index holds nothing that isn&rsquo;t derivable from the <code>.log</code>. It&rsquo;s a pure cache, which means index corruption is never a data-loss event.</p>
<h2 id="finding-offset-12345">Finding offset 12345</h2>
<p>The design doc says &ldquo;constant time suffices,&rdquo; which is a claim worth calling. Lookup is not O(1). <code>LogSegment.translateOffset</code> is four lines and it&rsquo;s two different algorithms:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span>LogOffsetPosition <span style="color:#a6e22e">translateOffset</span>(<span style="color:#66d9ef">long</span> offset, <span style="color:#66d9ef">int</span> startingFilePosition) <span style="color:#66d9ef">throws</span> IOException {
</span></span><span style="display:flex;"><span>    OffsetPosition mapping <span style="color:#f92672">=</span> offsetIndex().<span style="color:#a6e22e">lookup</span>(offset);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> log.<span style="color:#a6e22e">searchForOffsetFromPosition</span>(offset, Math.<span style="color:#a6e22e">max</span>(mapping.<span style="color:#a6e22e">position</span>(), startingFilePosition));
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Phase one:</strong> binary search the mmap&rsquo;d index for the greatest indexed offset ≤ target. O(log n) over a structure small enough to stay resident.</p>
<p><strong>Phase two:</strong> from that file position, read batch <strong>headers</strong> forward until you find the batch whose last offset ≥ target. It uses each batch&rsquo;s <code>Length</code> field to skip over payloads without reading them.</p>
<p>The scan in phase two is what makes it not-O(1), and it&rsquo;s also the point. A dense index (one entry per record) would need no scan, but it would be enormous and it would thrash the page cache. Kafka trades a small cache-resident index plus a short scan for a large index plus no scan. Because entries are spaced by bytes written, the scan is bounded by roughly <code>index.interval.bytes</code> plus one batch: <strong>a few KB, no matter how large the log is.</strong></p>
<p>That&rsquo;s what &ldquo;constant time suffices&rdquo; actually means. Not that lookup is O(1), but that the non-constant part runs over something small enough to stay in memory, and the part that touches the log is a fixed-size window.</p>
<p>And note what phase two returns. Not a record. A <strong>batch</strong>.</p>
<h2 id="the-consumer-is-handed-the-loaf">The consumer is handed the loaf</h2>
<p>This is the panel people don&rsquo;t expect, and it&rsquo;s the cleanest evidence that the batch is the unit rather than an implementation detail.</p>
<p>Kafka never seeks to a record. Ask for offset 12345 and the broker finds the batch containing it and sends you that whole batch, including the records before 12345 that you did not ask for. The filtering happens on the <strong>client</strong>, in <code>CompletedFetch.nextFetchedRecord</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span>currentBatch <span style="color:#f92672">=</span> batches.<span style="color:#a6e22e">next</span>();
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ...</span>
</span></span><span style="display:flex;"><span>records <span style="color:#f92672">=</span> currentBatch.<span style="color:#a6e22e">streamingIterator</span>(decompressionBufferSupplier);
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span>Record record <span style="color:#f92672">=</span> records.<span style="color:#a6e22e">next</span>();
</span></span><span style="display:flex;"><span><span style="color:#75715e">// skip any records out of range</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (record.<span style="color:#a6e22e">offset</span>() <span style="color:#f92672">&gt;=</span> nextFetchOffset) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> record;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Both halves of the story are in those two fragments. <code>streamingIterator(decompressionBufferSupplier)</code> is where decompression happens: in your application&rsquo;s process, not the broker&rsquo;s. And <code>if (record.offset() &gt;= nextFetchOffset)</code> is the consumer quietly discarding records it was sent and didn&rsquo;t want.</p>
<p>So <code>poll()</code> returning you one <code>ConsumerRecord</code> at a time is a client-side presentation layer over a batch that arrived whole. The API&rsquo;s shape and the system&rsquo;s shape are different things.</p>
<p>Why do it this way? Because narrowing further would mean <strong>re-encoding</strong>. To hand you offset 12345 and nothing else, the broker would have to decompress the batch, strip the records you don&rsquo;t want, rebuild the header, recompute deltas, recompute the CRC, and recompress. Per consumer, per fetch. It would have to become a system that transforms data rather than one that moves it.</p>
<h2 id="the-thing-underneath-all-of-it">The thing underneath all of it</h2>
<p>Every section above is the same decision seen from a different angle: <strong>the producer, the disk and the consumer use the exact same bytes, with no translation at any hop.</strong></p>
<p>The format the producer builds is the format the broker writes, is the format the consumer receives. That&rsquo;s why the broker can store compressed blobs it never opens. It&rsquo;s why an index entry can only ever be a batch boundary. It&rsquo;s why the consumer has to do its own filtering. And it&rsquo;s the precondition for the efficiency claim I haven&rsquo;t touched yet: that the broker can serve a fetch by handing the kernel a file region and a socket, and never copying the data into user space at all.</p>
<p>The cost of that decision is that the format can&rsquo;t be changed casually. It&rsquo;s simultaneously a disk format, a wire format, and a compatibility contract with every client ever written. Kafka has changed it twice in its life.</p>
<h2 id="takeaways">Takeaways</h2>
<ol>
<li><strong>Per-message overhead is 61 bytes per batch, not per message.</strong> An 11-byte payload costs 79 bytes alone and 19 bytes in a crowd. If your throughput numbers assume the former, they&rsquo;re wrong by 4x.</li>
<li><strong>Turning off batching turns off compression&rsquo;s ability to help.</strong> With <code>batch.size=0</code>, gzip made my data 25% <em>bigger</em>. Compression compresses a batch, so no batch means no compression, just overhead.</li>
<li><strong>The batch is the smallest addressable thing on disk.</strong> Index entries land only on batch boundaries, <code>index.interval.bytes</code> is a floor rather than a spacing, and a lookup resolves to a batch.</li>
<li><strong>Your consumer receives and decompresses records it didn&rsquo;t ask for,</strong> and does the filtering itself. That&rsquo;s client CPU you&rsquo;re paying for, and it scales with batch size.</li>
<li><strong>An oversized <code>.index</code> is how you spot the active segment.</strong> 10 MB preallocated and mmap&rsquo;d, trimmed on roll.</li>
</ol>
<hr>
<p><em>Everything above was measured against Apache Kafka trunk (4.5.0-SNAPSHOT) on a single-node KRaft broker. Source references are to <code>clients/…/record/internal/</code> and <code>storage/…/log/</code>. Note the record classes moved to <code>org.apache.kafka.common.record.internal</code> on trunk, so most references you&rsquo;ll find online point at the old package.</em></p>
]]></content></item></channel></rss>