Event-driven systems: Kafka, RabbitMQ and queues
Events vs commands, brokers, Kafka partitions and consumer groups, and how to choose the right tool.
In 30 seconds
In an event-driven system, services announce facts ('order placed') and other services react, instead of calling each other directly. A message broker stores and delivers those messages. Kafka is a durable, replayable log built for high-throughput streams; RabbitMQ is a flexible message router; Redis-based queues like BullMQ are a simple fit for background jobs. Choosing well depends on ordering, replay, throughput and how much infrastructure you want to run.
Not in my daily stack — I know the concepts and would learn the details when a project needs it.
Why it matters
- Events decouple producers from consumers: the checkout does not need to know that loyalty, analytics and email all react to an order.
- A broker absorbs traffic spikes and keeps work safe when a consumer is down, which makes systems far more resilient.
- Kafka matters when a team needs high-throughput streams, long retention and replay: analytics pipelines, audit logs, change data capture, many independent consumers of the same data.
- Knowing the delivery guarantees of each tool prevents the classic bugs: lost messages, duplicates and out-of-order processing.
How it works
01Events, commands and brokers
A command asks someone to do something: 'send this email', 'charge this card'. It has one intended handler and can be rejected. An event states a fact that already happened: 'order 42 was paid'. It has no opinion about who listens; zero or ten consumers may react. Mixing these up causes trouble: an 'event' named SendEmail couples the producer to one consumer. A message broker is the post office between them. Producers publish messages to it, it stores them durably, and consumers receive them at their own pace. If a consumer is down, messages wait. If traffic spikes, the backlog grows instead of the database melting. The price is asynchrony: the producer no longer knows when, or whether, the work is done, so you need monitoring of backlogs and failures.
- Command: imperative, one handler, may fail ('ReserveStock').
- Event: past tense, many handlers, immutable fact ('StockReserved').
- Broker: durable buffer and router between producers and consumers.
02Kafka: topics, partitions, offsets and consumer groups
Kafka is a distributed append-only log. Messages are written to a topic, which is split into partitions; each partition is an ordered sequence where every message gets an offset (its position number). Messages with the same key always go to the same partition, so ordering is guaranteed per key (for example, per order ID), never across the whole topic. Consumers join a consumer group; Kafka assigns each partition to exactly one consumer in the group, so the number of partitions caps parallelism. Each group tracks its own committed offsets, which means many groups (billing, analytics, search) read the same topic independently. Messages are not deleted when read: they stay for a retention period or size, or are compacted to the latest value per key. Since Kafka 4.0, clusters run on KRaft without ZooKeeper, and Kafka 4.2 added production-ready share groups for queue-style consumption.
# Create a topic: 6 partitions, 3 replicas, 7-day retention
kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic orders.v1 --partitions 6 --replication-factor 3 \
--config retention.ms=604800000 --config min.insync.replicas=2
# Produce keyed messages (same key -> same partition -> ordered)
kafka-console-producer.sh --bootstrap-server localhost:9092 \
--topic orders.v1 --property parse.key=true --property key.separator=:
# Inspect a consumer group: current offset, end offset and lag per partition
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group billing-service
03RabbitMQ and Redis queues
RabbitMQ is a message router. Producers publish to an exchange, which routes messages to queues through bindings: direct (exact routing key), topic (patterns like 'order.*'), fanout (every bound queue) or headers. Consumers take messages from queues and acknowledge them; unacknowledged messages are redelivered, and rejected ones can go to a dead-letter exchange. Once acknowledged, a message is gone, so RabbitMQ is about delivering work, not keeping history (its stream queues add a log-like mode when you need replay). It shines at per-message routing, priorities and request-reply. Redis-based queues such as BullMQ (Node) or Laravel queues with a Redis driver are the lightest option: jobs with retries, backoff, delays, rate limits, scheduled and repeatable jobs, all on the Redis you probably already run. Their limit is durability and scale: Redis keeps data in memory, so configure persistence and treat the queue as a job runner, not a permanent event store.
04Delivery guarantees in practice
Every broker offers some mix of three guarantees. At-most-once: the consumer commits before processing, so a crash loses the message but never duplicates it; acceptable for metrics, rarely for business data. At-least-once: the consumer processes, then acknowledges; a crash between the two means redelivery, so duplicates happen. This is the realistic default for most systems. Exactly-once: each message affects the result once. Kafka supports it inside Kafka, using idempotent producers and transactions with consumers reading only committed data, for read-process-write pipelines between Kafka topics. As soon as a side effect leaves Kafka (a database write, an email, a card charge), you are back to at-least-once and must make the consumer idempotent: store processed message IDs, use unique constraints or upserts, and design handlers so that running them twice changes nothing.
- Producer side: acks=all and idempotence on (the default in modern Kafka clients).
- Consumer side: commit offsets after successful processing, not before.
- Poison messages: retry with backoff, then move to a dead-letter topic or queue.
05Schemas, versioning and choosing a tool
Events are a public contract, often read by teams you never meet, so treat their shape like an API. Define schemas in Avro, Protobuf or JSON Schema, store them in a schema registry, and enforce compatibility rules: add optional fields freely, never remove or repurpose a field consumers rely on. For breaking changes, publish a new event type or topic version (orders.v2) and run both until consumers migrate. Include an event ID, type, version, timestamp and the entity key in every message. To choose: Kafka for high-volume streams, long retention, replay and many consumer groups; RabbitMQ for flexible routing, task distribution and per-message acknowledgement; Redis queues like BullMQ for application background jobs with retries and scheduling; managed services (SQS, SNS, Pub/Sub, managed Kafka) when you prefer not to operate brokers yourself.
Use it when
- Many independent consumers need the same stream of facts (analytics, search indexing, notifications).
- You need replay: rebuilding a read model, backfilling a new service or auditing history.
- Throughput is high (thousands to millions of messages per second) with ordering per key.
- Work must survive consumer downtime and traffic spikes without losing requests.
Skip it when
- A single app with background jobs: a Redis or database-backed queue is simpler and cheaper.
- The caller needs an immediate answer; use a synchronous API and keep events for side effects.
- The team cannot yet operate and monitor a distributed broker; use a managed service or a simpler queue.
Alternatives
- RabbitMQ
Exchanges and queues with rich routing, acks and dead-lettering; ideal for task distribution.
- Redis + BullMQ / Laravel queues
Lightweight jobs with retries, delays and rate limits; not a long-term event log.
- Managed cloud queues
SQS/SNS, Google Pub/Sub, Azure Service Bus: no brokers to run, pay per use.
- NATS / Redis Streams
Lightweight streaming with consumer groups when Kafka is too heavy.
Best practices
- Name events in the past tense and keep them immutable facts.
- Choose partition keys by the entity that needs ordering, and size partitions for future consumers.
- Make every consumer idempotent; assume each message can arrive twice.
- Publish events through a transactional outbox so database changes and messages never diverge.
- Version schemas with a registry and compatibility checks.
- Monitor consumer lag, dead-letter queues and retry rates, and alert on growth.
Common mistakes
- Expecting global ordering in Kafka; it is guaranteed only within a partition.
- Committing offsets before processing, then losing messages on a crash.
- Using the broker as a database with ad hoc queries instead of building proper read models.
- Hot partitions caused by a skewed key (for example, one huge customer), which cap throughput.
- Changing an event's shape without versioning and silently breaking consumers.
Deep dive
06Durability inside Kafka: replicas, ISR and leader election
Every partition has one leader replica that takes writes and followers that copy it. The in-sync replica set (ISR) is the leader plus followers that are caught up within replica.lag.time.max.ms. With acks=all, the producer gets a success only when every member of the current ISR has the record; min.insync.replicas sets how small the ISR may shrink before writes are refused with NotEnoughReplicas. The common durable setup is replication factor 3, min.insync.replicas 2 and acks=all: one broker can fail with no data loss and no write outage. Keep unclean.leader.election.enable at its default of false; enabling it lets an out-of-sync replica become leader and silently drop acknowledged messages in exchange for availability. Spread replicas across racks or availability zones with broker.rack so one zone outage cannot take every copy.
- RF 3 + min.insync.replicas 2 + acks=all: survives one broker loss safely.
- Producer idempotence and acks=all are client defaults since Kafka 3.0.
- Unclean election trades acknowledged data for availability; leave it off.
07Rebalancing, the new consumer protocol and processing stalls
When consumers join, leave or crash, partitions are reassigned in a rebalance. The classic protocol could pause the whole group; cooperative-sticky assignment reduced that, and the next-generation protocol (KIP-848), generally available since Kafka 4.0, moves assignment to the broker-side group coordinator and rebalances incrementally. Clients opt in with group.protocol=consumer; groups convert during a rolling upgrade. Two timeouts matter. session.timeout.ms detects a dead consumer through heartbeats. max.poll.interval.ms detects a stuck one: if your handler takes longer than this between poll calls, the consumer is evicted, its partitions move and the uncommitted batch is processed again elsewhere. Slow handlers therefore cause duplicate work and rebalance storms. Lower max.poll.records, move long work to a separate queue, or pause partitions while processing.
08Compaction, retention and replay at scale
Retention can be time or size based (delete policy) or key based (compact policy). Compaction keeps at least the latest record per key and removes older ones in the background, which turns a topic into a durable table of current state: customer profiles, product prices, configuration. A record with a null value is a tombstone; it marks the key for deletion and is itself removed after delete.retention.ms, so consumers that fall further behind than that can miss deletions. Combining both policies keeps the latest value per key and still expires very old keys. Replay is the superpower: a new consumer group starting at the earliest offset rebuilds a read model from history. Plan for it: rebuilds can read terabytes, so throttle them, give them their own group, and make sure retention is longer than your worst realistic recovery time.
# A compacted topic holding the latest price per product
kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic prices.v1 --partitions 12 --replication-factor 3 \
--config cleanup.policy=compact \
--config min.compaction.lag.ms=3600000 \
--config delete.retention.ms=86400000
# Rebuild a read model: new group, start from the beginning
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group search-indexer-v2 --topic prices.v1 \
--reset-offsets --to-earliest --execute
09Partition sizing, key skew and throughput tuning
Partitions are the unit of parallelism and ordering, so size them for the consumer parallelism you expect in a year or two, not today: adding partitions later remaps keys and breaks per-key ordering during the change. Too many partitions also cost: more open files, longer leader elections and more metadata. A key with far more traffic than others creates a hot partition that one consumer must handle alone. Fix skew by choosing a finer key (order ID rather than customer ID) when ordering only matters per order, or by salting a known hot key across a few sub-keys and merging downstream. Records without a key are spread by the sticky partitioner, which fills one batch before moving on. On the producer, linger.ms and batch.size trade a few milliseconds of latency for much higher throughput, and compression (lz4 or zstd) cuts network and disk use.
Real-world scenarios
Consumer lag climbs every evening
Problem
An analytics consumer group falls hours behind at peak and only catches up overnight. Adding more consumer instances did not help.
How to solve it
Run kafka-consumer-groups.sh --describe to see lag per partition. If lag sits on one or two partitions, the cause is key skew: change the key or salt the hot key. If it is spread evenly and instances already equal the partition count, extra instances sit idle, so increase partitions on a new topic version and migrate, or speed up the handler by batching database writes. Check for rebalance loops caused by handlers exceeding max.poll.interval.ms. Then alert on lag growth rate and time-to-drain, not on an absolute number.
Duplicate charges after a consumer crash
Problem
A payment consumer occasionally charges a customer twice. Logs show a consumer restart between the charge and the offset commit.
How to solve it
This is at-least-once delivery working as designed; the handler is not idempotent. Give every event a stable ID and pass a derived idempotency key to the payment provider, so a second call returns the original result. Record processed event IDs in the same database transaction as the local state change, with a unique constraint, and skip events already seen. Keep auto-commit off and commit offsets only after processing. Add a reconciliation job that compares local payments with the provider's report daily to catch anything that slipped through.
Choosing a broker for a mid-sized product
Problem
A team runs one application with Redis already in place. They want order events for email, loyalty and a future analytics pipeline, and ask whether to adopt Kafka now.
How to solve it
Start with what the needs are today: a handful of consumers, modest volume, no replay requirement yet. A Redis-backed job queue for side effects, fed by a transactional outbox, covers that with no new infrastructure. Design events as if they were public (IDs, versions, schemas) so moving them to a log later is a relay change, not a rewrite. When analytics or several independent teams need history and replay, add managed Kafka and point the outbox relay at it, rather than running brokers yourself from day one.
Cheat sheet
- Durable producer
acks=all, enable.idempotence=true (defaults since 3.0)- Durable topic
--replication-factor 3 --config min.insync.replicas=2- Consumer group lag
kafka-consumer-groups.sh --bootstrap-server host:9092 --describe --group app- Replay from start
kafka-consumer-groups.sh ... --group g --topic t --reset-offsets --to-earliest --execute- Next-gen rebalance protocol (4.0+)
group.protocol=consumer- Read only committed transactional data
isolation.level=read_committed- Latest value per key
cleanup.policy=compact- Higher producer throughput
linger.ms=10, batch.size=65536, compression.type=zstd- Stop handlers being evicted
Lower max.poll.records or raise max.poll.interval.ms
Questions & answers
Q1Kafka or RabbitMQ: how do you choose?
I ask what the messages are for. If they are a stream of facts that several teams consume independently, need retention and replay, and arrive at high volume, Kafka fits: it is a durable log with consumer groups and per-key ordering. If they are tasks to distribute among workers, with routing rules, priorities and per-message acknowledgement, RabbitMQ fits better and is simpler to reason about. For background jobs inside one application, a Redis-based queue such as BullMQ is often enough. Operating cost matters too; a managed offering removes much of the risk for small teams.
Q2How does Kafka guarantee ordering?
Only within a partition. A producer sends messages with a key, the key is hashed to choose a partition, and inside that partition messages keep their order and get increasing offsets. So if you key by order ID, all events of one order are processed in sequence, while different orders are processed in parallel across partitions. There is no ordering across partitions. Ordering also depends on the producer: with idempotence enabled, retries do not reorder messages. Be careful when you increase the partition count, because keys can map to new partitions and ordering for in-flight keys may break during the change.
Q3What does exactly-once mean in Kafka?
Kafka offers exactly-once semantics for read-process-write flows that stay inside Kafka. Idempotent producers stop duplicates from retries, and transactions write output messages and consumer offsets atomically, so a consumer reading with read_committed sees each result once. That is powerful for stream processing. But the moment your handler writes to a database, calls a payment API or sends an email, Kafka cannot make that external effect exactly-once. In practice you combine at-least-once delivery with idempotent handlers, using unique keys, upserts or a processed-messages table, to get exactly-once effects.
Q4What is consumer lag and why does it matter?
Lag is the difference between the latest offset in a partition and the offset a consumer group has committed: how many messages are waiting. It is the most important health signal of an event-driven system. Growing lag means consumers are slower than producers, because of a bug, a slow dependency, a poison message being retried, or too few consumers. Since one partition is read by one consumer in a group, adding consumers beyond the partition count does not help. I would alert on lag growth over time rather than a fixed number, and pair it with dead-letter and error-rate metrics.
Q5How do you evolve event schemas without breaking consumers?
Treat events as a public API. Define them with Avro, Protobuf or JSON Schema, register them in a schema registry and enforce a compatibility mode, usually backward or full compatibility. Safe changes are adding optional fields with defaults; unsafe changes are removing, renaming or changing the meaning of fields. For a breaking change, publish a new version of the event or a new topic and run both until every consumer migrates, then retire the old one. Each message should carry its type and version so consumers can handle several versions during the transition.
Q6Why not just use a Redis queue for everything?
For many applications you can, and should. BullMQ or Laravel queues on Redis give retries, backoff, delays, rate limiting and scheduling with almost no extra infrastructure. The limits appear with scale and history: Redis keeps data in memory, persistence must be configured carefully, jobs are deleted after completion, and there is no built-in replay for a new consumer that needs last month's events. When several teams need the same stream independently, or you need long retention and very high throughput, a log like Kafka becomes the better tool. Choose by need, not by fashion.
Senior-level questions
S1How would you guarantee per-entity ordering while still retrying failed messages?
Ordering and retries conflict: if message 5 for an order fails and you move on, message 6 is applied first. Options, by strictness. Block the partition and retry in place with backoff; ordering is preserved, but one poison message stalls every key on that partition, so cap attempts and alert. Use a retry topic per delay tier, but also park later messages for the same key until the failed one succeeds, which needs a small per-key state store. Or design handlers to tolerate disorder with versions: each event carries an entity version, the consumer ignores anything older than what it has stored. The last option scales best and survives replays, so I prefer making state changes version-checked and idempotent, keeping strict blocking only for the few flows that truly need it.
S2Self-managed Kafka or a managed service: what do you weigh?
Running Kafka well means capacity planning for brokers and disks, rolling upgrades, partition reassignment, rack-aware replication, monitoring under-replicated partitions and controller health, plus security with TLS and ACLs. That needs real expertise and on-call time. A managed service removes most of it, often adds tiered storage and autoscaling, and the cost is a higher bill per throughput, some lock-in on tooling and less control over versions and tuning. For a team without dedicated platform engineers, managed is almost always cheaper once salaries are counted. Self-hosting starts making sense at large, steady volumes, strict data-location rules or when a platform team already operates similar stateful systems. Either way, keep clients on standard Kafka protocols so moving later remains possible.
S3Event sourcing on Kafka: good idea or not?
Kafka is excellent for distributing events, but it is a weak primary store for event-sourced aggregates. Event sourcing needs to load all events of one entity quickly and to append only if the entity is still at the expected version, an optimistic concurrency check. Kafka has neither per-entity reads, since topics are partitioned by key across many entities, nor conditional appends. Teams that try it end up building a separate snapshot store and fighting race conditions. A better design keeps the event store in a database or a dedicated event store with per-stream versioning, then publishes those events to Kafka through an outbox or change data capture for other consumers. Kafka then does what it is good at: fan-out, retention and replay for projections.
Glossary
- Partition
- An ordered, append-only slice of a topic; the unit of ordering and parallelism.
- Offset
- The position number of a record within a partition.
- Consumer group
- A set of consumers sharing a topic's partitions, with its own committed offsets.
- ISR
- In-sync replicas: the leader and followers caught up closely enough to count for acks=all.
- Rebalance
- Reassigning partitions among a group's consumers when membership changes.
- Log compaction
- Keeping at least the latest record per key and discarding older versions.
- Tombstone
- A record with a null value that marks a key for deletion in a compacted topic.
- KRaft
- Kafka's built-in Raft-based metadata quorum; the only mode since Kafka 4.0 removed ZooKeeper.
- Dead-letter topic
- Where messages go after repeated processing failures, for inspection and replay.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.