Performance Tuning
Prefetch tuning, message size considerations, and connection/channel pooling.
Three levers that matter most
Once RabbitMQ is running reliably (durable queues, dead-letter exchanges, publisher confirms — the Reliability Patterns page in this track), the next question is throughput: how many messages per second the broker and its consumers can actually sustain. Three things affect that more than anything else: prefetch, message size, and how connections/channels get created and reused.
Prefetch tuning, in more depth
Publishing & Consuming Messages introduced channel.prefetch(n) as the fix for one greedy consumer hoarding a queue's traffic. Getting the number right is its own tuning problem — too low and consumers sit idle waiting for the next message even though more are available; too high and one slow message can block a large batch behind it, and a consumer crash loses (well, requeues) a bigger in-flight batch at once.
// Too low: a consumer that could easily handle 50 in-flight messages
// spends time idle between deliveries instead of processing continuously
channel.prefetch(1);
// A reasonable starting point for many workloads — high enough to keep
// the consumer continuously busy, low enough to bound how much work
// gets redelivered if this consumer's connection drops mid-batch
channel.prefetch(20);
There's no universal correct number — it depends on how long each message takes to process and how many consumers are competing for the same queue. A practical way to tune it: start low (1–10), measure consumer utilization (see Production Monitoring) and message throughput, then increase prefetch until throughput stops improving — that's roughly the point where the consumer, not the network round trip to fetch more messages, is the bottleneck.
| Prefetch value | Effect |
|---|---|
1 |
Safest for slow, expensive-to-process, or hard-to-parallelize messages — but throughput suffers if the network round trip per message is non-trivial. |
| Moderate (10–50) | The typical sweet spot for many workloads — keeps a consumer continuously fed without one crash losing too large a redelivery batch. |
| Very high / unset | Maximizes one consumer's throughput in isolation, but can starve sibling consumers on the same queue and increases how much gets redelivered if that consumer dies mid-batch. |
Message size considerations
RabbitMQ is built for many small-to-medium messages, not a few enormous ones. A large message (multiple megabytes) ties up more memory per in-flight message, takes longer to transfer over the same network link, and slows down replication for a mirrored/quorum queue (the Clustering & High Availability page) — every replica has to receive and persist the whole thing before the write counts as durable.
The standard fix, the claim-check pattern: store the large payload somewhere built for large objects (S3, a blob store, a shared filesystem), and publish only a small reference to RabbitMQ instead of the payload itself.
// Instead of publishing a 20MB video file directly...
async function publishLargeAsset(assetBuffer, metadata) {
const objectKey = `assets/${metadata.id}`;
await s3.putObject({ Bucket: 'app-assets', Key: objectKey, Body: assetBuffer });
// ...publish a small reference; the consumer fetches the real payload from S3
const message = { assetId: metadata.id, objectKey, contentType: metadata.contentType };
channel.publish('assets.exchange', 'asset.uploaded', Buffer.from(JSON.stringify(message)), {
persistent: true,
});
}
As a rule of thumb, keeping messages comfortably under roughly 100–128KB keeps RabbitMQ operating in the workload it's designed for; anything routinely larger than that is worth reconsidering with the claim-check pattern above.
Connection and channel pooling
Opening an AMQP connection is comparatively expensive — a TCP handshake plus the AMQP protocol negotiation — while a channel (a lightweight virtual connection multiplexed over one real TCP connection) is cheap to open, but still not free. The standard, correct pattern: one connection per application process, reused for its entire lifetime, with a small number of long-lived channels reused across many publish/consume operations — never a fresh connection (or even a fresh channel) per message.
// WRONG — opens a brand-new connection and channel for every single publish,
// paying a full TCP handshake and AMQP handshake cost on every message
async function publishBadly(message) {
const connection = await amqp.connect('amqp://localhost');
const channel = await connection.createChannel();
channel.publish('orders.exchange', 'order.created', Buffer.from(JSON.stringify(message)));
await channel.close();
await connection.close();
}
// RIGHT — one connection and one channel, created once at startup and reused
// for the lifetime of the process
let connection, channel;
async function initRabbitMQ() {
connection = await amqp.connect('amqp://localhost');
channel = await connection.createChannel();
connection.on('error', (err) => console.error('Connection error:', err));
connection.on('close', () => console.warn('Connection closed — reconnect logic goes here'));
}
function publishOrderCreated(order) {
// Reuses the already-open channel — no handshake cost per call
channel.publish('orders.exchange', 'order.created', Buffer.from(JSON.stringify(order)), {
persistent: true,
});
}
For an application with genuinely high concurrent publish volume across many logical streams, a small pool of channels (rather than exactly one) lets independent operations avoid contending on a single channel's internal state, while still avoiding the cost of opening a fresh connection per operation — the connection is what's expensive to create repeatedly; channels are cheap enough to have several of, but not so cheap that creating one per message is free.
Common mistakes
- Setting prefetch to an arbitrarily high number "to be safe" without measuring — this can starve sibling consumers on the same queue and increases how many messages get redelivered at once if that consumer's connection drops mid-batch.
- Publishing large binary payloads (files, images, video) directly as message bodies instead of using the claim-check pattern — this bloats memory usage, slows replication across a mirrored/quorum queue, and works against what RabbitMQ is actually optimized for.
- Opening a new connection (or even just a new channel) for every single publish or consume operation — the AMQP handshake cost, paid repeatedly, becomes the dominant cost of the entire pipeline well before the broker itself is the bottleneck.
- Sharing one channel across multiple concurrent async operations without care — a channel is not safe for fully independent concurrent use in every client library (commands can interleave unexpectedly), so genuinely parallel work should generally use its own channel from a small pool rather than one shared one.