ZephyrCode teardown · streaming · No. 01

The Kafka defaults that page you at 3am.

Apache Kafka’s own docs say these defaults must be tuned before production — yet they ship in the quickstarts and the compose files teams paste in and forget. Here are eight, each with the exact key, the mechanism, and the one-line fix. This is the format a fixed-price ZephyrCode audit produces, run against Kafka’s own reference configuration instead of your system.

What this is (and isn’t)

Every default below is from the Apache Kafka 4.1 reference docs, linked at each finding. The subject is Kafka’s own documented defaults and the widely-copied single-broker example configs — not any team’s private production system, which nobody can observe from outside. Before/after figures are labelled typical: representative of the failure class, not any client’s numbers.

Three things you’ll read elsewhere that are stale — and that this teardown is careful not to repeat: (1) “Kafka defaults to acks=1” — false since 3.0; the producer default is acks=all and the surviving trap is a weak min.insync.replicas (Finding 1). (2)unclean.leader.election defaults to true” — false since 0.11; it’s false by default, so it isn’t listed here. (3) “the default assignor is cooperative” — the default is a list with RangeAssignor first, so you still get stop-the-world rebalances until you change it (Finding 5).

8 findings · High → Medium
High severityFINDING 01 / 08

acks=all that isn’t

Pages the on-call the morning after a single broker bounce eats “committed” writes.

Since 3.0 the producer default is acks=all — but acks=all waits for the in-sync replicas, not all replicas. When a follower lags or restarts it’s ejected from the ISR rather than blocking writes, so “all” silently shrinks toward just the leader. With min.insync.replicas left at 1, Kafka happily acknowledges a write that lives on exactly one disk; if that leader then dies, the acknowledged record is gone. The strong-looking default is neutralised by the weak one next to it.

# broker / topic — the trap that survived the 3.0 change
min.insync.replicas = 1◂ acks=all now means “one disk”
# producer (already the 3.0+ default — not the problem)
acks = all
Before · default
Silent acceptance of writes during ISR shrink
After · fixed
0 acknowledged-then-lost records
Recommended fix

Set min.insync.replicas=2 with replication.factor=3; acks=all is already the default.

Tradeoff — A partition that drops below 2 in-sync replicas now rejects writes (NotEnoughReplicasException) — availability traded for durability, the correct trade for anything you can’t reproduce.

High severityFINDING 02 / 08

One disk from a topic-shaped hole

Pages whoever owns the disk that died.

default.replication.factor is 1 and auto.create.topics.enable is true. Any topic that gets auto-created — a typo’d topic name from a producer, a Connect connector, a mis-set consumer — lands as a single unreplicated partition. There’s no second copy: a single disk or broker loss is unrecoverable, and the partition is fully unavailable while that broker is down. No one ever ran a create-topic command.

default.replication.factor = 1      # one copy only
auto.create.topics.enable  = true◂ a typo mints a topic
num.partitions             = 1
Before · default
One bad-disk event = permanent loss of an auto-created topic
After · fixed
Survives loss of any single broker with zero data loss
Recommended fix

default.replication.factor=3, auto.create.topics.enable=false (create topics explicitly via IaC), and set num.partitions deliberately per workload.

Tradeoff — Explicit topic management is more ceremony; RF=3 roughly triples storage and replication bandwidth for that data.

High severityFINDING 03 / 08

The compose file everyone copies

Pages the person who scaled a “dev” cluster into prod.

The broker default for offsets.topic.replication.factor is actually 3 — but the single-broker example compose files teams copy override it to 1, because a one-node cluster can’t do more. __consumer_offsets stores every consumer group’s committed positions; at RF=1 it lives on one broker. Lose that broker and every group’s offsets vanish, so on recovery consumers fall back to auto.offset.reset (Finding 4) and either replay everything or skip everything. Teams copy the single-broker example, add brokers, and never revisit the internal-topic replication factor.

# what the single-broker quickstart forces — and you inherit
offsets.topic.replication.factor         = 1◂ every group’s offsets, one disk
transaction.state.log.replication.factor = 1
Before · default
Broker loss wipes all committed offsets
After · fixed
Offsets survive single-broker loss — no group-wide replay/skip event
Recommended fix

offsets.topic.replication.factor=3, transaction.state.log.replication.factor=3, transaction.state.log.min.isr=2 on any multi-broker cluster.

Tradeoff — These internal topics can’t be created until ≥3 brokers exist — a bootstrapping ordering constraint.

Medium severityFINDING 04 / 08

The silent data-skipper

Pages the data team when a new consumer “processed everything” but the warehouse is missing a day.

auto.offset.reset defaults to latest. When a group has no committed offset — a brand-new group, or offsets that expired or were lost per Finding 3 — latest starts at the end of the log. Every record already in the topic is skipped, silently, with no exception, and lag reads ~0. A newly deployed consumer group looks perfectly healthy while dropping its entire backlog.

auto.offset.reset = latest◂ new group starts at the END
Before · default
New consumer silently skips the backlog
After · fixed
Explicit start position; zero silent gaps
Recommended fix

Choose deliberately: earliest for pipelines that must not drop backlog; none to fail loudly on a missing offset and force an operator decision.

Tradeoff — earliest on a fresh group against a long-retained topic can replay millions of records — surprising, and duplicative if downstream isn’t idempotent.

High severityFINDING 05 / 08

Rebalance storms on every deploy

Pages the service owner during every rolling deploy.

group.protocol defaults to classic, and the default partition.assignment.strategy is a list — [RangeAssignor, CooperativeStickyAssignor] — with Range used first. Under the classic protocol with Range active, a rebalance is stop-the-world: every consumer revokes all of its partitions and blocks until reassignment completes. A rolling deploy bounces N instances, so a single deploy can cause N stop-the-world pauses and a cascade of duplicate JoinGroup rounds — and you get that eager disruption even though CooperativeSticky is sitting right there in the list, because Range is listed first.

group.protocol                = classic◂ stop-the-world rebalance
partition.assignment.strategy = [RangeAssignor,   # used FIRST
                                 CooperativeStickyAssignor]
Before · default
Every deploy = full-group pause + lag spike
After · fixed
Only reassigned partitions pause — a single-digit-second lag blip
Recommended fix

(a) Set partition.assignment.strategy=CooperativeStickyAssignor — a single rolling bounce migrates you off Range, so unaffected partitions keep processing. Or (b) on 4.x, opt into the next-gen protocol with group.protocol=consumer (KIP-848).

Tradeoff — Cooperative rebalancing requires a two-phase migration if you’re currently eager; the assignor swap must follow the documented upgrade path. KIP-848 is opt-in — classic remains the default through 4.x.

Medium severityFINDING 06 / 08

The consumer that keeps getting kicked out

Pages the team whose consumer flaps under load.

max.poll.interval.ms is 300000 (5 minutes) and max.poll.records is 500. A background thread heartbeats every 3 seconds, so the consumer looks alive — but if the application takes longer than 5 minutes to process one poll() batch of up to 500 records (a slow DB write, an external API, a GC pause), Kafka assumes the instance is stuck, evicts it, and reassigns its partitions. The consumer that inherits them gets the same slow batch and can also time out — a self-reinforcing eviction/rebalance loop that reads as flapping.

max.poll.interval.ms = 300000   # 5 min — one batch must finish inside this
max.poll.records     = 500◂ 500 slow records/poll blows the budget
Before · default
Flapping group under load spikes
After · fixed
Stable membership; no eviction-driven rebalance loop
Recommended fix

Lower max.poll.records so a batch comfortably fits inside the interval, and/or raise max.poll.interval.ms to bound worst-case processing; move genuinely long work off the poll thread.

Tradeoff — A larger interval means a genuinely dead consumer takes longer to detect and its partitions longer to reassign.

Medium severityFINDING 07 / 08

At-most-once hiding as at-least-once

Pages the team investigating records that were “consumed but never processed.”

enable.auto.commit is true and commits on a 5-second timer, based on what poll() returned — not on what your code finished processing. If the consumer crashes after poll() but before handling the records, those offsets may already be committed, so on restart they’re skipped: silent loss. Crash the other way — between processing and the next auto-commit — and they replay. Either way, your delivery semantics are decoupled from your actual processing boundary and set by a timer.

enable.auto.commit      = true◂ commits on a timer, not on success
auto.commit.interval.ms = 5000
Before · default
Silent skip/replay tied to a 5-second timer
After · fixed
Commit boundary aligned to processing — the semantics you actually chose
Recommended fix

enable.auto.commit=false, and commit explicitly after successful processing (commitSync per batch, or careful manual async).

Tradeoff — More code and a small throughput cost from synchronous commits; you must make processing idempotent to tolerate the at-least-once replays this enables.

Medium severityFINDING 08 / 08

Retention outlives nobody’s assumptions

Pages the team when a lagging consumer’s data ages out, or when a disk fills.

log.retention.hours is 168 (7 days) and log.retention.bytes is -1 (unbounded). Two traps in one. Size retention is off by default, so a topic’s disk usage is bounded only by time — a traffic spike or a stuck compaction can fill the disk and take brokers down. And the 7-day time bound means a consumer that falls more than a week behind loses data even with auto.offset.reset=earliest, because the records are already deleted; the offset it wants no longer exists. “earliest = never lose data” is false once retention has passed.

log.retention.hours = 168   # 7 days — a >1wk-behind consumer loses data
log.retention.bytes = -1◂ size retention OFF — disk bounded by time only
Before · default
Disk-full broker outage / silent purge for lagging consumers
After · fixed
Bounded disk + lag alerting before the retention edge is hit
Recommended fix

Set retention intentionally per topic (time and/or log.retention.bytes) to match the worst-case consumer-downtime SLO and available disk; alert on partition-disk headroom and on consumer lag approaching the retention window.

Tradeoff — Longer/larger retention costs disk; shorter retention risks purging data a slow consumer still needs.

That’s one config file. Now picture your system.

This is the exact deliverable format of a ZephyrCode audit — findings with the mechanism, the evidence, and the fix, ranked by what pages you. Run against your Kafka, your inference stack, your Postgres. Fixed scope, fixed price, two weeks. Not sure it’s worth it? Start with the $1,200 48-hour triage.

ZephyrCode · engineering audits · the price is on the door