Kafka Producer Internals: Tuning RecordAccumulator, batch.size & linger.ms
Client-side buffer memory pools, Sender I/O thread, and batch-level compression.
Part 7 in Series — Catch up on the previous article: Kafka Storage Internals: Log Segments, Sparse Indexes & Log Compaction (Part 6) before diving into this post.
Suppose you are building an telemetry collection agent deployed across 10,000 application servers.
Every application server emits 500 log events per second.
If your producer code opens a network connection and sends an individual TCP request to Kafka for every single event (10,000 servers x 500 events = 5,000,000 network calls/sec), network packet header overhead and TCP RTT latencies saturate network bandwidth.
Sending individual network requests for tiny 100-byte payloads is inefficient.
Kafka solves network overhead by implementing an in-memory client-side batching engine inside the KafkaProducer.
Calling producer.send() does not send a message over the network. It appends the message to an in-memory buffer pool and returns immediately.
The Internal Architecture of KafkaProducer
The Java KafkaProducer client splits work between two threads: the User Application Thread and an asynchronous Sender I/O Thread.
APPLICATION THREAD SENDER I/O THREAD
+--------------------+ +-----------------+
| producer.send() | | Sender Loop |
+--------------------+ +-----------------+
| ^
v | (Reads Batches)
[ Interceptors & Serializers ] |
| |
v |
[ Partitioner ] ---> Calculates Target Partition |
| |
v |
+--------------------------------------------------------------------+------------+
| RECORD ACCUMULATOR (In-Memory Buffer Pool) |
| |
| Topic: "orders", Partition 0 queue: [ Batch 1 (Full) ] [ Batch 2 (Accumulating) ]
| Topic: "orders", Partition 1 queue: [ Batch 1 (Full) ] |
| Topic: "orders", Partition 2 queue: [ Batch 1 (Linger timer active) ] |
+---------------------------------------------------------------------------------+
Step-by-Step producer.send() Trace
When your code executes producer.send(new ProducerRecord<>("orders", key, value)):
- Producer Interceptors: Executes custom pre-processing or metric enrichment logic.
- Serializers: Converts key and value Java objects into raw byte arrays using configured serializers (
StringSerializer,ByteArraySerializer,KafkaAvroSerializer). - Partitioner: Evaluates key hash or round-robin logic to assign the record to a specific partition index (e.g. Partition 2).
- Buffer Memory Allocation: The producer requests a byte buffer from
BufferPool(buffer.memory = 32MB). - RecordAccumulator Append: Appends the byte payload to an active
ProducerBatchqueued insideRecordAccumulatorfor Partition 2. - Future Return: Returns a Java
Future<RecordMetadata>to the user thread without blocking.
Tuning Throughput vs Latency: batch.size & linger.ms
The RecordAccumulator decides when a ProducerBatch is ready to be shipped across the network by evaluating two configuration settings:
1. batch.size (Default: 16384 bytes / 16KB)
Defines the maximum size in bytes allocated for a single batch destined for a specific partition. When a batch fills up to batch.size, the RecordAccumulator marks it ready for the Sender thread immediately.
2. linger.ms (Default: 0 ms)
Defines the maximum time to wait before sending a batch if it has not reached batch.size.
SCENARIO A: linger.ms = 0 (Default)
As soon as a record is appended, the batch is marked ready immediately.
Result: Low latency, but smaller batch sizes and higher TCP packet overhead.
SCENARIO B: linger.ms = 20
The producer waits up to 20ms for additional incoming records to fill the batch.
Result: High throughput, compressed batches, minimal network packet overhead.
Setting linger.ms = 20 and batch.size = 65536 (64KB) is a common production tuning pattern for high-throughput producers.
Compression Mechanics
Kafka producers can compress batches before transmitting them over the wire (compression.type = lz4 | zstd | gzip | snappy).
Compression operates at the batch level, not per-message.
Compressing a 64KB batch of 500 records together achieves dramatically higher compression ratios (often 5x to 10x reduction) compared to compressing single records individually.
UNCOMPRESSED BATCH (64 KB):
[ Record 1 ][ Record 2 ][ Record 3 ] ... [ Record 500 ]
COMPRESSED BATCH (12 KB via ZSTD):
[ Compressed Byte Payload Block ]
The broker receives the compressed byte batch and writes it directly to disk without decompressing it, preserving Zero-Copy efficiency!
Handling Buffer Exhaustion
What happens if producers append records faster than the Sender thread can transmit them over the network?
When BufferPool memory runs out (buffer.memory = 33554432 bytes), producer.send() blocks the application thread until buffer space becomes available.
If buffer space is not freed within max.block.ms (default: 60,000ms), producer.send() throws a TimeoutException.
Quick Summary
producer.send()does not make network calls; it appends records to an in-memoryRecordAccumulatorbuffer.- The
Senderthread asynchronously drains batches fromRecordAccumulatorqueues and sends TCP requests. batch.sizeandlinger.mscontrol the trade-off between network latency and throughput.- Compression operates across entire record batches, achieving high compression ratios while preserving Zero-Copy disk writes on brokers.
References & Further Reading
- Apache Software Foundation. Apache Kafka Source Code:
KafkaConsumer.java. GitHub. - Apache Software Foundation. Apache Kafka Documentation: Consumer Configuration & Heartbeat Thread Mechanics. Apache Kafka Docs.
- Shapira, G., et al. (2021). Kafka: The Definitive Guide (2nd Edition) — Chapter 4: Kafka Consumers. O’Reilly Media.
Part 8: Kafka Partition Routing: MurmurHash2 Keys, Sticky Partitioning & Idempotence
Continue to Part 8 →