Nobody owns the middle
The queue between two services is not plumbing. It is the only contract they share.
Producer–consumer gets taught as a concurrency primitive and then quietly promoted to an architecture, and something important goes missing in the paperwork. As a primitive it is a buffer between two threads. As an architecture it is an admission that the thing making work and the thing doing work are different systems with different owners, different failure modes, and no business being deployed together.
They scale on unrelated axes
Producers scale with traffic — more users, more events. Consumers scale with the cost of the work. Those numbers have nothing to do with each other. An API taking a thousand uploads a minute might need two workers or two hundred depending entirely on what "process an upload" means this quarter.
Couple them and every change to the cost of the work becomes a change to the shape of your API tier. Decouple them and it becomes a number you edit.
They fail differently, and that is the whole point
- A producer failing loses the event — unless accepting it was durable.
- A consumer failing loses nothing, if the queue holds and the job is idempotent.
- The queue failing loses everything, which is why it is the part you do not write yourself.
That middle line is the entire reason anyone does this. A crashed consumer becomes a delay instead of a data-loss incident. But it only holds if the job is idempotent, because at-least-once delivery means your worker will see the same job twice eventually. Every broker documents this. Every team is surprised by it exactly once.
pythonasync def handle(job: Job) -> None:
# at-least-once delivery: this will be re-run. make that boring.
if await results.exists(job.idempotency_key):
return
result = await do_work(job.payload)
await results.put(job.idempotency_key, result)The message is an API
The best thing about putting a queue between two components is not the buffering — it is that the message becomes the contract. Once the producer writes a job and walks away, the only thing binding the two sides is a schema. Either half can be rewritten, relocated or rescaled without the other noticing.
So treat the message like an API. Version it. Keep internal object graphs out of it. The moment a consumer needs to know which framework wrote the job, your decoupling was decorative.
A queue does not make a system asynchronous. It makes two halves independently deployable, which is worth considerably more.