Producer Design
How to build a production-grade Kafka producer: the full config walkthrough, sync vs async sends, error handling in delivery callbacks, a worked order-events producer, graceful shutdown, monitoring, and a production-readiness checklist.
You Already Know What a Producer Does. Now Build One That Doesn't Lose Data.
Module 03 covered what a producer is at the mechanical level: it serializes records, discovers partition leaders, batches by partition, and waits for whatever acknowledgement acksdemands. That mental model is necessary but not sufficient. Knowing that acks=allexists is different from knowing which fifteen configuration properties you actually need to set, in what combination, to ship a producer that survives a broker restart at 2 AM without dropping an order event or silently duplicating a payment.
This module is deliberately applied. Every example below is code you could paste into a real service, not pseudocode standing in for a concept. We use Python with theconfluent-kafka client throughout — its API maps closely to the underlying librdkafka C client that most production Kafka clients are built on, so the property names and semantics you learn here transfer directly to Java, Go, or any other client library with only syntax changing.
What "production-ready" means for a producer, concretely: it does not silently drop a record on a transient network blip. It does not block your application's request thread for hundreds of milliseconds on every single send. It tells you, unambiguously, when a write failed instead of leaving a future or callback nobody checked. It shuts down without abandoning messages still sitting in its internal buffer. And it exposes metrics that let you notice degradation before a customer does.
Every section that follows builds toward one thing: a complete, annotatedOrderEventProducer class in Part 06 that you could genuinely adapt for a real service. The config walkthrough, the sync-vs-async discussion, and the error handling patterns are all groundwork for understanding why that class is built the way it is.
Every Property That Actually Matters, and What Its Default Costs You
A Kafka producer has dozens of configuration properties. Most teams never touch most of them. A much smaller set determines whether your producer is durable, fast, or both — and the defaults for several of these are tuned for "works out of the box in a demo," not "safe in production."
acks — what counts as a successful write
Covered conceptually in Module 03, but worth restating as a config decision you write down explicitly rather than inherit from a default. acks=0 means fire-and-forget — the producer does not even wait for a response. acks=1 (a common client default) means the partition leader acknowledges after writing to its own local log, before followers have replicated it. acks=all means the leader waits for every replica currently in the in-sync replica set to confirm. For anything you would be upset to lose — an order, a payment, an inventory adjustment — the answer is acks=all, full stop, and the decision is not up for per-service debate.
enable.idempotence — closing the duplicate-on-retry gap
Retries make a producer resilient to transient failures, but naive retries can create duplicates: a batch is written successfully, the acknowledgement is lost in the network, the producer retries, and the same batch is written a second time. enable.idempotence=true assigns the producer a Producer ID and a per-partition sequence number, and the broker discards any retried write whose sequence number it has already committed. This defaults to false in older client versions and true in current ones — check your client version rather than assuming. Setting it explicitly costs nothing and removes an entire category of duplicate-write bugs.
max.in.flight.requests.per.connection — ordering under retry
This controls how many unacknowledged requests can be outstanding to one broker connection at once. A higher number lets the producer pipeline more requests without waiting for each to be acknowledged, which helps throughput. The danger: if a request is retried while later requests are still in flight, and it succeeds after them, records can be written out of the order they were sent. With enable.idempotence=true, the broker's sequence-number tracking makes it safe to leave this as high as 5 (the common default) without risking reordering. Without idempotence enabled, set it to 1 if strict ordering matters, at a real throughput cost.
retries, request.timeout.ms, and delivery.timeout.ms — the retry budget
retries alone is a poor way to reason about producer resilience, because a fixed retry count says nothing about how long you are willing to let a send remain unresolved.delivery.timeout.ms is the better mental model: it is the total upper bound, from the moment you call send(), on how long the producer will keep retrying before giving up and reporting failure to your application. request.timeout.ms bounds a single request attempt inside that budget. Set retries high (or effectively unbounded) and letdelivery.timeout.ms be the actual governing constraint — a common production setting isdelivery.timeout.ms=120000 (two minutes), giving the producer room to survive a broker restart or a brief network partition without your application giving up too early.
linger.ms, batch.size, compression.type — batching, revisited as config
Module 03 explained the mechanics. As config decisions: linger.ms=0 is the safest default for latency-sensitive request paths (a checkout API waiting on a synchronous confirmation), while linger.ms in the 5-20ms range is a near-free throughput win for background ingestion where nobody is blocked on an individual record. batch.size (default 16KB in many clients) caps how large a single partition's batch can grow before it is sent regardless oflinger.ms — raising it to 32KB or 64KB alongside a nonzero linger.ms is a standard pairing for high-throughput topics. compression.type=lz4 orzstd is close to a free win for most workloads: modest CPU cost, meaningful reduction in both network bytes and broker disk usage.
| Property | What it controls | Production-safe default |
|---|---|---|
| acks | What acknowledgement counts as a successful write. | 'all' for anything durability-sensitive; '1' only for tolerable-loss telemetry. |
| enable.idempotence | Whether the broker deduplicates retried writes by sequence number. | true — set explicitly, do not rely on client-version defaults. |
| max.in.flight.requests.per.connection | How many unacked requests can be outstanding at once. | 5 with idempotence enabled; 1 without it, if ordering matters. |
| retries | How many times a failed send is retried. | A high value or Integer.MAX_VALUE — let delivery.timeout.ms govern instead. |
| delivery.timeout.ms | Total time budget from send() to final success or failure. | 120000 (2 minutes) as a common starting point. |
| request.timeout.ms | Time budget for a single request attempt. | 30000, well inside delivery.timeout.ms. |
| linger.ms | How long to wait for a batch to fill before sending. | 0 for latency-sensitive paths; 5-20 for background ingestion. |
| batch.size | Max bytes per partition batch before it is sent regardless of linger.ms. | 16KB-64KB depending on throughput needs. |
| compression.type | Codec used to compress each batch before sending. | 'lz4' or 'zstd' for most workloads. |
acks setting just because they share a code template. Treat the config block as a business decision reviewed alongside the code, not boilerplate copied from the last service.Blocking on Every Send Quietly Destroys Your Throughput
The single most common mistake in producer code written by engineers new to Kafka is callingsend() and then immediately blocking on its result before sending the next record. This is understandable — it feels safe, like each write is confirmed before you move on — but it defeats the entire purpose of client-side batching described in Part 02, and the throughput cost is not subtle.
What blocking on every send actually does
When you call .get() or .result() on a send's future immediately after sending, you force the producer to wait for that specific record's full round trip — network out, broker append, replication if acks=all, acknowledgement back — before your application code is allowed to call send() again. The producer's internal batching mechanism can never accumulate more than one record per batch, because you never give it the chance: you are serializing what should be a pipelined, asynchronous operation into a synchronous one, one record at a time.
from confluent_kafka import Producer
producer = Producer({
'bootstrap.servers': 'broker-1:9092,broker-2:9092',
'acks': 'all',
'enable.idempotence': True,
})
# ANTI-PATTERN: blocking on every single send
for order_event in order_events:
future = producer.produce(
topic='orders.events',
key=order_event['order_id'],
value=serialize(order_event),
)
producer.flush() # blocks until THIS record is fully acknowledged
# next iteration cannot start until the flush above returns
# effective throughput: roughly one record per network round trip# Benchmark: 100,000 order events, single producer, acks=all,
# 3-broker cluster, ~2ms average network round trip
# Pattern A — flush() after every produce() call:
# 100,000 records x ~2ms round trip = ~200,000ms = ~200 seconds
# effective throughput: ~500 records/second
# Pattern B — async produce() with batching (linger.ms=10, batch.size=32768):
# 100,000 records arrive far faster than network round trips can drain them
# producer accumulates ~2,000-record batches, sends ~50 requests total
# 50 requests x ~5ms (larger batch, still one round trip) = ~250ms
# effective throughput: >100,000 records/second
# The difference is not "a bit slower." It is roughly 200x.
# Both patterns use the exact same acks=all durability guarantee.The correct pattern — fire asynchronously, handle results in a callback
The fix is not to stop checking results — that would trade a throughput problem for a silent data loss problem. The fix is to attach a delivery callback that runs asynchronously when the broker actually responds, and let your application keep calling produce() in the meantime without blocking. The producer's internal batching then works the way it was designed to.
from confluent_kafka import Producer
import logging
logger = logging.getLogger(__name__)
producer = Producer({
'bootstrap.servers': 'broker-1:9092,broker-2:9092',
'acks': 'all',
'enable.idempotence': True,
'linger.ms': 10,
'batch.size': 32768,
'compression.type': 'lz4',
})
def delivery_callback(err, msg):
if err is not None:
logger.error(
f"Delivery failed for key={msg.key()} "
f"topic={msg.topic()} error={err}"
)
# decide here: alert, dead-letter, or fail the upstream request
else:
logger.debug(
f"Delivered key={msg.key()} to "
f"{msg.topic()}[{msg.partition()}]@{msg.offset()}"
)
for order_event in order_events:
producer.produce(
topic='orders.events',
key=order_event['order_id'],
value=serialize(order_event),
on_delivery=delivery_callback,
)
# poll(0) services delivery callbacks without blocking the send loop
producer.poll(0)
# after the loop, drain everything still in flight before moving on
producer.flush(timeout=30)Two details matter in the pattern above. First, producer.poll(0) inside the loop — this is what actually triggers delivery callbacks to run; without periodic poll()calls, callbacks queue up and your internal producer buffer can fill, eventually causingproduce() itself to block or raise BufferError. Second,producer.flush() after the loop — this blocks until every outstanding record has been delivered or has definitively failed, which is exactly what you want before considering a batch of work "done," just not on every individual record.
Not All Producer Errors Deserve the Same Response
A delivery callback receiving an error is not a single category of problem. Some errors mean "the producer already retried this transparently and it still failed — something is structurally wrong." Others mean "this exact message can never succeed, retrying is pointless." Treating every error the same way — logging it and moving on, or worse, silently swallowing it — throws away information you need to build a correct response.
Retriable vs non-retriable exceptions
The producer's own retry mechanism, governed by retries anddelivery.timeout.ms from Part 02, already handles retriable errors internally — things like a leader election in progress, a temporary network blip, or the broker being briefly unavailable. By the time an error reaches your delivery callback, the producer has already exhausted its internal retry budget for that message. That distinction matters: an error in your callback is not "try again" territory for most retriable cases, it is "the built-in retries already failed, now what."
| Error category | Example | What already happened | What your callback should do |
|---|---|---|---|
| Retriable, exhausted | KafkaError._TIMED_OUT after all internal retries | Producer retried internally per delivery.timeout.ms and still failed. | This points to sustained broker or network trouble, not a bad message — alert on it, consider circuit-breaking new sends. |
| Non-retriable, message-specific | MSG_SIZE_TOO_LARGE | The broker rejected this message outright; retrying it changes nothing. | Log the specific record, route to a dead-letter path if one exists, do not retry the same payload. |
| Non-retriable, config-level | UNKNOWN_TOPIC_OR_PARTITION on a topic that does not exist | Every message to this topic will fail the same way. | This is a deploy-time or config bug — alert loudly, this is not a per-message problem. |
| Serialization failure | Exception raised before produce() is even called | The message never reached the producer at all. | Fix at the source — log the malformed input, do not let it silently vanish from the pipeline. |
from confluent_kafka import KafkaError
import logging
logger = logging.getLogger(__name__)
# Errors where the message itself is permanently unsendable —
# retrying with the same payload will never succeed
NON_RETRIABLE_CODES = {
KafkaError.MSG_SIZE_TOO_LARGE,
KafkaError.INVALID_MSG_SIZE,
KafkaError.TOPIC_AUTHORIZATION_FAILED,
KafkaError.UNKNOWN_TOPIC_OR_PARTITION,
}
def make_delivery_callback(dlq_producer, dlq_topic):
def delivery_callback(err, msg):
if err is None:
return # success — nothing to do
error_code = err.code()
if error_code in NON_RETRIABLE_CODES:
logger.error(
f"Non-retriable delivery failure, routing to DLQ: "
f"key={msg.key()} error={err}"
)
dlq_producer.produce(
topic=dlq_topic,
key=msg.key(),
value=msg.value(),
headers=[('failure_reason', str(err).encode())],
)
dlq_producer.poll(0)
else:
# producer already exhausted its own retry budget for this message
logger.critical(
f"Retriable error exhausted internal retries — "
f"likely broker/network instability: key={msg.key()} error={err}"
)
# this is an operational alert, not a per-message fix
emit_metric('producer.delivery_failure.retriable_exhausted')
return delivery_callbackThe dead letter queue pattern here mirrors the one introduced for consumers in the message brokers module — the same principle applies on the producer side. A message that can never be sent successfully should not silently vanish; it should land somewhere a human can find it, with enough context (the failure reason, ideally the original topic it was destined for) to diagnose and potentially replay it once the root cause is fixed.
Serialization Failures Happen Before Kafka Ever Sees the Message
A subtle failure mode: an exception thrown during serialization — a datetime object that isn't JSON-serializable, a schema validation failure against a registered Avro schema, a field that isNone when the schema requires it — happens entirely on the producer's own process, before produce() is even called. This kind of failure never reaches a delivery callback, because there was never a message to deliver. If your error handling only watches delivery callbacks, these failures disappear without a trace.
import json
from decimal import Decimal
def serialize_order_event(event: dict) -> bytes:
try:
return json.dumps(event, default=_json_default).encode('utf-8')
except (TypeError, ValueError) as exc:
logger.error(
f"Serialization failed for order_id={event.get('order_id')}: {exc}"
)
emit_metric('producer.serialization_failure')
raise # let the caller decide: skip, dead-letter, or fail the request
def _json_default(obj):
if isinstance(obj, Decimal):
return str(obj)
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")Choosing the partition key deserves the same deliberateness as the config decisions in Part 02. Module 04's keying guidance still applies directly here: key by order_id orcustomer_id when per-entity ordering matters to a downstream consumer, and be conscious that a highly skewed key distribution — one enormous customer, one dominant tenant — creates a hot partition no amount of consumer scaling can fix, because a partition is only ever owned by one consumer in a group at a time.
Header metadata — carrying context without polluting the payload
Kafka records support headers — small key-value pairs attached to a record separately from its key and value. This is the right place for cross-cutting metadata that downstream consumers or tracing systems need but that does not belong in the business payload itself: a trace ID for distributed tracing, a schema version identifier, the name of the producing service, or a content-type hint for consumers that need to handle multiple payload formats on the same topic during a migration.
producer.produce(
topic='orders.events',
key=order_id.encode('utf-8'),
value=serialize_order_event(event),
headers=[
('trace_id', trace_context.trace_id.encode('utf-8')),
('schema_version', b'3'),
('producing_service', b'checkout-service'),
],
)
# a downstream consumer can inspect headers without deserializing
# the full payload -- useful for routing or filtering before the
# more expensive deserialization step even runs
def route_by_schema_version(msg):
headers = dict(msg.headers() or [])
version = headers.get('schema_version', b'1').decode()
if version == '1':
return parse_legacy_order_event(msg.value())
return parse_order_event_v3(msg.value())Headers are not free — they add bytes to every record and are not compressed as effectively as the batch-level value payload in some client implementations — so reserve them for genuinely cross-cutting metadata rather than using them as a second place to stash business fields that belong in the value itself.
A Production-Grade Order-Events Producer, Start to Finish
Everything above comes together here. This is a complete, self-contained producer class for an order-events service — the kind of thing you would find in a real checkout or fulfillment pipeline. It handles configuration, serialization, async delivery with categorized error handling, dead lettering, and graceful shutdown.
import json
import logging
from decimal import Decimal
from typing import Optional
from confluent_kafka import Producer, KafkaError
logger = logging.getLogger(__name__)
NON_RETRIABLE_CODES = {
KafkaError.MSG_SIZE_TOO_LARGE,
KafkaError.INVALID_MSG_SIZE,
KafkaError.TOPIC_AUTHORIZATION_FAILED,
KafkaError.UNKNOWN_TOPIC_OR_PARTITION,
}
class OrderEventProducer:
"""
Production producer for order lifecycle events.
Durability posture: acks=all, idempotent, unbounded retries within
a two-minute delivery budget. Optimized for throughput via batching,
not per-record latency -- appropriate for an events stream, not a
synchronous request/response path.
"""
def __init__(self, bootstrap_servers: str, topic: str, dlq_topic: str):
self.topic = topic
self.dlq_topic = dlq_topic
self._sent_count = 0
self._failed_count = 0
self.producer = Producer({
'bootstrap.servers': bootstrap_servers,
'acks': 'all',
'enable.idempotence': True,
'max.in.flight.requests.per.connection': 5,
'retries': 2147483647,
'delivery.timeout.ms': 120000,
'request.timeout.ms': 30000,
'linger.ms': 10,
'batch.size': 32768,
'compression.type': 'lz4',
'client.id': 'order-events-producer',
})
# separate producer instance for the DLQ -- keeps DLQ writes
# from competing with primary-topic batching and simplifies
# reasoning about each producer's delivery guarantees independently
self.dlq_producer = Producer({
'bootstrap.servers': bootstrap_servers,
'acks': 'all',
'enable.idempotence': True,
'client.id': 'order-events-producer-dlq',
}) @staticmethod
def _json_default(obj):
if isinstance(obj, Decimal):
return str(obj)
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
def _serialize(self, event: dict) -> Optional[bytes]:
try:
return json.dumps(event, default=self._json_default).encode('utf-8')
except (TypeError, ValueError) as exc:
logger.error(
f"Serialization failed for order_id={event.get('order_id')}: {exc}"
)
self._failed_count += 1
return None
def send_order_event(self, event: dict) -> bool:
"""
Queue an order event for async delivery. Returns False immediately
if the event could not even be serialized -- callers should treat
that as a hard failure for this specific event, not retry blindly.
Actual delivery success/failure is reported later via the
delivery callback, not by this method's return value.
"""
value = self._serialize(event)
if value is None:
return False
order_id = event.get('order_id', '')
try:
self.producer.produce(
topic=self.topic,
key=order_id.encode('utf-8') if order_id else None,
value=value,
on_delivery=self._delivery_callback,
)
except BufferError:
# local producer queue is full -- this means we are producing
# faster than the broker can absorb, even with batching.
# Block briefly to drain, then retry once.
logger.warning("Producer local queue full, blocking to drain")
self.producer.poll(1.0)
self.producer.produce(
topic=self.topic,
key=order_id.encode('utf-8') if order_id else None,
value=value,
on_delivery=self._delivery_callback,
)
# service any callbacks that are ready without blocking the caller
self.producer.poll(0)
return True def _delivery_callback(self, err, msg):
if err is None:
self._sent_count += 1
logger.debug(
f"Delivered order_id={msg.key()} to "
f"{msg.topic()}[{msg.partition()}]@{msg.offset()}"
)
return
self._failed_count += 1
error_code = err.code()
if error_code in NON_RETRIABLE_CODES:
logger.error(
f"Non-retriable failure, routing to DLQ: "
f"key={msg.key()} error={err}"
)
self._send_to_dlq(msg, str(err))
else:
logger.critical(
f"Delivery failed after exhausting internal retries "
f"(likely broker/network instability): "
f"key={msg.key()} error={err}"
)
self._send_to_dlq(msg, str(err))
def _send_to_dlq(self, msg, failure_reason: str):
try:
self.dlq_producer.produce(
topic=self.dlq_topic,
key=msg.key(),
value=msg.value(),
headers=[
('failure_reason', failure_reason.encode('utf-8')),
('source_topic', self.topic.encode('utf-8')),
],
)
self.dlq_producer.poll(0)
except Exception as exc:
# if even the DLQ write fails, this is the last line of defense --
# log at the highest severity available, this needs a human now
logger.critical(
f"DLQ write itself failed for key={msg.key()}: {exc}"
)
emit_metric('producer.dlq_write_failure') def close(self, timeout: float = 30.0):
"""
Flush every in-flight and buffered message before the process
exits. Called from a signal handler or application shutdown hook --
never let the process exit while messages are still buffered
in the producer's local queue, or they are lost silently.
"""
logger.info(
f"Shutting down producer: {self._sent_count} sent, "
f"{self._failed_count} failed. Flushing remaining messages..."
)
remaining = self.producer.flush(timeout=timeout)
if remaining > 0:
logger.critical(
f"{remaining} messages still unflushed after {timeout}s "
f"timeout -- these are LOST. Investigate broker health."
)
emit_metric('producer.shutdown_data_loss', value=remaining)
self.dlq_producer.flush(timeout=timeout)
logger.info("Producer shutdown complete.")Notice what this class does not do: it never blocks the caller of send_order_eventon a network round trip, it never lets a serialization failure disappear silently, it distinguishes "this message can never succeed" from "the broker is having a bad moment" in its error handling, and it refuses to let the process exit while messages are still sitting unflushed in memory. Each of these is a specific, named failure mode from earlier parts of this module, addressed deliberately rather than accidentally.
The Last Few Seconds of a Producer's Life Matter Most
A producer that batches records in memory before sending them has, at almost any given moment, some amount of unsent data sitting in local buffers waiting for linger.ms to elapse orbatch.size to fill. If the process exits — a deploy, a crash, an orchestrator killing the container — without giving the producer a chance to flush, that buffered data is gone. This is not a Kafka bug; it is the direct, unavoidable cost of the batching that makes producers fast in the first place, and it is entirely preventable with the right shutdown sequence.
Wiring flush() into signal handling
The fix is to intercept the signals your orchestrator sends on shutdown (typicallySIGTERM in a containerized environment, giving you a grace period beforeSIGKILL) and call flush() with a bounded timeout before allowing the process to actually exit.
import signal
import sys
producer = OrderEventProducer(
bootstrap_servers='broker-1:9092,broker-2:9092',
topic='orders.events',
dlq_topic='orders.events.dlq',
)
def handle_shutdown(signum, frame):
logger.info(f"Received signal {signum}, beginning graceful shutdown")
producer.close(timeout=25.0) # leave headroom under the orchestrator's kill timeout
sys.exit(0)
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)flush(timeout=30.0) call races the kill signal and can lose exactly the messages you were trying to save. Set the flush timeout comfortably below the orchestrator's grace period — 25 seconds against a 30-second grace period, for example — so flush has a chance to report an honest failure and log it, rather than being killed mid-flush with no record of what was lost.The return value of flush() matters and is frequently ignored: it returns the number of messages still outstanding when the timeout was hit. A nonzero return value after your shutdown flush is not a warning to log and forget — it is a count of messages that did not make it, and it deserves a metric and an alert, not just a log line nobody reads until the postmortem.
The Metrics That Tell You a Producer Is Degrading Before It Fails Outright
A producer that is slowly failing does not usually announce itself with a crash. It announces itself with rising error rates, growing request latency, and shrinking batch sizes — all visible in metrics well before the failure becomes obvious in application logs or customer-facing symptoms.
| Metric | What it measures | What a bad trend means |
|---|---|---|
| record-error-rate | Errors per second reported through delivery callbacks. | Rising errors point to broker instability, a misconfigured topic, or a downstream authorization change — investigate immediately, do not let this normalize. |
| request-latency-avg | Average time for a produce request to receive a response. | Rising latency often precedes an under-replicated-partition or broker-overload incident; it is frequently the earliest visible signal. |
| batch-size-avg | Average size of batches actually sent, in bytes. | A batch size far smaller than batch.size suggests linger.ms is too low for the actual traffic rate, or traffic itself has dropped -- worth distinguishing the two. |
| buffer-available-bytes | Remaining local producer buffer capacity (bounded by buffer.memory). | Approaching zero means the producer is generating records faster than the broker can absorb them -- a leading indicator of BufferError before it happens. |
| record-retry-rate | Rate of records being internally retried. | A sustained nonzero retry rate, even without ultimate failures, is a sign of network or broker flakiness worth tracking before it escalates to outright failures. |
def _delivery_callback(self, err, msg):
if err is None:
self._sent_count += 1
emit_metric('producer.delivery.success')
return
self._failed_count += 1
emit_metric('producer.delivery.failure', tags={'error_code': str(err.code())})
if err.code() in NON_RETRIABLE_CODES:
self._send_to_dlq(msg, str(err))
else:
emit_metric('producer.delivery.retriable_exhausted')
self._send_to_dlq(msg, str(err))Most Kafka client libraries also expose broker-reported statistics through a periodic stats callback (statistics.interval.ms in confluent-kafka), which surfacesrequest-latency-avg, batch-size-avg, and similar metrics without you having to compute them yourself from raw timings. Wiring that stats callback into whatever metrics system your organization uses — StatsD, Prometheus, CloudWatch — is a small amount of setup that pays for itself the first time it catches a degrading producer before an on-call page does.
Is This Producer Actually Production-Ready?
Before shipping a new producer — or reviewing someone else's pull request that adds one — run it against this checklist. Every item traces back to a specific failure mode covered earlier in this module.
- ✓acks is set explicitly and matches the actual durability requirement of the data, not copied from an unrelated service.
- ✓enable.idempotence=true is set explicitly, not assumed from a client-library default.
- ✓delivery.timeout.ms and retries are configured together as a coherent retry budget, not left at whatever the client library ships with.
- ✓Every send() call has an attached delivery callback (or equivalent future handling) that is actually checked — no send() with no way to observe failure.
- ✓The delivery callback distinguishes retriable-exhausted failures from non-retriable, message-specific failures, and handles each appropriately.
- ✓Serialization failures are caught and logged explicitly, since they never reach the delivery callback at all.
- ✓A dead letter path exists for messages that fail delivery permanently, with enough context in the DLQ record to diagnose and replay later.
- ✓Shutdown calls flush() with a timeout comfortably shorter than the orchestrator's kill grace period, and checks the returned count of unflushed messages.
- ✓Metrics for error rate, request latency, and batch size are wired into your monitoring system, not just written to logs nobody dashboards.
- ✓The producer does not block the calling thread on every individual send under normal operating conditions.
Config Values Are Claims. Testing Is How You Verify Them.
A producer's configuration makes specific, checkable claims: "this producer does not lose data under a broker failure," "this producer sustains 100,000 records/second," "this producer's shutdown never abandons buffered messages." Every one of these claims should be verified with a test, not assumed from reading the config file. This part covers the three tests worth writing for any producer before it ships to production.
Test 1 — durability under a simulated broker failure
Against a local, multi-broker test cluster, kill the current partition leader mid-send and confirm the producer either successfully delivers (after failing over to the new leader) or reports a clear failure through the delivery callback — never silently drops the record without reporting anything.
import time
import subprocess
def test_producer_survives_leader_failure(docker_compose_cluster):
producer = OrderEventProducer(
bootstrap_servers='localhost:9092,localhost:9093,localhost:9094',
topic='test.orders.events',
dlq_topic='test.orders.events.dlq',
)
delivered = []
failed = []
def tracking_callback(err, msg):
if err is None:
delivered.append(msg)
else:
failed.append((msg, err))
# send a batch, then kill the current leader mid-flight
for i in range(1000):
producer.producer.produce(
topic='test.orders.events',
key=str(i).encode(),
value=json.dumps({'order_id': str(i)}).encode(),
on_delivery=tracking_callback,
)
if i == 500:
subprocess.run(['docker', 'kill', 'test-broker-leader'])
producer.producer.poll(0)
producer.producer.flush(timeout=60)
# every record must be accounted for -- either delivered or explicitly
# reported as failed. Silence is the only unacceptable outcome.
assert len(delivered) + len(failed) == 1000
assert len(delivered) >= 500 # everything before the kill should have landedTest 2 — throughput under realistic batch settings
A load test confirms the batching configuration from Part 02 is actually producing the throughput it is meant to. Run it against a realistic message size and volume, not a trivial synthetic payload that compresses and batches differently than real traffic.
import time
def benchmark_producer_throughput(producer, record_count=200_000):
start = time.monotonic()
sent = 0
def count_callback(err, msg):
nonlocal sent
if err is None:
sent += 1
for i in range(record_count):
producer.producer.produce(
topic='orders.events',
key=f"order-{i}".encode(),
value=make_realistic_order_payload(i), # real field shapes, not {"x": 1}
on_delivery=count_callback,
)
producer.producer.poll(0)
producer.producer.flush(timeout=60)
elapsed = time.monotonic() - start
throughput = sent / elapsed
print(f"Sent {sent}/{record_count} records in {elapsed:.2f}s "
f"({throughput:,.0f} records/sec)")
return throughputTest 3 — shutdown does not abandon buffered messages
Send a burst of records, then immediately trigger the shutdown sequence, and confirm the flush timeout in close() is sufficient to drain everything that was still buffered rather than reporting a nonzero unflushed count.
def test_shutdown_flushes_all_buffered_messages():
producer = OrderEventProducer(
bootstrap_servers='localhost:9092',
topic='test.orders.events',
dlq_topic='test.orders.events.dlq',
)
for i in range(5000):
producer.send_order_event({'order_id': str(i), 'status': 'created'})
# deliberately do NOT wait for a natural flush -- simulate a shutdown
# signal arriving immediately after a burst of sends
producer.close(timeout=30.0)
# a correct close() logs and alerts on a nonzero remaining count;
# this test asserts the common case where the timeout is sufficient
assert producer._sent_count + producer._failed_count == 5000Five Misconceptions About Building Kafka Producers
What This Looks Like on Day One
At Affirm: the payments platform team is reviewing a new producer for loan-installment events before it ships. The reviewer's first question, straight from Part 09's checklist, is not about business logic at all — it's "show me the delivery callback and the shutdown handler." The original PR had neither: send() was called with no callback, and there was no signal handling to flush on deploy. Both gaps are the kind that pass every unit test and only surface as silent data loss weeks later during a routine rolling deploy.
At Instacart: a batch-ingestion producer for shopper-location pings is bottlenecked far below expected throughput. Following Part 03's benchmark numbers, an engineer checks the code and finds producer.flush() called after every singleproduce() call — a leftover from an early prototype that nobody removed once real traffic arrived. Switching to async sends with a delivery callback and a 10mslinger.ms takes throughput from roughly 800 records/second to over 60,000/second with no change to the durability guarantee.
At Brex: a transaction-event producer starts throwing BufferErrorduring a traffic spike from a large customer's batch upload. Per Part 06's worked example, the on-call engineer recognizes this as the local producer buffer filling faster than the broker can absorb it — not a broker outage. The immediate fix is the brief-block-and-retry pattern from the worked producer class; the longer-term fix, tracked as a follow-up, is raisingbuffer.memory and revisiting partition count on the target topic.
5 Interview Questions — With Complete Answers
The Mistakes That Make Kafka Producers Unreliable in Production
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓acks, enable.idempotence, and a coherent retries/delivery.timeout.ms budget are business decisions about data loss, not performance knobs to copy from another service.
- ✓Blocking on every individual send defeats client-side batching and can cost roughly two orders of magnitude in throughput compared to async sends with a delivery callback, using identical durability settings.
- ✓A delivery callback error means the producer's own internal retries already ran and failed — the job is to categorize the error (message-specific and non-retriable vs. retriable-but-exhausted) and respond accordingly, typically via a dead letter path or an operational alert.
- ✓Serialization failures happen before produce() is called and never reach a delivery callback, so they need their own explicit error handling and metrics.
- ✓Graceful shutdown means wiring flush() into real signal handling with a timeout under the orchestrator's kill grace period, and treating a nonzero unflushed-message count as an incident, not a log line.
- ✓enable.idempotence deduplicates retries within one producer session only — it does not survive a producer restart and is not a substitute for downstream idempotent processing.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.