Popov R&D logo
POPOV R&D tech blog
12 min read messaging / spring by Andrii Popov

Rabbit MQ: surprises and frustrations

"A closer look at RabbitMQ as a Spring Developer"


After diving deeper into RabbitMQ message broker, I ended up with mixed feelings. Some aspects impressed me, while others left me genuinely confused. Below is my unbiased, experience-driven overview of the system from a Spring developer’s perspective.

First, a quick definition: RabbitMQ is a mature, cross-platform open-source multiprotocol — with AMQP being primary — broker, created in 2007, written in Erlang, and as of time of writing this post maintained by Broadcom (VMware Tanzu) — the same group behind Spring framework. In this article, I will ony focus on AMQP 0-9-1 being the primary, stable, long-term protocol, AMQP 1.0 support is new and opt-in.

For clarity, I’ve structured this article around two key parts: an overview of RabbitMQ’s core features, and a look at the conveniences Spring brings to Java developers working with it. In both parts I focus only on features that matter most for me as an application developer.

Now, let’s break it down.

RabbitMQ: core capabilities#

Indirection#

(Confused). As per AMQP model, messages are published to an exchange — using a routing key when needed — rather than being sent directly to a queue. The exchange acts as the broker’s front door, routing messages through its bindings and introducing a deliberate layer of indirection. While in some cases such decoupling producers and queues can be useful, e.g. attaching extra queues on the fly for debugging or replay, in most messaging use cases it feels redundant to me.

Furthermore, the AMQP protocol does not require exchanges to deliver messages atomically across multiple queues, and RabbitMQ does not claim otherwise. This means a broker crash during, say, a fan-out process may leave some queues with the message and others without. By contrast, Kafka’s log + consumer group model handles multi-consumer delivery natively and avoids such inconsistencies.

Queues#

(Confused). Queue types differ as well. Many developers assume that classic queues are “safe by default,” but in reality they prioritize speed over fault tolerance. A classic queue lives on a single node and can be declared as durable or non-durable. When durable, the queue definition and any persistent messages it holds will survive a broker restart — but only a restart, not a node failure. Because the queue is not replicated, if the hosting node goes down permanently, both the queue and its messages are lost.

Modern quorum queues, by contrast, are replicated across multiple nodes using the Raft consensus algorithm. They are slower, but they remain the only production-grade option for reliable delivery. They also provide correct, predictable poison-message handling at the broker level. With an explicit delivery-limit configuration, the broker can automatically classify a message as poisoned after N failed delivery attempts and dead-letter it without relying on application logic.

Visibility timeout#

(Surprised). Historically, RabbitMQ did not provide an AWS SQS-style visibility timeout — the period (defaults to 30 sec) during which a message becomes temporarily invisible to all other consumers after it has been received. If the consumer does not delete the message before the visibility timeout expires, the message becomes visible again in the queue and may be delivered to another consumer for processing. It matters for long-running jobs and a proper value for it lets reduce potentially duplicated processing.

But RabbitMQ handles it differently. Since version 3.8.x, a global delivery acknowledgement timeout consumer_timeout is enabled by default: if a consumer fails to ack within this period (30 minutes by default), RabbitMQ closes the channel with a PRECONDITION_FAILED error and re-queues its outstanding deliveries. Additionally, you can set this param per queue, but still the mechanism is coarser-grained (per-channel/not per-message) and works by closing the consumer’s channel rather than simply flipping per-message visibility.

Transactions#

(Expected). XA (“eXtended Architecture”) is fundamentally incompatible with AMQP and RabbitMQ’s architecture, meaning you can’t atomically persist a DB row and publish a message to a broker together in an atomic way. The standard solution remains the Transactional outbox pattern — store the event in your DB first, then asynchronously publish it. RabbitMQ does support AMQP local transactions, which let you commit multiple publishes or acknowledgements on a single channel as one unit. The guarantee is narrow: atomicity only holds when all operations target a single queue. Anything involving multiple queues or external resources is outside its consistency model. There are very few valid use cases today, and almost all modern systems avoid them, making them effectively a legacy feature.

RabbitMQ team explicitly recommend avoiding local transactions and using publisher confirms instead, which is a AMQP protocol extension: the broker asynchronously acknowledges published messages once they’re safely handled. The mechanism works as follows: the client library creates and maintains a per-channel counter starting at 1 in an unconfirmed map. This counter is sent together with an associated message to the broker. When RabbitMQ later returns a confirmation, the client removes the corresponding entries. Because the broker can confirm many messages at once using a single ack with param multiple=true, confirms require far fewer round-trips and are dramatically faster than blocking transactional commits.

Observability#

(Expected) RabbitMQ offers multiple monitoring options, with Prometheus-compatible scrapes and K8s Operator being recommended for production environments. The official doc explicitly states that: the combination of Prometheus and Grafana is the highly recommended option for RabbitMQ monitoring. RabbitMQ comes with built-in rabbitmq_prometheus plugin, disabled by default and enabled by a simple command: rabbitmq-plugins enable rabbitmq_prometheus. The plugin serves metrics to Prometheus-compatible scrapers on port 15692 by default in Prometheus text format, so do not forget to expose that port if running via docker container.

In should be noted that RabbitMQ exposes Prometheus metrics in two modes: aggregated and per-object. Aggregated mode (default) is low-overhead and reports cluster-level totals, but hides which queue or channel they came from. Per-object mode emits metrics per queue, per channel, and per connection, giving full detail but increasing metric cardinality and scrape cost. In practice: use aggregated for alerts, and per-object for debugging when you need to pinpoint which queue is causing trouble.

RabbitMQ: work programmatically#

RabbitMQ ships many client libraries for different programming languages, including Java. Virtually speaking, this client is used in all JVM languages (Java, Kotlin, Scala, etc.) and most Java framework integrations for RabbitMQ (Spring, Quarkus, Micronaut). It is worth getting acquainted with its core abstractions such as Connection, Channel etc., because Spring exposes them in some cases for fine-grained tuning or advanced messaging patterns.

In Spring, you typically use the spring-amqp module to interact with RabbitMQ programmatically — a blocking wrapper around the official Java client that removes boilerplate and provides idiomatic Spring capabilities, making it declarative and production-safe (a reactive implementation exists too, but I will cover it separately later). At this level you operate such core abstractions as: RabbitAdmin, RabbitTemplate, @RabbitListener.

Spring Boot takes this even further: it automatically wires all the necessary AMQP components for you and exposes them through the dedicated spring-boot-starter-amqp starter. In Spring terminology, autoconfiguration means that the framework detects which libraries you have on the classpath and transparently creates and configures all the standard beans (connection factory, templates, listener containers, message converters, etc.) at start-up using sensible production-ready defaults. At this level, you typically configure RabbitMQ through application.properties / application.yaml prefixed with spring.rabbitmq.* and focus entirely on your business logic rather than infrastructure setup.

To keep the story structured, I’ll break it into three parts: the admin side (topology), the producer side, and the consumer side. They all parts of the same Spring module, but each plays its own role.

Let’s go through them one by one.

Topology#

(Puzzled) Topology in spring-amqp is handled by RabbitAdmin, which declares exchanges, queues, and bindings at runtime. Internally, it just opens an AMQP channel and calls the standard queueDeclare, exchangeDeclare, and queueBind operations defined in the AMQP spec. These operations are idempotent, so running them on every startup is safe. Spring Boot’s contribution is convenience: if you define Queue, Exchange, or Binding beans, RabbitAdmin automatically declares them for you, while the actual work is done by the underlying Java client library.

In practice, topology is almost always defined on the producer side or provisioned as infrastructure. It’s worth noting that runtime topology declaration is specific to the AMQP 0-9-1 model — AMQP 1.0 removed these operations entirely, and modern brokers such as Artemis, Kafka, and Pulsar also treat queues, topics, and streams as infrastructure rather than something applications create at runtime.

Producer#

(Historically shaped) The main abstraction here is RabbitTemplate — somewhat low-level and overloaded with convertAndSend() methods, giving it an early-2000s feel. Under the hood, it simply obtains a Channel — a RabbitMQ virtual connection multiplexed over a single TCP connection — from the configured ConnectionFactory (by default, a CachingConnectionFactory that maintains a cache of reusable channels), and then calls basicPublish doc on that channel. Spring mostly layers message conversion, optional retries, and publisher confirm/correlation handling on top.

Let’s walk through the key features (all prefixed spring.rabbitmq.*):

  • Message converter. Controls how your POJOs turn into bytes on the wire. Most teams use Jackson2JsonMessageConverter, so both producer and consumer simply agree on JSON as the common format for interpreting the message payload. You typically set a proper converter instance into RabbitTemplate itself or define it as bean.
  • Re-tries set-up. Covers failures before the message reaches the broker, when it is temporality unavailable or a network error happened. Can be configured via properties file, see *.template.retry.* set of properties.
  • Correlation logic. Controls if publish succeeds or fails after sending. Also configured via application.yaml via *.publisher-confirm-type=correlated. There are 3 modes: none (fire-&-forget), simple (blocking), and correlated (async, preferred for prod).

These are only the essential ones — more options exist, so consult the official reference documentation for the full list of supported properties.

Consumer#

(Historically shaped) Core abstraction is @RabbitListener annotation, over a method processing a message. It is Spring’s responsibility to wire incoming messages and execute method body. But, first let’s understand how RabbitMQ works with consumers.

RabbitMQ uses a push-based delivery model: as soon as a message arrives in a queue, the broker immediately pushes it to any connected consumers. As per doc, normally, active consumers connected to a queue receive messages from it in a round-robin fashion.

On the consumer side, the Java client library reads incoming frames (basicConsume doc) from the socket and hands them off directly to a shared worker thread pool, which executes the consumer callbacks.

Spring, in turn, builds its own execution layer on top, introducing two types of listener containers:

  • Simple. Older (~2011, v.1), adapted for early version of Java client lib;
  • Direct. Newer (~2017, v.2), and actually simpler, uses simplified concurrency of Java client lib;

Both containers predate project Loom and were never designed with virtual-thread semantics in mind. Their responsibility is to manage threads, concurrency, message conversion, retries, acknowledgments, error handling, and lifecycle for you.

The key consumer’s properties to take into consideration are (all prefixed spring.rabbitmq.listener.*):

  • Acknowledgement mode. Controls when a message is considered “successfully handled”. There are 3 modes: none (messages are auto-acked immediately upon delivery), auto (Spring auto-acks only after your listener method completes without throwing, default and recommended for prod), and manual (you explicitly call channel.basicAck() / basicNack()). You typically set this param via properties file *.[simple/direct].acknowledge-mode=auto.
  • Prefetch count. The max number of unacked messages RabbitMQ can push and store inside the client library’s internal buffer for that consumer/channel. This is your back-pressure lever. Just be careful with the number here. You typically set this parm via properties *.[simple/direct].prefetch=10. For I/O-bound handlers offloaded to virtual threads, you can usually afford a higher prefetch to keep more virtual threads busy, as long as memory usage and fairness between consumers remain acceptable.
  • Concurrency. Depending on the container type simple or direct you set different application props. For simple, *.simple.concurrency=2 and *.simple.max-concurrency=4 — Spring owns and manages worker threads. For direct, *.direct.consumers-per-queue=2 — Java client lib owns and manages worker threads. In both cases, the effective concurrency maps to the number of opened AMQP channels, and RabbitMQ’s documentation explicitly recommends keeping a single-digit number of channels per connection, so this value should not be set too high.

Application property keys (for both producer and consumer) can change between Spring Boot versions, so always check the official docs for the correct keys.

Since Spring Boot v3.2, you can enable virtual threads (VT) for consumers by setting spring.threads.virtual.enabled=true, but this works only for Simple containers, while Direct – still uses regular platform threads for now. I expect this may change with future releases, so test and inspect threads rather than assuming every container is VT-backed. Offloading to VTs improves scalability for blocking or I/O-bound handlers by providing cheap concurrency with lower memory overhead. Virtual threads are scheduled on a small shared pool of carrier threads (ForkJoinPool) by default.

This overview would be incomplete without mentioning Spring Cloud Stream— a higher-level abstraction that decouples your application code from the underlying messaging or streaming system, making broker switching theoretically easier. It provides a set of binders, including one for RabbitMQ, which internally relies on the spring-amqp module. In short: Spring Cloud Stream offers configuration-driven, broker-agnostic APIs, while Spring AMQP is the lower-level, Rabbit-specific layer that actually interacts with the broker. The trade-off is the additional dependency footprint and the risk of upstream bugs leaking into your service. So for straightforward, RabbitMQ-only scenarios where you don’t intend to change brokers, plain Spring AMQP feels the more direct and predictable choice.

To avoid cluttering this post with excessive code, I’ve created a dedicated GitHub demo repository that implements both approaches, with producers and consumers split into separate Maven modules.

Conclusion#

After reviewing major aspects of AMQP-protocol, RabbitMQ as its battle-tested implementation and ergonomics Spring framework brings to development phase, it’s time to answer the question: would I ever recommend this technology to my customers? The answer is: yes — but only under the right conditions.

RabbitMQ still carries some 2007-era design choices for compatibility. Its Java client and Spring AMQP module do the same. Because the documentation isn’t always clear, detailed or up-to-date, real system understanding and performance tuning often comes down to reading the source code. And given this accumulated legacy, it wouldn’t be surprising if a new, Rabbit-style broker eventually appears — one that keeps RabbitMQ’s strengths but is built from scratch with modern design trends in mind.

At the same time, RabbitMQ occupies a unique, almost irreplaceable position in the messaging ecosystem. It is the only open-source, cloud-neutral, fully polyglot message broker that provides rich messaging semantics. Its lightweight, actor-style concurrency model on the BEAM VM is uniquely suited to high-throughput, low-contention messaging — a fit that JVM-based brokers typically achieve with more complex threading and I/O architectures. In this sense, RabbitMQ retains a distinct niche that few other brokers genuinely compete with.

However, business ultimately chooses technology by cost per delivered message. No CTO approves a messaging backbone because it is elegant. They approve it because it scales economically, monthly bills stay predictable, failures do not create hidden cost and operational overhead is minimized. In this sense, RabbitMQ definitely has its niche, but every business case is unique and has to be evaluated separately. As a rough rule of thumb, I would use the following categorisation (based on current AWS pricing and realistic throughput assumptions for small RabbitMQ clusters — not “a universal law of physics”):

  • Tens RPS sustained (up to ~50–100 RPS) or spiky workloads – cloud-native queues (AWS SQS, etc.) are usually cheaper and much simpler. Though you may face unpredictable price change in the future or vendor lock-in situation.
  • Few hundred\thousand+ RPS sustained – AWS SQS starts to be noticeably expensive; a self-hosted or managed cluster (AmazonMQ for RabbitMQ, etc.) can be cheaper if you’re ready to own the operational burden.
  • Tens\hundreds of thousands+ RPS sustained – you’re likely beyond what a typical RabbitMQ cluster should handle; you’re entering Kafka/Pulsar territory and should think in terms of event-streaming platforms rather than classic message brokers.

In other words, RabbitMQ is a specialised tool, not a universal default. If you genuinely need its rich messaging semantics, flexibility, portability and rather serious workload, it is hard to beat. If you don’t, a managed cloud queue is usually the simpler and cheaper option; once your volume exceeds what a messaging broker can sustain, only streaming platforms can realistically cope with that scale.