RabbitMQ from Fundamentals to Production, Part 2: Building a Scalable Service

I killed the broker halfway through a burst of 20,000 orders. The publisher from Part 1 had called channel.publish 10,900 times without a single error. When RabbitMQ came back, the queue held 10,433 orders. Four hundred and sixty-seven orders had vanished, and nothing in the process knew. No exception, no log line, no failed promise. As far as checkout could tell, every one of those customers was about to be charged.

Part 1 was about getting a message to the right queue. This part is about what keeps it there when things go wrong: publisher confirms, connections that come back on their own, consumers you can scale and shut down without losing work, and a measured drill at the end that kills the broker under load and counts what survived. The same Aurora Coffee Co. topology, the same src/topology.ts.

A publisher showing a green checkmark while envelopes fade out mid-flight toward a crashed broker

Where Part 1 left the gap

Part 1 ended with an admission. This is its retry path as first published, from src/billing-consumer.ts:

// Park it in the delay queue with its original routing key and headers, so the // x-death counter keeps accumulating across attempts. channel.publish(RETRY_EXCHANGE, message.fields.routingKey, message.content, { persistent: true, headers: message.properties.headers, messageId: message.properties.messageId, }); channel.ack(message); 

Two operations, not one transaction. If the copy never reaches the retry queue, the ack has already deleted the original, and the order is gone. And channel.publish returning true does not mean what it looks like. It means the message fit in a buffer in your own process. The broker has not seen it yet. It may never see it.

The checkout publisher has exactly the same blind spot, and that is what the 467 missing orders above came from. Closing it takes publisher confirms.

And two things Part 1 got wrong

Before building on Part 1, I tested its failure paths against RabbitMQ 4.3. Two of its claims did not survive, and Part 1 has since been corrected. Here is what the tests showed.

The retry counter never counted. Part 1 read the broker's x-death header to count retries. This is its consumer, unchanged, with the payments service unreachable and one order to process:

$ docker run … -e PAYMENTS_URL=http://payments-is-down.invalid:4001 aurora-billing node part1/billing-consumer.ts $ node src/checkout.ts 1 [checkout] no queue bound for order.us.refunded [checkout] 1/1 orders confirmed by the broker $ docker logs <consumer> | grep -E "^\[billing\] (retry|giving up)" [billing] retry 1/3 for ord-be50d472-fee9-4c2d-a88c-a92bcfd568d7 [TypeError: fetch failed] { [billing] retry 2/3 for ord-be50d472-fee9-4c2d-a88c-a92bcfd568d7 [TypeError: fetch failed] { [billing] retry 2/3 for ord-be50d472-fee9-4c2d-a88c-a92bcfd568d7 [TypeError: fetch failed] { [billing] retry 2/3 for ord-be50d472-fee9-4c2d-a88c-a92bcfd568d7 [TypeError: fetch failed] { [billing] retry 2/3 for ord-be50d472-fee9-4c2d-a88c-a92bcfd568d7 [TypeError: fetch failed] { $ docker exec aurora-rabbitmq rabbitmqctl -q list_queues name messages name messages orders.billing 0 orders.billing.retry 1 orders.billing.dead 0 

Retry 2 of 3, every 30 seconds, for as long as I let it run. The order never reaches orders.billing.dead. Reading the message in the retry queue showed why: after several trips, its x-death still said count: 1. When a consumer republishes a message, RabbitMQ 4.x does not carry over the x-death the client sends. It records only the death it has just seen. The fix is to count in a header the consumer owns, retry-count, which is what retriesSoFar reads now. The same test with this article's consumer:

$ docker run … -e PAYMENTS_URL=http://payments-is-down.invalid:4001 aurora-billing $ node src/checkout.ts 1 [checkout] no queue bound for order.us.refunded [checkout] 1/1 orders confirmed by the broker $ docker logs <consumer> | grep -E "^\[billing\] (retry|giving up)" [billing] retry 1/3 for ord-14f839be-4815-4639-88b2-d8deb9302923 [TypeError: fetch failed] { [billing] retry 2/3 for ord-14f839be-4815-4639-88b2-d8deb9302923 [TypeError: fetch failed] { [billing] retry 3/3 for ord-14f839be-4815-4639-88b2-d8deb9302923 [TypeError: fetch failed] { [billing] giving up on ord-14f839be-4815-4639-88b2-d8deb9302923 after 3 retries [TypeError: fetch failed] { $ docker exec aurora-rabbitmq rabbitmqctl -q list_queues name messages name messages orders.billing 0 orders.billing.retry 0 orders.billing.dead 1 

x-delivery-limit does not stop a requeue loop. Part 1 presented the delivery limit as the first layer of defence against a message that fails forever. On RabbitMQ 4.3, it counts a message that comes back because its channel or connection closed: in my test, 6 deliveries and then dead-lettered. It does not count an explicit nack(message, false, true) on a live channel: 50 back-to-back requeues of one message, and no dead-lettering at all. It is a safety net for consumers that crash, not a brake on code that requeues. That changes one line of this article's consumer, as you will see below.

First, upgrade amqplib

One housekeeping step before any new code. Part 1 pinned amqplib@^0.10.5, and that was already behind when the article went out. amqplib 1.0 shipped in March 2026, and the 2.x line is current. Three changes matter here:

  • Types are bundled since 1.2, so @types/amqplib goes away.
  • 1.1 added opt-in connection recovery. Most of this article is about what it does, and what it leaves to you.
  • 2.0 changed one default: heartbeat: 0 now disables heartbeats instead of meaning "use the server's value". If you never set it, nothing changes.

The dependency lines of Part 1's package.json:

{ "dependencies": { "amqplib": "^0.10.5" }, "devDependencies": { "@types/amqplib": "^0.10.6", "typescript": "^5.9.0" } } 

Part 2's, in full:

{ "name": "aurora-orders-queue", "private": true, "type": "module", "engines": { "node": ">=24" }, "dependencies": { "amqplib": "^2.2.0" }, "devDependencies": { "typescript": "^5.9.0", "@types/node": "^24.0.0" } } 

@types/node is there for tsc --noEmit, so process and Buffer typecheck. I ran Part 1's topology.ts and billing-consumer.ts through the compiler against amqplib 2.2's bundled types, and both compile without a single change.

Publisher confirms: the broker's half of the contract

The consumer acknowledges messages to the broker. A publisher confirm is the same thing pointed the other way: the broker acknowledges messages to the publisher. You turn it on per channel (createConfirmChannel in amqplib), and every publish then gets exactly one answer:

  • basic.ack: the broker took responsibility. For a persistent message routed to a durable classic queue, that means written to disk. For a quorum queue, it means a majority of replicas have it in their Raft log, whatever the delivery mode, since quorum queues persist every message. On a single node, a majority is that one node, fsynced.
  • basic.nack: the broker could not take it, usually because of an internal error or a queue refusing publishes when it is full. Rare, but real.
  • No answer, because the connection died first. This one is the important case, and it does not mean the message was lost. It means you do not know. The broker may have written it and lost the socket before the ack went out.

Three lanes between publisher and broker: an ack, a nack, and a cut connection whose outcome nobody knows

Two consequences fall straight out of that third case.

An unconfirmed message has to be sent again, so duplicates are part of the design. Republishing on reconnect will sometimes deliver an order the broker already had. That is why Part 1's consumer sends the order id as an idempotency key. The drill below counts these duplicates rather than pretending they do not happen.

A confirm does not mean the message was routed. Part 1's other gotcha was a typo'd routing key matching no binding. With mandatory: true, the broker returns the message (basic.return), and then it still sends basic.ack, because from its point of view it has finished with the message. The return always arrives before the ack, so a publisher that tracks returns can tell "confirmed" apart from "confirmed, and dropped on the floor".

Confirms are not slow. Waiting for them one at a time is.

The usual objection to confirms is throughput. I measured three ways of publishing the same 20,000 persistent orders into the quorum queue, two runs each:

run 1 no confirms (fire-and-forget) 6859 msg/s confirms, awaiting each one 70 msg/s confirms, 1,000 in flight (OrderPublisher) 5728 msg/s run 2 no confirms (fire-and-forget) 7425 msg/s confirms, awaiting each one 78 msg/s confirms, 1,000 in flight (OrderPublisher) 4784 msg/s 

Awaiting each confirm before sending the next one drops throughput by two orders of magnitude, because every message pays a full fsync round trip on its own. Keeping a window of messages in flight and matching confirms as they come back keeps most of the throughput: 4,784–5,728 msg/s against 6,859–7,425 with no confirms at all. That gap is the real price of knowing, and it is small next to the alternative. The absolute numbers come from Docker Desktop on a Windows laptop, where an fsync through the VM is slow, so expect yours to be different. The ratio is the point. If you remember one sentence from this section: never await a confirm inside a publishing loop.

The publisher

Here is the checkout side, with confirms, a bounded in-flight window, and resending after reconnect. It replaces Part 1's bare channel.publish plus drain handling.

// src/order-publisher.ts import type { ChannelModel, ConfirmChannel, Options, RecoveringChannelModel } from 'amqplib'; import { connectWithRecovery } from './connection.ts'; import { assertTopology, ORDERS_EXCHANGE } from './topology.ts'; export type OrderPlaced = { orderId: string; sku: string; quantity: number; totalCents: number; }; /** Caps memory, and is the backpressure: callers wait once this many are unconfirmed. */ const MAX_IN_FLIGHT = 1_000; // amqplib rejects a confirm callback with this message when the broker nacks, and with // "channel closed" when the channel dies first. Only the second one is worth resending. const BROKER_NACK = 'message nacked'; type Unconfirmed = { routingKey: string; content: Buffer; options: Options.Publish & { messageId: string }; /** Written to a socket at least once, so the broker may have it. */ sent: boolean; resolve: () => void; reject: (error: Error) => void; }; export class UnroutableError extends Error { override name = 'UnroutableError'; } /** The deadline passed before the order reached any socket. It was never published. */ export class NotPublishedError extends Error { override name = 'NotPublishedError'; } /** * The deadline passed after the order was sent but before the broker confirmed it. * It stays queued and is resent until confirmed, so it must not be reported as failed. */ export class PublishPendingError extends Error { override name = 'PublishPendingError'; } export class OrderPublisher { #connection: RecoveringChannelModel | null = null; #channel: ConfirmChannel | null = null; #closed = false; #nextKey = 0; readonly #unconfirmed = new Map<number, Unconfirmed>(); readonly #returned = new Set<string>(); readonly #waitingForCapacity: Array<() => void> = []; readonly #waitingForIdle: Array<() => void> = []; static async start(): Promise<OrderPublisher> { const publisher = new OrderPublisher(); publisher.#connection = await connectWithRecovery('checkout-publisher', (model) => publisher.#attach(model), ); return publisher; } /** * Resolves once the broker has confirmed the order. Rejects if the broker refuses it, * if no queue is bound for the routing key, or when `signal` aborts first. While the * broker is unreachable it waits: the order is resent on the next connection. */ async publish(routingKey: string, order: OrderPlaced, { signal }: { signal?: AbortSignal } = {}): Promise<void> { while (this.#unconfirmed.size >= MAX_IN_FLIGHT && !this.#closed && !signal?.aborted) { await new Promise<void>((resolve) => { this.#waitingForCapacity.push(resolve); signal?.addEventListener('abort', () => resolve(), { once: true }); }); } if (this.#closed) throw new NotPublishedError('publisher is closed'); if (signal?.aborted) throw new NotPublishedError(`no capacity before the deadline for ${order.orderId}`); const { promise, resolve, reject } = Promise.withResolvers<void>(); const key = this.#nextKey++; const message: Unconfirmed = { routingKey, content: Buffer.from(JSON.stringify(order)), options: { persistent: true, mandatory: true, contentType: 'application/json', messageId: order.orderId, }, sent: false, resolve, reject, }; this.#unconfirmed.set(key, message); // No channel means we are between connections. The order waits in #unconfirmed // and #attach sends it as soon as there is a channel again. if (this.#channel) this.#send(this.#channel, key, message); signal?.addEventListener('abort', () => { if (!this.#unconfirmed.has(key)) return; if (!message.sent) { // Never left this process, so it is safe to drop and safe to report as failed. this.#settle(key); reject(new NotPublishedError(`${order.orderId} was not published before the deadline`)); } else { // The broker may already have it. Keep resending; the caller only stops waiting. reject(new PublishPendingError(`${order.orderId} is sent but not yet confirmed`)); } }, { once: true }); return promise; } /** Waits for outstanding confirms (up to a deadline), then closes the connection. */ async close(timeoutMs = 10_000): Promise<void> { this.#closed = true; for (const wake of this.#waitingForCapacity.splice(0)) wake(); if (this.#unconfirmed.size > 0) { const idle = new Promise<void>((resolve) => this.#waitingForIdle.push(resolve)); await Promise.race([idle, new Promise((resolve) => setTimeout(resolve, timeoutMs))]); } await this.#connection?.close(); for (const message of this.#unconfirmed.values()) { message.reject(new PublishPendingError(`publisher closed before ${message.options.messageId} was confirmed`)); } this.#unconfirmed.clear(); } /** Runs after every (re)connect. */ async #attach(model: ChannelModel): Promise<void> { const channel = await model.createConfirmChannel(); await assertTopology(channel); // Returns belong to the channel that saw them. A return whose confirm died with the // old channel must not mark the resend, which may well be routed this time. this.#returned.clear(); // With `mandatory`, an unroutable message comes back as basic.return *before* its // confirm, and that confirm is still an ack. Note the return so the ack is read right. channel.on('return', (returned) => { const id: unknown = returned.properties.messageId; if (typeof id === 'string') this.#returned.add(id); }); // A channel error leaves the connection up, so recovery never fires and everything // unconfirmed would wait forever. Closing the connection hands the problem to // recovery, which backs off, reconnects, and runs this method again. channel.on('error', (error: Error) => { console.error(`[checkout-publisher] channel error, rebuilding: ${error.message}`); void model.close().catch(() => undefined); }); channel.on('close', () => { if (this.#channel === channel) this.#channel = null; }); this.#channel = channel; // Whatever the previous connection left unconfirmed goes out again. Some of it may // already be on disk with only the confirm lost: that is a duplicate, and the // consumer's idempotency key is what makes a duplicate harmless. for (const [key, message] of this.#unconfirmed) this.#send(channel, key, message); } #send(channel: ConfirmChannel, key: number, message: Unconfirmed): void { try { channel.publish(ORDERS_EXCHANGE, message.routingKey, message.content, message.options, (error) => { if (error instanceof Error && error.message !== BROKER_NACK) return; // resent on reconnect this.#settle(key); const wasReturned = this.#returned.delete(message.options.messageId); if (error) message.reject(new Error(`broker refused ${message.options.messageId}`)); else if (wasReturned) message.reject(new UnroutableError(`no queue bound for ${message.routingKey}`)); else message.resolve(); }); message.sent = true; } catch { // The channel closed under us. The message stays in #unconfirmed for the next one. } } #settle(key: number): void { this.#unconfirmed.delete(key); this.#waitingForCapacity.shift()?.(); if (this.#unconfirmed.size === 0) { for (const wake of this.#waitingForIdle.splice(0)) wake(); } } } 

What to notice, in order of how much it matters:

  • #unconfirmed is the whole design. A message enters it before it is sent and leaves only when the broker answers. Anything still in it when a connection dies is resent by #attach on the next one. The in-memory buffer is not a cache. It is the list of orders whose fate is unknown.
  • The window is also the backpressure. Part 1 waited for drain when the socket buffer filled. Here, a caller waits once 1,000 messages are unconfirmed, which caps the socket buffer as well. While the broker is down, that means publish() blocks, and the checkout request blocks with it. T...