Skip to the content
Software Made Clear Diagrams that show the mechanism About

When a message broker is the wrong answer

ANSWER

A queue moves work in time, and that is all it moves. It cannot make two writes commit together, it cannot make a slow call fast, and it cannot add capacity a consumer does not have — so if the problem is any of those three, the queue only hides it.

IN PLAIN TERMS

In a busy restaurant the orders pile up on a spike by the kitchen door. The spike saves the waiters from queueing at the pass, and nothing more. It does not cook faster, it does not stop two waiters writing the same table down twice, and on a busy night the pile just grows like the queue it replaced.

Somebody has proposed adding a broker. The reasoning sounded fine in the meeting — it will decouple the services, it will smooth the spikes, it will let the consumer scale on its own — and you cannot quite say why it bothers you. Here is the argument, in a form you can use on Thursday. It is not that brokers are bad. It is that a queue does exactly one thing, and three of the problems it gets asked to solve are not that thing.

What a broker gives you, and what it cannot#

Start with what is genuinely valuable, because the case against misusing a broker is worthless if it is unfair to the tool. A broker gives you three things that are hard to build yourself. Buffering: the producer stops waiting for the consumer, so a burst becomes a backlog instead of a set of failed requests. Fan-out: one event, several independent readers, none of whom the producer has to know about. And durability of work in flight, so a restart on either side does not lose what was in the air. Those are real, and a system that needs them and refuses a broker is making its own worse one.

Now the limit, which is the whole of this section. Your write to the database and your publish to the broker are two writes, to two systems, with no shared commit. Whichever order you put them in, there is a window between them, and a crash in that window leaves the two disagreeing permanently. This is the dual write, and both directions fail:

Publish first, then write. The event goes out, the database write fails. You have now told three downstream systems that an order was confirmed, and there is no confirmed order. Every consumer acted correctly on a fact that never became true.

Write first, then publish. The row commits, the publish fails. The order is confirmed and nobody has been told. This is the direction most teams pick, and it is the quieter of the two — nothing is obviously broken, there is just a customer whose confirmation email never arrived and a warehouse that never got the pick request.

Publish first, then writecrash in this windowPublish to brokerWrite to databaseThe event is out, and the order never existed.Write first, then publishcrash in this windowWrite to databasePublish to brokerThe order exists, and nobody was told.
Whichever order you choose, the two writes have a window between them that no commit spans — and a crash inside it leaves the two systems disagreeing in a different way, not a smaller one.

You cannot close the window by being careful about ordering, retrying harder, or wrapping both in a try block. The window is a property of writing to two systems that do not share a commit. What you can do is stop writing to two systems.

the outbox
-- the state change and the message commit together, or neither does
BEGIN;

UPDATE orders
   SET status = 'confirmed'
 WHERE id = 88;

INSERT INTO outbox (topic, payload)
VALUES ('order.confirmed', '{"order_id": 88}');

COMMIT;

-- A separate process reads outbox rows and publishes them.
-- It may publish one twice; it can never publish one for an
-- order that did not get confirmed.

Write the message into your own database, in the same transaction as the state change, and let a separate process move it to the broker. That is the transactional outbox, and what it buys is precise: the message is sent if and only if the transaction commits. Two writes to two systems became one write to one, plus a delivery problem — and a delivery problem is the thing a broker is actually good at.

The relay can crash after publishing and before recording that it published, so it will sometimes send the same message twice. That is at-least-once, not exactly-once, and it is not a flaw in the pattern — it is the honest cost of having removed the other failure entirely.

Which sets a requirement on everybody downstream: a consumer will sometimes run twice, so it has to produce the same outcome when it does. That property has a name and a page of its own — idempotency keys and safe retries works through what a handler has to do to survive being called again. It is an earlier stop on this path than this article, and it is the one that makes the outbox safe to rely on.

Three problems a queue is asked to solve#

Each of these is a shape you will recognise. In each one the reasoning is sound right up to the last step, which is why they survive design review.

01

Two services that must agree, with no transaction between them

An order service and an inventory service both have to end up in the same state. There is no shared transaction, so a queue is put between them to carry the change. But what the queue supplies is delivery, not agreement — inventory can receive the message perfectly and still reject it, because the stock is gone. Now the two disagree and there is nothing to roll back. What the design needed was either one boundary covering both changes, or an explicit compensating action for the rejection. The compensating action is the part that never gets written, because at design time nobody is thinking about the branch where the message is delivered and refused.

02

A slow call, made asynchronous

A call takes four seconds, so it is moved onto a queue and the endpoint returns 202 immediately. The endpoint is now fast. But the caller still needs the answer, so it polls a status endpoint, or opens a socket, or the user refreshes the page until something changes. Nothing got faster: the wait moved somewhere with more moving parts, no stack trace crossing the gap, and a new failure mode where the answer never arrives at all. A queue helps here only when the caller genuinely does not need the result — and whether it does is a product question, not an engineering one. Ask it out loud before building anything.

03

A consumer that cannot keep up

Requests are arriving faster than they can be processed, so a queue goes in front to absorb the load. It absorbs it and adds no capacity whatsoever. What changes is the failure mode: instead of a fast rejection the caller can see and react to, you get a queue that grows without bound and a lag metric nobody is paged on until it is measured in days. A fast failure is information; a growing queue is the same failure with the information removed. What actually helps is more consumers, less work per message, or shedding load deliberately at the edge — and a queue is useful alongside any of those, just not instead of them.

Where the coupling went#

The queue becomes the coupling it was added to remove.

Producer and consumer now share a message format. Unlike a function signature, nothing checks it: there is no compiler, no type, and no call site to grep for. The dependency is as real as a direct call and considerably harder to see, and it shows up in three specific ways.

A rename breaks messages already in flight. The producer ships the new field name; the consumer is still on the old one for the ninety seconds the rollout takes. Every message sent in that window is unreadable by whichever side has not moved yet, and where those messages end up — dead-letter queue, retry loop, silently dropped — depends on configuration nobody has looked at since the broker was installed.

The consumer starts depending on a field the producer thought was incidental. Nothing announced this. The producer’s team learns about it when they try to remove the field and something they have never heard of fails in a way that does not name them.

Deployment order becomes load-bearing and undocumented. Consumer before producer for an added field, producer before consumer for a removed one. Get it backwards and the failure appears minutes later, in a different service, in someone else’s logs.

So the honest question is not is this decoupled? — it is where did the coupling go? It moved from a place with types and a stack trace to a place with neither. That is sometimes a good trade, and it is never a free one.

What separates a broker that ages well from one that does not is not the broker. It is three habits: an explicitly versioned message schema, so a change is a decision rather than a discovery; a consumer that ignores fields it does not recognise, so an added field is not an incident; and a producer that treats the message as a published contract rather than an internal struct it happens to serialise. A team with those habits can add a broker safely. A team without them is exporting its coupling somewhere it cannot see it — and if the broker is right for you anyway, the distributed-systems path covers what changes once there is a network between two things that used to share a process.

The architecture path holds the rest of these decisions in the order that makes each one explain the next.

IF YOU REMEMBER ONE THING

A broker moves work in time. If the problem is that two things must agree, that a caller is waiting, or that a consumer is too slow, moving the work in time changes when you find out — not whether.

Removing a queue that should never have been added is the same shape of problem as removing anything else from a running system, one route at a time — the strangler fig, from the inside.

Questions people also ask

5 QUESTIONS
When is a message broker the right answer?

When the producer genuinely does not need the result, and one of three things is true: the work can be done later, several independent readers want the same event, or work in flight must survive a restart. Those are real problems that are hard to solve without one, and a broker solves them well.

Does a queue guarantee my messages arrive in order?

Within one partition or one queue, usually yes. Across several, no — and that is the case people are surprised by. Kafka orders messages within a partition, not across a topic, so ordering for related events depends on choosing a key that routes them all to the same partition.

Can I get exactly-once delivery?

Not for a side effect in another system. What you can get is at-least-once delivery plus a consumer that produces the same outcome when it runs twice, which is indistinguishable from exactly-once to anyone looking at the result. Designs that promise more than that are usually promising it only inside one product's own boundary.

Is the outbox pattern worth the extra table?

If a message must be sent whenever a state change commits, and must not be sent when it does not, then yes — that is the property the table buys, and there is no cheaper way to buy it. If losing the occasional message is genuinely acceptable, publishing directly is simpler and you should do that instead.

Should the queue live between two services or inside one?

Inside one is far easier to change, because both ends belong to the same team and the message format is not a public contract. A queue between two services is an integration point with a schema, a deployment order and two backlogs, so it should be a decision somebody made deliberately rather than a side effect of adding a broker.