Kafka rarely goes down from load. It goes down under its own clients: a deploy that multiplies worker count by ten, each worker holding a connection, a rebalance on every restart, a consumer committing after every record. The cluster stalls for minutes. The team adds brokers. The stalls continue.
Most of the controls that would have caught this ship with Kafka. In the connection-storm incidents we've worked through this year, the broker's connection-rate limit was unset in every one, and in more than one the platform team didn't know it existed. The rest are client mistakes no broker setting reaches, because the broker never sees them.
Kafka Goes Down Because of Its Clients
Picture a tier-zero cluster: a handful of brokers, tens of thousands of partitions. A service that deploys many times a day gets a concurrency change, and its worker count goes up by an order of magnitude. Every worker opens its own connections and joins the same consumer group. On the next redeploy, every one of them leaves and rejoins.
Adding brokers doesn't help, because the part that's saturating doesn't spread. Every consumer group has one coordinator, the broker leading its __consumer_offsets partition, and all of that group's JoinGroup, SyncGroup, Heartbeat and OffsetCommit traffic goes there. With the default RangeAssignor still first in partition.assignment.strategy, every member stops and hands back all its partitions whenever any member changes, so a rolling deploy of N workers is N stop-the-world rebalances landing on one node.
The pull request that starts this changes one number in one service. It passes review. It doesn't touch a Kafka setting, and it's the whole problem.
Kafka Has Rate Limits. Turn Them On.
Kafka ships controls for all of this. They're off by default.
On the broker
| Setting | Default → start with | What it does |
|---|---|---|
max.connections.creation.rate | unlimited → 100/s | New connections per second, per broker or listener (Kafka 2.7+) |
max.connections.per.ip | unlimited → 500 | Concurrent connections from one address |
max.connections | unlimited → 300000 | Total connections per broker |
request_percentage quota | none → 200 | Share of request-handling thread time per client; 100 is one thread's worth |
In the client
| Setting | Default → start with | What it does |
|---|---|---|
reconnect.backoff.max.ms | 1000 → 10000 | Longest pause between reconnect attempts |
retry.backoff.max.ms | 1000 → 10000 | Same, for request retries |
group.instance.id | unset → set on every consumer | Static membership: a restart doesn't trigger a rebalance |
partition.assignment.strategy | RangeAssignor → CooperativeStickyAssignor first | A rebalance no longer stops the whole group |
The broker-side settings all govern load: how many connections, how many bytes, how much CPU. The broker can see all of that, so it can meter it. The client-side settings govern configuration, and they live inside each application, set by whichever team wrote it, in whichever language they used.
The Broker Can't See Client Configuration
A broker quota can't tell you that a producer sent acks=1. It accepts the write and the durability guarantee silently isn't there; min.insync.replicas only matters if the producer asked for acks=all in the first place. It can't require group.instance.id; nothing on the broker knows whether a joining consumer set one. It can't limit OffsetCommit for one group; the request quota sees a principal's total CPU share and can't tell a commit from a fetch.
These are decisions the client made before the request left the application. By the time the broker sees the request, its only choices are to serve it or not, and for settings like these it has nothing that refuses a request because of how the client was configured.
That is what a proxy is for. The broker already has the limits that protect it from load. What it lacks is a place to enforce how clients behave, once, for every application regardless of language, and to log why. A Kafka-protocol proxy sits where every request passes and reads the settings the broker can't.
Four Policies, Four Client Mistakes
Each of the four policies below catches a client doing something that looks fine from inside the application and is expensive from inside the cluster. In every case the broker accepts the request as sent, because nothing in its configuration lets it do otherwise. These are the policies in Conduktor Gateway that map onto the incident at the top of this post, and every violation from any of them lands in Gateway's audit log with the identity that sent the request, the policy it broke, and why.
A producer that skips durability
When a producer writes to Kafka, it chooses how much confirmation it wants before treating the write as done. That choice is the acks setting. With acks=all, the leader broker waits until every in-sync replica has the record before acknowledging. With acks=1, it acknowledges as soon as it has the record itself, while the copies are still in flight. Faster, and if the leader dies in that window the record is gone.
- The mistake.
acks=1oracks=0on a topic that matters, or uncompressed batches on a high-volume one. - Why the broker can't catch it.
min.insync.replicasonly applies when the producer asked foracks=all; a producer that asks foracks=1getsacks=1. The broker can recompress what arrives but can't refuse an uncompressed batch. - What the policy does. Makes both settings mandatory for a given application. A producer that sends
acks=1gets aPolicyViolationExceptionnaming the problem: invalid value for 'acks': 1, valid value is one of -1. - Rollout. Scope it to one service account, a virtual cluster, or everything. Start with
action: INFO, which logs every producer that would have been rejected without rejecting any, then switch toBLOCK.
apiVersion: gateway/v2
kind: Interceptor
metadata:
name: guard-on-produce
scope:
username: payments-app
spec:
pluginClass: io.conduktor.gateway.interceptor.safeguard.ProducePolicyPlugin
priority: 100
config:
acks:
value: [-1]
action: BLOCK
compressions:
value: ["ZSTD", "LZ4"]
action: BLOCK A consumer group that rebalances on every restart
Consumers in the same group divide a topic's partitions between them, and whenever a member joins or leaves, the group pauses and redistributes. That pause is a rebalance. By default a consumer that restarts counts as a new member, so a rolling deploy of six consumers is six rebalances, and during each one nothing in the group is reading.
Kafka's fix is static membership: a consumer that sets group.instance.id to a stable name is recognised when it comes back, and the group holds its partitions for it as long as it returns within session.timeout.ms (45 seconds by default). It's been in Kafka since 2.3, and it's off unless the application sets it.
- The mistake. No
group.instance.id, so every deploy is a full rebalance. - Why the broker can't catch it. A
JoinGrouprequest either carries agroup.instance.idor it doesn't, and the broker accepts both. - What the policy does. Accepts only the first kind. A
JoinGroupwithout one is rejected. - Caveat. The
JoinGroupresponse has no field for an error message, so the consumer sees a generic failure and the reason appears in Gateway's audit log. Run it inINFOmode first and that audit log is a list of every consumer in the fleet not using static membership, which is usually the most useful output of the whole exercise.
apiVersion: gateway/v2
kind: Interceptor
metadata:
name: enforce-static-group-membership
spec:
pluginClass: io.conduktor.gateway.interceptor.safeguard.ConsumerGroupPolicyPlugin
priority: 100
config:
groupInstanceId:
value: .*
action: BLOCK A deploy that never stops rebalancing
Every consumer group has one broker acting as its coordinator, and every rebalance runs through it. When a deployment is crash-looping or a large group is rolled quickly, rebalances arrive faster than the coordinator can finish them, which is the pile-up in the timeline above.
- The mistake. A fast or unstable deploy that rejoins the group faster than it can settle.
- Why the broker can't catch it. Its request quota slows a client that uses too much CPU, but it can't tell a
JoinGroupfrom a fetch, so it can't target rebalances. - What the policy does.
LimitJoinGroupPolicyPlugincapsJoinGrouprequests per minute, with a separate limit for each consumer group, so one team's crash loop can't spend another team's allowance. Size it to the group: under the classic protocol every membership change makes every member rejoin, so 50 to 100 a minute suits 10 to 20 consumers. - Caveat. It watches the classic protocol's
JoinGroup. A consumer on the KIP-848 protocol sendsConsumerGroupHeartbeatinstead, which it doesn't see.
A consumer that commits after every record
After a consumer processes records, it commits its position so a restart picks up where it left off. By default that happens every five seconds, about seventy commits a minute for a group of six. Some applications turn auto-commit off and commit after every record so that a crash never replays more than one, and a consumer handling a few hundred records a second is then sending a few hundred commits a second.
- The mistake.
enable.auto.commit=falseand a commit inside the poll loop. - Why the broker can't catch it. Every commit is a write to
__consumer_offsetson the coordinator broker, the same one handling the group's rebalances, and the broker has no per-group limit on them. - What the policy does.
LimitCommitOffsetPolicyPlugincapsOffsetCommitrequests per minute, per consumer group. It never rejects a commit; it throttles, and the wait grows with how far over the limit the client is. A slightly chatty consumer barely notices, a runaway one is slowed hard. Twenty a minute leaves a healthy small group untouched.
Together, the two limits change what the coordinator spends its minute on:
Where to Start
- Turn on the broker's limits.
max.connections.creation.rateand a request quota. They protect the broker from load; connection-rate limits have been there since Kafka 2.7 and request quotas far longer. - Fix the client defaults that should have been defaults.
group.instance.idon every consumer,CooperativeStickyAssignorfirst, backoff caps well above one second. - Enforce the client settings the broker can't see, at the proxy. Start every policy in
INFO, read the audit log for a week, then move toBLOCK. The teams you'd have had to convince one by one never change anything.
Adding brokers treats the symptom. Central traffic enforcement is the remedy.
Related: What a Kafka Proxy Can Do: From Routing to Enforcement → · Most Kafka Guardrails Don't Protect Your Data → · Quotas and Rate Limiting in Kafka →
