Skip to main content

Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with Benchmarks

Three brokers, three questions, one set of measurements. Ordering, replay, consumer scaling and operational footprint measured against real Kafka 4.2.1, RabbitMQ 3.10.25 and Pulsar 4.2.4 brokers, ending in a decision table where every row has a transcript behind it — including the client-side buffer that silently defeats consumer fan-out in two of the three.

Most broker comparisons are throughput tables. They are close to useless, because the number was produced on hardware you do not have, with a message size you do not send, by someone with an interest in the result — and because throughput is almost never the thing that decides. The thing that decides is what happens when you add the second consumer, and whether you can read the message again after a bad deploy. So this article measures those instead. Three brokers, three questions, asked identically of each by driving the client libraries directly against real brokers. Every row in the decision table at the end has a transcript behind it, and there is not a single messages-per-second figure anywhere — a number from one two-core container would say nothing about any of these systems, and publishing it would be worse than publishing nothing.
If you want…Read
the one sentence per broker that predicts everything elsePart 1
ordering, replay and consumer scaling, measuredPart 2
what each costs to run, what Spring adds, and the decision tablePart 3
Versions. Brokers: Kafka 4.2.1 (KRaft), RabbitMQ 3.10.25, Pulsar 4.2.4 standalone. Clients: kafka-clients 4.2.1, amqp-client 5.30.0, pulsar-client 4.2.4 — the versions a Spring Boot 4.1.1 application actually gets, read from spring-boot-dependencies-4.1.1.pom rather than from release announcements. Tests on JDK 25.0.4.1+1, brokers on JDK 21.0.12.1+1. RabbitMQ is pinned at 3.10.25 because the box has Erlang 24; the AMQP 0-9-1 semantics measured here are unchanged in 4.x. Companion project: asmhatre/spring-messaging-demo, module broker-comparison/.

Part 1 — Where the message lives

Almost every difference between these three follows from one sentence about storage. Kafka is an append-only log, partitioned. The broker keeps every record for the retention period and remembers nothing about individual consumers except an offset. Reading removes nothing. RabbitMQ is a router with queues. The broker owns each message until a consumer acknowledges it, and then deletes it. It can route, expire and dead-letter on its own. Pulsar is a log too, but the cursor and the storage are separate services: brokers are stateless and BookKeeper holds the data. Acknowledgement moves a cursor, and a message is deleted once every subscription has passed it — unless a retention policy says otherwise.
Consuming, in three storage models Kafka: a partitioned log offset everything stays untilretention expires RabbitMQ: a queue acked = gone nothing to rewind to,and no second reader Pulsar: a log with cursors sub A sub B deleted once every cursorhas passed — unless retention A second reader: Kafka, a new consumer group at any time. Pulsar, a new subscription at any time. RabbitMQ, another queue bound to the same exchange — but only if you bound it BEFORE the messages arrived. That decision cannot be made after the fact, because the messages are gone.
KafkaRabbitMQPulsar
unit of parallelismpartitionqueuesubscription
set whenthe topic is createdthe queue is declareda consumer subscribes
consumingmoves an offsetdeletes the messagemoves a cursor
Read those three sentences again before reading any comparison table, including mine. They predict most of it.

Part 2 — The three questions

Ordering: what survives the second consumer

All three are FIFO with one producer and one consumer. The question is what happens when you add the consumer you will inevitably add. Kafka — twelve records, three keys, three partitions:
F -> partition [2]  values [2, 5, 8, 11]
A -> partition [1]  values [1, 4, 7, 10]
D -> partition [0]  values [3, 6, 9, 12]
Each key’s values come back in the order they were produced. The global sequence does not: the consumer drains one partition’s buffer before moving to the next, so the delivered order is 2, 5, 8, 11, 1, 4, 7, 10, 3, 6, 9, 12. That is the whole Kafka guarantee, and it is usually enough, because “in order” nearly always means per customer or per account rather than globally.
The keys in that transcript are D, A and F, and that is not an aesthetic choice. With three partitions, murmur2 sends A, B and C all to partition 1. My first version of this measurement used the obvious three keys, produced a transcript in which global order happened to be preserved, and proved nothing. Before concluding that your keys spread evenly, compute Utils.toPositive(Utils.murmur2(key)) % partitions for the ones you actually use.
RabbitMQ — one consumer sees the queue in order, two do not:
one queue, one consumer      : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
one queue, two consumers     : [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1, 12]
Message 1 went to the slower consumer and finished tenth. There is no key and no partition, so there is nothing to pin related messages to one consumer. Ordering across related messages in RabbitMQ means one queue and one consumer — and therefore no horizontal scaling of that queue — or the consistent-hash exchange, which is a plugin and effectively reintroduces partitions by hand. Pulsar — two consumers on a Shared subscription and two on Key_Shared, same topic, same messages:
Shared      A -> [consumer-1, consumer-2]   B -> [consumer-1, consumer-2]   C -> [consumer-1, consumer-2]
Key_Shared  A -> [consumer-2]               B -> [consumer-1]               C -> [consumer-2]
Under Shared every key was handled by both consumers, so two messages with the same key can be in flight at once and no ordering claim survives. Under Key_Shared each key went to exactly one consumer — Kafka’s guarantee, but chosen by the subscriber rather than fixed by a partition count someone picked when the topic was created, and recomputed as consumers join and leave. That property is the strongest single argument for Pulsar: two teams reading the same topic can make different ordering-versus-throughput trades without negotiating with each other.

Replay: reading it twice

“Can I read that again?” gets asked after a bad deploy, and by then it is too late to change brokers.
Kafka
  group replay-group-1, first read           : 12 records
  group replay-group-2, brand new group      : 12 records
  group replay-group-1, after seekToBeginning: 12 records

RabbitMQ
  first drain of the queue  : 12 messages
  second drain of the queue : 0 messages
  queue depth afterwards    : 0

Pulsar
  subscription replay-sub, first read          : 12 messages
  subscription replay-sub, after seek(earliest): 12 messages
  subscription replay-sub-2, brand new         : 12 messages
The middle block is the one that changes architectures. Acknowledging deletes; there is no offset to rewind and no second reader that can see what the first consumed. This is not a defect — a router that deletes what it has delivered is much cheaper to operate than a log, and most work queues genuinely do not need history. It only becomes a defect at the moment you need history and do not have it. (RabbitMQ streams, from 3.9, are a separate log-shaped feature in the same broker with their own protocol; worth knowing about, and not what queueDeclare gives you.)
Pulsar’s replay has a default that surprises people. Kafka keeps a record for the retention period regardless of who read it. Pulsar deletes a message once every subscription has acknowledged it, unless a retention policy on the namespace says otherwise. A namespace with default retention and one well-behaved subscription keeps nothing — so the replay you are counting on is a policy you have to set, not a property you inherit.

Consumer scaling: what happens at the fifth consumer

Five consumers, one group or queue or subscription Kafka, 3 partitions 2 idle — structural, cannot be tuned away RabbitMQ, default (no basicQos) 4 idle — one consumer took all 40 messages Pulsar, default receiverQueueSize 1000 idle — but only until the buffer is bounded Kafka’s ceiling is the design. The other two are client-side buffers with generous defaults: basicQos(1) and receiverQueueSize(1) give 8 messages to each of the five, in both.
Kafka’s assignment is unambiguous: a partition belongs to at most one consumer in a group, so two of the five processes are running, connected, healthy, and doing nothing at all — and they will keep reporting healthy forever. Raising the partition count later changes which partition a key maps to, so the per-key ordering guarantee is broken across the change for every key that moves. In practice partition count is a capacity decision made at design time on incomplete information, that you then live with. The other two have no structural ceiling and both defeat themselves by default:
RabbitMQ, no basicQos (unlimited prefetch, the AMQP default)
  consumer-1 -> 40    consumer-2..5 -> 0
RabbitMQ, basicQos(1)
  consumer-1..5 -> 8 each

Pulsar, receiverQueueSize default (1000)
  one or two consumers take everything; the rest receive nothing
Pulsar, receiverQueueSize(1)
  consumer-1..5 -> 8 each
This is the one finding here that applies whichever you pick. A client-side buffer with a generous default will silently concentrate a small backlog on one consumer and make your fan-out look broken. basicQos is not a tuning knob to postpone; it is what makes competing consumers exist. Spring AMQP does set it for you — AbstractMessageListenerContainer.DEFAULT_PREFETCH_COUNT is 250 — which is better than unlimited and still enough to concentrate any backlog smaller than 250 messages on one consumer.

Part 3 — What it costs to run

“Ops burden” cannot be benchmarked. The number of moving parts, the memory floor and the size of the configuration surface can be, and they are what people are actually asking about. Each broker started from its shipped distribution with default configuration and a 700 MB heap cap, on one 2-core / 3.8 GB Linux box:
Kafka 4.2.1RabbitMQ 3.10.25Pulsar 4.2.4 standalone
launch to first accepted connection10.0 s15.1 s20.8 s
resident memory at idle333 MB115 MB610 MB
server processes11 (+ epmd)1
listening ports9092, 90935672, 4369, 256726650, 8080, 2181
unpacked distribution135 MB26 MB344 MB
settings in the shipped default config240357
Read those as orders of magnitude, not as a benchmark: one sample, one small machine. Three things they say clearly. RabbitMQ ships no configuration file at all. The boot log records Config file(s): (none) and rabbitmq.conf.example is entirely commented out. Everything works out of the box, and the smallest resident footprint here is the one that is not a JVM. Against that, epmd on 4369 and the inter-node port on 25672 are the Erlang distribution — which is also how clustering works, and how clustering goes wrong. Pulsar standalone is three systems in one process. Port 2181 is ZooKeeper and the on-disk data is BookKeeper’s; the standalone distribution hides that behind one command. A real deployment does not: you operate a metadata store, BookKeeper bookies and Pulsar brokers as three tiers with three scaling stories. The 357-setting standalone.conf is the honest signal — that is the configuration surface, and running it seriously means learning most of it. Kafka since KRaft is genuinely simpler than it was. One process, two ports, 24 settings, no ZooKeeper. The old “Kafka means also running ZooKeeper” objection is a version behind; anyone still arguing it is comparing to 2022.

What Spring adds — and what Boot 4 stopped giving you

KafkaRabbitMQPulsar
projectSpring for Apache Kafka 4.1.1Spring AMQP 4.1.1Spring for Apache Pulsar 2.0.7
starterspring-boot-starter-kafkaspring-boot-starter-amqpspring-boot-starter-pulsar
listener@KafkaListener@RabbitListener@PulsarListener
retry / DLQDefaultErrorHandler, @RetryableTopicdead-letter exchangeDeadLetterPolicy
All three are Boot-managed at 4.1.1, so you do not pin their versions. But there is a Boot 4 trap that applies to all three equally:
In Spring Boot 4, depending on a messaging library directly rather than through its Boot starter means you have no auto-configuration. The auto-configuration classes moved out of spring-boot-autoconfigure into per-technology modules that only the starters bring. Depend on org.springframework.kafka:spring-kafka and the application compiles, starts, and fails at runtime with No qualifying bean of type KafkaTemplate<...>. Same for spring-rabbit and spring-pulsar. Every Boot 3 tutorial gets this wrong now, and the symptom names a missing bean rather than the cause.

The decision table

Kafka 4.2.1RabbitMQ 3.10.25Pulsar 4.2.4
ordering with many consumersper key, alwaysnoneper key with Key_Shared
who chooses thattopic designa plugin, or one consumereach subscription, independently
replay after the factyes, within retentionnoyes, if retention is configured
second independent readernew group, any timemust be arranged in advancenew subscription, any time
consumer parallelism ceilingpartition countnonenone
changing that ceilingrepartition; breaks key→partitionnothing to changenothing to change
the client-side trapnone by defaultunlimited prefetchreceiverQueueSize 1000
memory at idle333 MB115 MB610 MB
processes to operate1 (KRaft)1 + epmdbroker + bookies + metadata store
shipped default settings240357
Spring project maturityvery highvery highgood, much smaller community
Choose RabbitMQ when the work is a queue of tasks: each message is an instruction, order between instructions does not matter, and once it is done it is done. It is the smallest thing that works, it has the best out-of-the-box operability of the three, and queue depth is a metric anyone can interpret. Choose it knowing you are giving up replay permanently — adding it later is a broker migration, not a configuration change. Choose Kafka when the messages are events other people will want to read: when more than one consumer will exist, when someone will need to reprocess history after a bug, or when ordering per entity is part of the contract. Pay for it with a partition count decided too early, consumers that scale only as far as that number, and an operational model where lag is the thing you watch. Choose Pulsar when you genuinely need what neither of the others gives: per-subscription choice of ordering versus fan-out, consumer counts not bounded by a number chosen at topic creation, or multi-tenancy with real isolation. It is the most capable design here. It is also three systems, 357 settings, five times RabbitMQ’s memory floor, and the one your team has least experience with — and that last point decides more incidents than the first three prevent.
If the honest answer is “we don’t know yet”, that argues for Kafka — on grounds that have nothing to do with the technology. You can hire for it, your monitoring vendor supports it, and the failure modes are documented by thousands of people who hit them first. That is a real engineering argument and it is fine to make it out loud rather than dressing it up as a technical one.

The question that dissolves the choice

A surprising number of these decisions are made for a system with one producer, one consumer and fewer than a hundred messages a second. At that volume all three work, none of the measurements above will ever be reached, and the decision is entirely about what your team can operate at three in the morning. The measurements matter when you can name which row you are relying on. If you cannot name it, you are choosing an operational burden rather than a broker — and the cheapest one to operate is the one in the 115 MB cell.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.