Brokers, Logs, and Queues
The shape of the whole thing
Six stages, from the word that hides two opposite data structures to the discipline of choosing a carrier on purpose. Read it top to bottom, or jump to the part you came for.
"Broker" is one word for two data structures built on opposite ideas — and the shared vocabulary hides which one deletes what it hands out.
Three familiar-looking ways to choose a carrier, in the order teams reach for them, and the incident each one is one workload-change away from.
Queue semantics vs log semantics: what each buys, where ordering actually lives, and why retention is the line between a source of truth and a slogan.
Queue-or-log is a per-workload fit, not a universal winner — and two companies that reached opposite, equally correct answers.
Seven carriers, one semantic axis: Kafka, Kinesis, Redis Streams, SQS, RabbitMQ, Pub/Sub, and Pulsar as positions, each with one sharp edge, not a feature-list shootout.
When this decision earns real attention, who actually pays for getting it wrong, and the exercises that make you go find your own mismatch.
Introduction
The message bus was RabbitMQ, and it was RabbitMQ because that's what we already ran. We'd used it for years to move background jobs around — send this email, generate that thumbnail, retry the failed one. It was reliable, the team knew its quirks, and standing up a second piece of infrastructure next to it was not a fight anyone wanted. So when we went event-driven, the decision took about 4 seconds. Events are messages. We have a thing that moves messages. Put the events on the thing that moves messages.
For a year it was fine, which is the dangerous kind of fine. Orders were placed, OrderPlaced went onto a topic, billing and fulfillment each read it and did their jobs. The demo worked. Staging worked. Production worked.
Then someone asked for a search index over historical orders, we went to build a new consumer, and there was nothing to consume. The events from last week were gone. They'd been delivered to billing and fulfillment, both had acknowledged them, and RabbitMQ had done exactly what a queue is supposed to do with an acknowledged message: it deleted it. There was no history. There was never going to be a history. To build the search index we went back to the orders database and reconstructed a year of events we'd been publishing all along and throwing away the moment two consumers had seen them.
Nobody made a bad call in a code review. We made one bad call in a hallway, in 4 seconds: we chose the carrier by what we already ran, without asking what the workload needed. And that's easy — almost the default — because a queue and a log look identical from a distance. They share a vocabulary. Both have producers and consumers. Both let you publish. Both will carry your OrderPlaced without complaint. What they will not both do is let you read that event a second time, and you find that out on the day you need it.
A delete-on-consume carrier keeps no history, so any future need to replay or add a consumer has nothing to read. Find the stream in your system you'd have to rebuild from the source database, because the messages themselves are already gone.
That is the decision this chapter is about, and Chapter 2 walked you right up to it. Chapter 2 said every message has an intent — fact, command, or notification — and that intent decides your coupling. This chapter says the intent also decides your carrier, and that the two families of carrier are far more different than their shared vocabulary admits. Facts want a log. Commands often want a queue. Choose by habit instead of by intent, and you don't learn you were wrong until the workload asks the carrier for something it was never built to give.
Here's the ground we'll walk together:
- Why "broker" is one word for two genuinely different data structures — a to-do list you cross things off, and a ledger you only ever append to — and how the shared vocabulary hides the gap
- What each one actually buys you: queues give work distribution and backpressure for free; logs give replay, fan-out, and time travel — and you pay full price for whichever one you didn't pick
- Where ordering actually lives, which turns out to be the consumer model, not the broker — competing consumers, consumer groups, and independent readers each keep a different promise
- Why retention is a design decision that decides whether your log is a source of truth, not a cost knob the platform team tunes in the dark
- How to read the carriers — Kafka, Kinesis, SQS, RabbitMQ, Pub/Sub, Redis Streams, Pulsar — as points on one semantic axis rather than as brands, so you can pick the one the workload needs and know what you gave up
By the end, "let's just put it on the message bus" should sound to you like the beginning of a question, not the end of one.
The Problem, Precisely
The vocabulary is shared. The semantics are opposite. You pay for the difference on the day you need the half you didn't buy.
Start with the word that causes the trouble: "broker." One word, for two things built on opposite ideas.
The first is a queue. A queue is a to-do list. Work goes in, a worker takes an item off, does it, and crosses it off — and crossing it off means the item is gone. A task gets done once, by one worker, and then leaves. When two workers share a queue they don't each get a copy; they race, one wins the item, the other never sees it. That's not a bug — it's the feature. It's how you spread a pile of work across ten machines.
The second is a log. A log is an append-only ledger. Records go on the end, each gets a position — an offset — and readers read forward from wherever they are. The defining property is what doesn't happen when you read: nothing. Reading a record doesn't remove it. Ten readers can read the same record, each at its own pace and bookmark, and it sits there untouched until it ages out on a schedule you set. This is the event stream from the Internet-Scale Product Systems atlas, and its superpower is exactly the thing the queue refuses to do: it keeps what it has already handed out.
Now watch the vocabulary do its damage. Both have "producers." Both have "consumers." Both let you "subscribe to a topic." Kafka calls a unit of parallelism a partition; RabbitMQ calls a routing rule a binding; SQS calls the retry window a visibility timeout; Pulsar calls a reader a subscription and means four different things by it depending on mode. The words rhyme across the products, and the rhyme convinces you the concepts are the same. They are not. Underneath the shared nouns, two data structures disagree about the single most important question you can ask a carrier: after a consumer reads a message, does it still exist?
Put a number on it, because the number is the whole chapter. Say a message has been read, and you later need it again — a new consumer, a rebuilt read model, a bug fix to reprocess. On a queue, the count of messages still readable after they're acknowledged is zero. Not "hard to reach." Zero, by design. On a log with a retention window of R days, it's every message from the last R days, readable by any number of new consumers, as many times as you like, because reading never consumed anything.
Here's the part that catches experienced people: none of this is visible while it's working. On day one you have one producer and two consumers, every message is read once by exactly whom you expected, and the queue and the log are indistinguishable — because you aren't yet using the one capability that separates them. The difference isn't a feature you can see in the happy path. It's a future you either kept open or quietly closed, in a hallway, in 4 seconds, a year before it mattered.
Naive Solutions, and What They Cost
Before the two models, the three ways teams actually pick a carrier before they've been burned. They show up in roughly this order, because each is the path of least resistance at the moment it's chosen.
Pick the one we already run. The hallway decision from the opening, and the most common by far. You already operate a queue for background jobs, or a managed queue is one click away, so events go there — one fewer system to run, learn, and page on. It works right up until a workload needs the capability your carrier deleted: pick a queue and that day is when you need replay or fan-out and the history isn't there; pick a log for what was really a work queue and the day is coming too. The cost is a migration of load-bearing infrastructure, the kind you schedule around a reorg.
Standardize everything on the log. The correction to the first, overshooting. Someone reads that Kafka is a superset — retention and fan-out and replay — and concludes the log is a strictly better queue, so the whole org should run one broker and make it the log. On paper, airtight. In practice a log is bad at being a queue: you want to hand ten thousand image-resize jobs to a pool of workers, do each once, delete the successes, and set aside the one that keeps failing — and a log does none of that naturally. No per-message delete, no visibility timeout to reassign a stuck job, and a poison job sits at the head of its partition blocking everything behind it, because a log is ordered and a queue is not. "One broker to rule them all" buys operational simplicity and bills you in workarounds.
Pick by the throughput benchmark. The one that wears the costume of rigor. Someone measures messages per second and picks the winner — optimizing the dimension that rarely decides anything and skipping the one that does: work distribution or replay. A carrier 30% faster and semantically wrong still ends in the migration.
Failure Modes Worth Naming
The field guide before the framework. Each of these is a carrier-choice decision coming back as an incident.
| Failure mode | Trigger | Symptom on-call sees | Cost |
|---|---|---|---|
| Carrier by familiarity | Carrier chosen by what you run / what the talk used, not by the semantics the workload needs | Months later a routine request ("add a consumer", "rebuild this") is suddenly impossible | A migration of load-bearing infrastructure |
| No history to replay | A delete-on-consume queue chosen for what became a source-of-truth stream | A new consumer or read-model rebuild has nothing to read; you reconstruct from the source DB | The reconstruction project, plus the gap you can never fully backfill |
| Fighting the log | A log chosen for a work-distribution workload | Offset gymnastics, tombstones, filter-and-skip code; one bad message blocks a partition | Home-grown queue features, badly, on top of a log |
| Retention cliff | Retention lowered for cost on a log you call a source of truth | A replay or audit reaches back past the cliff and finds nothing | Silent, permanent data loss, discovered during a rebuild |
Notice the "Why it hides" column: every one is invisible on the single-consumer happy path and surfaces only when the stream is used a second way — a new reader, a rebuild, a replay, an audit. That's the signature of a carrier mismatch: the first workload never tells you that you chose wrong; the two after it do, and by then the choice is load-bearing. The rest of the chapter is about making that choice on purpose, up front, when it's cheap.
The Two Models
Everything above reduces to two models. Learn them as sets of semantics, and every product becomes a variation you can reason about instead of a brand to memorize.
Queue semantics: compete for work, then delete. A queue hands each message to exactly one consumer among however many are listening, waits for an acknowledgment, and deletes the message on the ack. If the consumer fails to ack within a window — crash, timeout — the message becomes visible again and another consumer gets it. That redelivery is where at-least-once delivery comes from, and why you'll see some messages twice; surviving it is Chapter 4's subject. Queue semantics buy two things that are hard to get otherwise. First, work distribution: add consumers and the pile drains faster, with no coordination, because the queue deals out the work. Second, backpressure for free: the depth of the queue is your backlog — growing means you're behind. You don't compute that signal; it's the length of the list.
Log semantics: append once, read many, delete never (on read). A log appends each record at the end, gives it an offset, and lets any number of consumers read forward from any position, each tracking its own offset — its bookmark — independently. Committing an offset is not a delete; it moves a bookmark. The record stays where it was, available to the next consumer, and to the same consumer tomorrow if it resets. What this buys is the mirror image of the queue's gifts. Replay: a new consumer starts at offset zero and reads all of history; an existing one can rewind. Fan-out: ten teams read the same stream without touching each other, because none consumes it in the deleting sense. Time travel: "what did the stream look like last Tuesday" is answerable, because last Tuesday is still there.
A queue is a to-do list: the value is in crossing things off. A log is a ledger: the value is in never crossing anything off. Ask which one your workload wants, and you've picked your carrier. Ask which broker you already run, and you've picked your next migration.
This is why Chapter 2's intent maps onto the carrier so cleanly. A fact — OrderPlaced, immutable, published for anyone who cares — wants a log, because many consumers will read it at their own pace and replay it later. A command — CaptureFunds, addressed, done once — wants a queue, because a command is a task, and tasks want to be dealt out, done, and crossed off. A notification can ride either, which is exactly why it makes you think. The intent you named in Chapter 2 was already telling you which model to reach for; choosing by familiarity is what it looks like to not listen.
Where Ordering Lives
Here's the question that surprises people who did everything else right: where does ordering come from? Not from the broker. Ordering is a property of the consumer model, and there are three of them.
Competing consumers is the queue's model, and it trades ordering away on purpose. Many workers share one queue; each message goes to one worker; the workers race. This is what gives you work distribution — but the moment you have more than one worker, global order is gone, because worker two might finish message five before worker one finishes message four. If the messages are independent tasks, you don't care, and this is right. If they're a sequence that must be applied in order, competing consumers will corrupt it — out-of-order processing, exactly as the Internet-Scale Product Systems atlas described it.
The consumer group is the log's model, and it's the clever one. A log is split into partitions, and a consumer group hands each partition to exactly one member. So you get parallelism — P partitions read by up to P members at once — but within a partition, records are read in offset order, by one member, in sequence. The trade is explicit: give up global order across the stream, keep per-partition order plus parallelism up to the partition count. This is the ordering-versus-parallelism trade, and it's why partitioning is a modeling decision, not a tuning knob. Key each entity's events to the same partition — entity-keyed partitioning, Chapter 5's whole subject — and every event for order 4815 lands on one partition, read in sequence by one member, so per-entity order survives even as different entities process in parallel. You don't get order across entities; you almost never need it. That's the bargain, and it's a good one.
The independent reader is the log's other model, and the one a queue simply can't offer. Each consumer reads the whole stream on its own offset, caring nothing for any other. This is fan-out: search, analytics, and billing all read every OrderPlaced, none competing, none blocking another. On a queue this is impossible by construction — the first reader to ack deletes the message out from under the others.
Competing consumers: the colors interleave chaotically across three workers — order visibly lost.
One consequence to name and hand off. Because a consumer group reads a partition in strict sequence, a single message that can't be processed doesn't get skipped — it sits at the head and blocks everything behind it on that partition. That's head-of-line blocking, the dark side of ordering, and it's the entire subject of Chapter 12. I'll name it here and leave the poison pills, dead-letter routing, and retry topologies where they belong.
Retention Is a Design Parameter, Not an Ops Setting
There's a number in your log's config that decides whether "the log is our source of truth" is true or a slogan, and it usually lives in a file owned by whoever runs the cluster, next to the disk quotas. It's retention.
The log-retention trade is the one from the Internet-Scale Product Systems atlas, and eds sharpens it into a rule: retention is where your replay-ability actually lives. A log's whole advantage over a queue is that it keeps what it hands out — but only for the retention window. A log with 3 days of retention is not a source of truth. It's a 3-day buffer wearing a source-of-truth costume. Everything log semantics promised — replay, backfilling a new consumer, time travel, rebuilding a read model — is available only back to the cliff, and no further.
This is why retention can't be tuned in the dark. The person who lowers it from 30 days to 7 to save disk is making an architectural decision about how far back your system can rebuild itself — and if they don't know that's what they're doing, you get the retention cliff: nothing breaks the day retention drops, and then months later a replay reaches for the history you were counting on and finds nothing. The data isn't hard to get. It's gone.
A log is a source of truth only as far back as its retention reaches. Past the cliff, it's a queue that was slow to delete. If a stream is load-bearing truth, its retention is a design decision with your name on it, not a disk-cost knob with the platform team's.
The escape hatches — compaction that keeps the latest record per key forever, tiered storage that pushes old segments to cheap object storage so retention stretches to years without the disk bill — are real, and they're how teams run logs that genuinely are systems of record. But they're choices made on purpose, priced against the replay horizon you need. How you use that horizon — replaying or backfilling without re-firing every side effect — is Chapter 11's subject. This chapter's point is prior: you only have a horizon if retention gave you one.
Tradeoffs Worth Arguing About
Two decisions here don't have a universal answer. They have a per-workload one and a per-org one, and the job is to make each on purpose.
Queue versus log: which semantics does this workload need? The queue side buys work distribution and free backpressure — deal out tasks, drain them across workers, read the backlog off the depth — and costs you memory: the moment a message is acked it's gone, and every future need to re-read it is foreclosed. The log side buys replay, fan-out, and time travel — the whole future stays open — and costs you work distribution: everything is retained whether you wanted it or not, there's no per-message delete, and compete-for-work must be approximated with partitions and consumer groups built for ordering, not for load-balancing independent tasks.
So there's no winner, only a fit. A stream of facts that many teams will read, replay, and rebuild from leans hard toward the log — that's most event-driven work, which is why this atlas talks about logs so much. A stream of addressed commands executed once and forgotten leans toward the queue — and reaching for a log there is fighting the log. The tell that you chose wrong is always the same: you're building, on top of your carrier, the feature the other model would have handed you for free. Queue people hand-rolling an event store. Log people hand-rolling visibility timeouts. When you catch yourself doing that, you didn't hit the limits of the tool — you picked the wrong one and are paying to convert it.
One broker or two? This one is organizational, and it genuinely cuts both ways. Standardizing the org on a single broker is a real good: one system to operate, one set of failure modes, one pager rotation. Running both because different workloads want different semantics is also a real good: each workload gets the model it needs and nobody fights the tool. It depends on your workload mix and operational maturity. A team whose work is overwhelmingly fact streams should standardize on a log and route its handful of true work-queues through consumer groups, eating a little friction to avoid a second system. A team with a genuine mix should run both and stop apologizing for it, because forcing either onto the wrong model costs more than the second system does. What you don't get to do is pretend the two models are one so you can feel like you standardized. That's not simplicity; it's the carrier-by-familiarity failure with a strategy deck.
You never get work distribution and replay from the same semantics for free; one is a queue's gift and the other is a log's. Standardize when your workloads truly share a shape. Run both when they don't. Just never decide it by which logo you already have a login for.
What the Companies Actually Built
LinkedIn built Kafka because a queue could not do what they needed, and it's the cleanest illustration of this chapter you'll find. Around 2010 they had the problem every growing company gets: dozens of systems all needing the same streams of activity data — every profile view, click, and connection. Search needed it. Analytics needed it. Monitoring needed it. The batch jobs loading Hadoop needed it, on their own schedule, hours later. A queue was exactly the wrong shape, for the reason this chapter keeps hammering: the first consumer to read an event would consume it and the others would never see it. Stand up a separate queue per consumer, and the producer now has to know every consumer — the coupling events were supposed to kill.
So they built a distributed commit log instead — the thing Jay Kreps later wrote up in the 2013 essay "The Log," still the best few thousand words on why this data structure matters. The insight was to stop thinking of it as messaging and start thinking of it as an append-only log many readers consume independently, each at its own offset. Search reads the whole stream; analytics reads the whole stream; Hadoop reads it 6 hours later from wherever it left off. None competes with or blocks the others, and a consumer added next year starts at the beginning. That is log semantics, and Kafka became the default event carrier because this shape is what "event-driven" almost always wants. The lesson isn't "use Kafka" — it's why: many independent consumers who needed to replay, and that requirement, not a benchmark or what they already ran, chose the model.
And now the example that complicates the chapter's own advice, because a chapter that only praises logs is lying to you. Instagram — like many Python-heavy shops — ran huge volumes of asynchronous work through Celery on a queue broker (RabbitMQ, with Redis in the mix): notifications, activity fan-out, the tasks that should happen just after a request but not inside it. Here a queue isn't the lazy choice; it's the correct one. These are commands in Chapter 2's sense — addressed tasks executed once and crossed off. You want compete-for-work so a pool of workers drains them, and ack-and-delete so a finished task disappears. And you emphatically do not want a log's replay: nobody ever wanted to re-send 10 million notifications by replaying a topic from offset zero. Reaching for Kafka here would be fighting the log.
The two examples are the whole argument in miniature. LinkedIn had many replay-capable readers of facts, so the log won. Instagram had a firehose of fire-and-forget tasks, so the queue won. Same question — what does the workload do with the messages — two opposite, correct answers.
Technologies Worth Knowing
The point here is to read the products as positions on the queue-versus-log axis, each with one sharp edge.
Apache Kafka is the archetypal log: partitioned, offset-addressed, retained, built for many independent consumers and replay. Its sharp edge is the one this chapter keeps circling — it is not a work queue, and the years of teams trying to use it as one are why the project added share groups (KIP-932) to bolt on queue-like compete-for-work. Read that as the log admitting, from the other side, that the two models are different. If Kafka needed a feature to act like a queue, a queue was never just a slow Kafka.
AWS Kinesis is log semantics as a managed service — the AWS-native answer to Kafka. Shards are the partitions, sequence numbers are the offsets, records are retained (24 hours by default, up to 365 days if you pay for it), and multiple consumers read independently, with enhanced fan-out when each needs its own throughput. Its sharp edge is the shard: capacity is something you provision, and resharding a hot stream is real operational work, not a slider. Kinesis gives you the log's replay and fan-out with a capacity model you have to plan and a retention bill that grows the further back you keep the truth.
Redis Streams is the one that blurs the line on purpose. It's a genuine log — entries appended with monotonic IDs you read by range — but it also lets a consumer group acknowledge entries one at a time with XACK and reclaim stuck ones with XCLAIM, which is queue-like per-message handling grafted onto log storage. Its sharp edge is where the log lives: in memory. Retention is bounded by RAM and by the MAXLEN you trim to, so Redis Streams is a superb low-latency buffer and a poor long-horizon source of truth — the log you reach for when the replay window is minutes to hours, not years.
AWS SQS is the archetypal managed work queue: nothing to operate, compete-for-work, a visibility timeout for redelivery, delete-on-ack. Its sharp edge is that it's a queue all the way down — standard SQS doesn't even promise ordering, FIFO SQS gives ordering within a message group at reduced throughput, and neither offers replay, because an acked message is gone. SQS is the right answer astonishingly often, and the wrong answer the instant someone says "replay."
RabbitMQ is the archetypal routing broker: an AMQP system whose real sophistication is getting a message to the right queue via exchanges and bindings. Its sharp edge is that its center of gravity is the queue — flexible routing into queues you consume and delete from — and while it added Streams in 2021 for log-like retention and replay, the default mental model, and the one most deployments are built on, is still the work queue. Which is exactly how a team ends up in the opening of this chapter: RabbitMQ is a superb queue, and its excellence at being one is what makes putting events on it feel safe.
Google Cloud Pub/Sub leans queue with a replay bolt-on, and it's worth reading precisely because it doesn't fit the binary cleanly. Each subscription gets its own copy of the topic — so you fan out across subscriptions — and within a subscription you ack messages against an ack deadline that behaves exactly like a visibility timeout: queue semantics, per subscription. Then message retention plus seek lets you replay within a window, and Pub/Sub Lite adds partition-based log semantics for those who want them. Its sharp edge is that the default is per-subscription ack-and-redeliver, with replay as a feature you switch on rather than the native shape — and if you actually want a pure task queue on GCP, that's Cloud Tasks, not Pub/Sub.
Apache Pulsar is the interesting one, because it refuses the binary. It offers both models in one system: a shared subscription behaves like a queue (compete-for-work), while an exclusive or failover subscription plus retention behaves like a log (ordered, replayable), with storage managed separately from serving so one shape doesn't cripple the other. Its sharp edge is the honest cost of that flexibility: more moving parts, and enough subscription modes that the vocabulary trap bites hardest here — you have to know which one you actually asked for. Pulsar is proof you don't have to pick a religion. It's also proof that not picking one has an operational bill.
Put them all on one axis, from most log to most queue. Read the table by its second column first — everything else in a row follows from whether a read consumes the message.
| Product | Model | Read consumes it? | Replay | Ordering | Sharp edge |
|---|---|---|---|---|---|
| Apache Kafka | Log | No — commit moves an offset | Yes, back to retention | Per-partition | Not a work queue; share groups bolted on |
| AWS Kinesis | Log (managed) | No — sequence numbers | Yes, ≤ 365-day retention | Per-shard | Shards are provisioned; resharding is real work |
| Redis Streams | Log + per-message ack | No — XACK tracks pending |
Yes, but RAM/MAXLEN-bounded |
Per-stream | Log semantics capped by memory |
| Apache Pulsar | Both | Depends on subscription | Yes (log subscriptions) | Per-partition | Two models, more moving parts |
| Google Cloud Pub/Sub | Queue per subscription (+ seek) | Yes — ack per subscription | Via retention + seek |
Optional (ordering keys) | Replay is an add-on, not the default |
| RabbitMQ | Queue (routing) | Yes — ack deletes | No (Streams add it) | Per-queue | A superb queue; events on it can't replay |
| AWS SQS | Queue | Yes — delete on ack | No | None (FIFO: per group) | Right often; wrong the instant you say "replay" |
The table is the chapter on one screen, but don't let it make the choice for you. The deciding column is "Read consumes it?" — every other property in the row follows from that one, and it's the exact property the shared vocabulary hides. A product that answers "no" is a log wearing whatever brand name; one that answers "yes" is a queue. Everything else is packaging.
The Principal Engineer's Perspective
When this decision earns real attention — and when it doesn't. If a stream will only ever have one consumer that reads each message once and never needs it again, the queue-versus-log distinction is nearly irrelevant. It starts mattering at a predictable moment: the second time someone wants to use the stream — a new consumer, a rebuilt read model, a replay after a bug fix, an audit. So the design-time question isn't "which is faster," it's "will anyone ever need to read this stream a second way?" For facts on a durable medium the honest answer is almost always yes, which is why facts default to logs; for fire-and-forget tasks it's almost always no, which is why tasks default to queues. Decide it when it's a sentence in a design doc, not when it's a migration.
Who pays. The costs of a wrong carrier don't land on whoever chose it, which is why the choice is made carelessly. The person who put events on the existing queue in a hallway saved a week and paid nothing; the bill went to the engineer a year later who couldn't build the search index, and the team that rebuilt a year of history from the database. Choosing by familiarity moves a cost from your present self onto a future colleague who wasn't in the room. Good carrier selection is paying it now, as a design conversation, instead of later, as an incident.
The business decision hiding in an engineering costume. "What's our retention?" reads like a disk-provisioning question. It's actually a question about what your business can reconstruct and prove — how far back you can rebuild a corrupted read model, whether you can reproduce what the system knew on the day of a dispute. That's a data-governance and sometimes a compliance decision, and it shouldn't be made by whoever owns the cluster optimizing the disk bill in isolation. Name the retention horizon the business actually needs, then pay for the storage that reaches it — compaction and tiered storage exist precisely to decouple that horizon from the disk bill.
The observability that has to exist first. Before you make a stream load-bearing, you need to see two numbers. For a queue, it's depth: the backlog, which is your backpressure signal and your "are we keeping up" alarm in one. For a log, it's consumer lag measured per partition, because a consumer group can be healthy on nineteen partitions and hopelessly behind on the twentieth, and the average will hide it. Lag per partition is the heartbeat of a consumer group. The depth of what these signals tell you is Chapter 13's subject, but the wiring has to exist before you'll want it.
Questions to take back to your team:
- For the stream we're about to build — will anyone ever need to read it a second way? A new consumer, a rebuild, a replay, an audit? If yes, we need log semantics, and we need to say so now.
- Is this stream carrying facts or commands? If facts, why isn't it on a log? If commands, why is it on a log we'll end up fighting?
- What is our retention, who owns that number, and do they know that lowering it shortens how far back we can rebuild ourselves?
- Are we standardizing on one broker because our workloads genuinely share a shape — or because running two feels like a failure? Name the workloads and check.
- Where in our code does a consumer assume that reading a message removes it? On a log, that assumption is a bug waiting for a replay to expose it.
"Broker" is one word for two opposite data structures — a queue that deletes what it hands out and routes each message to one worker, and a log that keeps what it hands out and serves every record to everyone. They share a vocabulary and almost nothing else. Let the message's intent pick the model — facts to the log, commands to the queue — and treat retention as the design decision that says how far back your truth reaches. Choose by what the workload needs, and the carrier is a decision. Choose by what you already run, and it's a migration you haven't scheduled yet.
Exercises
None of these has a clean answer, and that's the point. Paste any into an AI and you'll get a confident, tidy reply in 4 seconds — one that skips your actual workload mix, your real retention, and the one stream that doesn't fit the taxonomy. The value is in the argument.
Exercise 1 — Sort your streams. List five streams in a system you own. For each, write down what you do with the messages: distribute work, or read-replay-fan-out. Then name the carrier each is on today. The interesting output is any row where the workload says "log" and the carrier says "queue," or vice versa — that's a migration you haven't scheduled yet.
Exercise 2 — Rebuild the mistake in your domain. Our worked example was RabbitMQ chosen for events, then no history to replay. Rebuild it in your own system: name the workload, the carrier chosen by habit, the semantic it needed but didn't get, and the point where the mapping breaks — the incident the right choice would have prevented. If it maps with no break, either you chose well or you're living inside the failure and calling it normal. Say which.
Exercise 3 — Run the replay test. Pick one stream you'd call a source of truth. Find its retention. Then answer concretely: could you rebuild a brand-new consumer from the beginning of time today? If the answer is "back to the retention cliff and no further," you've just measured the real reach of your source of truth — and it's probably shorter than the phrase implies.
Exercise 4 — Argue one broker versus two. For your org, make the strongest case for standardizing on a single broker, then the strongest case for running both — using your real workload mix, not a hypothetical one. "It depends" is allowed only if you then say what it depends on and pick a side for your actual situation.
Exercise 5 — Hunt the read-means-delete assumption. If you run a log, grep your consumers for the assumption that a read consumes a message — an offset never committed, a consumer that panics on redelivery, code assuming it sees each record exactly once. Each is a place a future replay or rebalance will surprise you. You're not looking for a bug that's firing; you're looking for the one that's armed.
Connections
← The Anatomy of an Event (Chapter 2). Chapter 2 said intent decides coupling; this chapter says intent also decides carrier. Facts want a log — read by many, replayed, rebuilt from. Commands want a queue — addressed tasks done once and deleted. The command-disguised-as-event has a carrier-level twin: put a command on a log and you fight the model; put a fact on a queue and you lose the history. Choosing by familiarity is what it looks like to ignore the intent you already named.
← From Request/Response to Events (Chapter 1). "We just added a queue" was Chapter 1's picture of accidental adoption; this chapter is what that queue's semantics cost you once business-critical state flows through it. The carrier is a design decision, not plumbing.
→ Delivery Guarantees in Practice (Chapter 4). The ack-and-redelivery in the queue model and the offset commit in the log model are where duplicates are born — and Chapter 4 constructs exactly-once processing end-to-end on top of the at-least-once delivery both carriers actually give you. This chapter set up where the ack happens; Chapter 4 is what to do about the duplicate it causes.
→ Ordering, Keys, and Partitions (Chapter 5). The consumer group and per-partition ordering got a working recap here; Chapter 5 makes partition-key selection its whole subject — global order as a myth you pay for, per-entity order as the guarantee you can afford, hot partitions as the incident that surprises people who did everything else right.
→ Replays (Chapter 11) and Error Flow (Chapter 12). Log semantics are what make replay possible; Chapter 11 is how you run one without re-firing a million side effects. And head-of-line blocking — one poison message stuck at the front of an ordered partition — was named here and handed to Chapter 12.
Appendix A: Reference Implementations
The snippets below are illustrative, not production drop-ins. They exist to make the one load-bearing difference — delete-on-consume versus read-by-offset — concrete.
A.1 — Queue semantics: receive, process, delete
// A work queue (SQS-style). The message is DELETED when you ack it.
while (true) {
msg = queue.receive(visibilityTimeout = 30s) // hidden from others for 30s
if (msg == null) continue
process(msg) // do the work
queue.delete(msg.receiptHandle) // ack → the message is gone, forever
}
// If process() crashes before delete(), the 30s visibility timeout lapses
// and another consumer receives the SAME message. That redelivery is where
// at-least-once (and your duplicates) come from — see Chapter 4.
The load-bearing line is queue.delete(): acknowledgment is destruction. After it runs, no consumer — not a new one, not this one tomorrow — can ever read this message again. The deliberately fragile part is the ordering of process() and delete(): ack before the effect is durable and you risk losing the message; ack after and you risk doing the effect twice on redelivery. That dilemma is not solved here on purpose — it's Chapter 4's whole subject. Note what this loop cannot do: read a message a second time, because the model's purpose is to cross work off a list.
A.2 — Log semantics: read from an offset, commit a bookmark
// A log (Kafka-style). Committing an offset MOVES A BOOKMARK; it deletes nothing.
offset = committedOffset("search-indexer") // resume where we left off — or 0 to replay all
while (true) {
records = log.poll(fromOffset = offset, max = 500)
for (r in records) {
process(r)
offset = r.offset + 1
}
commitOffset("search-indexer", offset) // just saves our position; records stay put
}
// A DIFFERENT consumer group keeps its OWN offset over the SAME records:
// analytics reads from its own bookmark, billing from its own, none competing.
// To replay: set offset = 0 and read all of history again — it's still there,
// up to the retention horizon (Chapter 11 covers doing this safely).
The load-bearing line is commitOffset(): it saves a position, and that's all it does. The records are untouched, which is why a second consumer group (analytics) reads the same stream on its own bookmark, and why setting the offset back to zero replays all of history — the two capabilities a queue structurally cannot offer. The quiet dependency is retention: "read all of history again" is true only back to the cliff. Past it, this loop finds nothing, and the log was a buffer after all.
Next: Chapter 4 — Delivery Guarantees in Practice: why no broker setting grants you exactly-once, and how you construct it end-to-end out of the at-least-once delivery every carrier in this chapter actually gives you.