Messaging in .NET, part 3: Kafka and RabbitMQ side by side, or a queue is not a log
Part 3from the Messaging in .NET series · 3 parts in all
Parts one and two built the same order pipeline twice, and both times the answer to "how do I retry with a delay?" was "build it yourself". This article builds it a third time on RabbitMQ, where the answer is a line of configuration — and along the way we settle the argument about when to use which.
The sample is KafkaVsRabbitMQ, and its structure matters: the events, the business rule and the generated workload are shared between the two implementations. Only the transport differs. So if the two sides behave differently, that difference belongs to the broker, not to the code.
The misunderstanding to clear up first
People say "Kafka and RabbitMQ are message brokers" the way they say "a bicycle and a van are transport". True, and useless. They are answering different questions.
Kafka is a log. You append to a topic and it stays there — a newspaper archive. Ten readers can read the same article, and a reader who arrives next week can start from the beginning.
RabbitMQ is a router with queues. You hand a message to an exchange, the exchange looks up which queues should get a copy, and each queue delivers to whichever worker is free — peel off a ticket at the deli counter. When your ticket is done, it is gone. There is no archive.
That difference, not throughput, is what decides most real cases. Ask: do I ever need to read this message again, after I have dealt with it? Yes means log. No means queue.
The three new words
| Term | Plain meaning |
|---|---|
| Exchange | The mailbox you post to. It does not store anything; it decides where copies go. |
| Queue | Where messages actually wait. A queue is what a worker reads from. |
| Binding | The rule joining the two: "this queue wants
messages with routing key placed". |
Because the routing rule lives in the broker, you can read a RabbitMQ system's behaviour off the broker. In Kafka, that behaviour is spread across your consumers' code. The sample spells the whole topology out in one place:
// KafkaVsRabbitMQ.RabbitMq/RabbitTopology.cs
new(PlacedQueue, Exchange, "placed", MainQueueArguments),
new(RetryQueue, Exchange, "retried", RetryQueueArguments(delay)),
new(ConfirmedQueue, Exchange, "confirmed", null),
new(RejectedQueue, Exchange, "rejected", null),
new(DeadLetterQueue, DeadLetterExchange, "dead-lettered", null),
Producing: same idea, one more hop
Kafka: name a topic, append. The broker picks the partition from your key.
var record = new Message<string, byte[]>
{
Key = KafkaTopology.PartitionKeyFor(order.CustomerId),
Value = JsonDefaults.Serialize(order),
};
await _producer.ProduceAsync(KafkaTopology.Placed, record, cancellationToken);
RabbitMQ: name an exchange and a routing key. The broker picks the queues.
var properties = new BasicProperties
{
DeliveryMode = DeliveryModes.Persistent, // survive a broker restart
Headers = new Dictionary<string, object?> { ["attempt"] = 0 },
};
await _channel.BasicPublishAsync(
RabbitTopology.Exchange, RabbitTopology.RoutingKeys.Placed,
mandatory: false, basicProperties: properties,
body: JsonDefaults.Serialize(order), cancellationToken: cancellationToken);
That extra hop is not ceremony. It is why adding a third consumer system in RabbitMQ is "declare a queue and bind it" — the broker starts copying messages into it — while in Kafka it is "the new system joins its own consumer group" and the broker never learns the new system exists. RabbitMQ routes better. Kafka decouples better.
Consuming: pull versus push, and one word that fixes a lot
This is where a beginner notices the difference immediately.
Kafka pulls. You call Consume in a loop and decide your own
pace. The broker will not talk to you unless you ask:
var record = consumer.Consume(TimeSpan.FromMilliseconds(500));
await HandleAsync(record);
consumer.Commit(record); // advance the bookmark for the whole partition
RabbitMQ pushes. You register a handler and the broker sends messages
as fast as it can — which is exactly why prefetch exists, and why forgetting
it is the classic first-week mistake. Prefetch is how many unacknowledged messages the
broker may hand one worker at a time:
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += (_, args) => HandleAsync(channel, args);
await channel.BasicConsumeAsync(queue: "orders.placed", autoAck: false, consumer: consumer);
With prefetchCount: 1, a worker holds one message at a time: memory stays
flat and two workers share the queue evenly. Without it, one fast worker can be handed the
entire queue while a second worker sits idle.
And note the acknowledgement. Kafka moves a cursor; RabbitMQ acknowledges a single message:
await channel.BasicAckAsync(args.DeliveryTag, multiple: false); // this one, done
The retry, in one line
Here is the payoff for part 1's complaint that Kafka has no delay. In the sample's RabbitMQ consumer, a temporary failure is handled like this:
// requeue: false — send it to the queue's dead-letter routing instead of
// putting it straight back (which would spin).
await channel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false);
That is all. The nack hands the message to the retry queue, whose only
feature is a time-to-live:
["x-message-ttl"] = 5000, // hold it for five seconds
["x-dead-letter-exchange"] = Exchange, // then send it...
["x-dead-letter-routing-key"] = "placed", // ...back to the pipeline
The broker does the waiting while no consumer is involved, and the message comes back to the main queue when the delay has elapsed. Compare that with the Kafka side of the same sample, where the consumer seeks back onto the record and pauses its own partition until the delay expires — real scheduling logic in your application, which also blocks everything queued behind it.
The honest catch: a TTL belongs to a queue, so the delay is the same for every
message in it. Exponential backoff means one retry queue per delay — retry.5s,
retry.30s, retry.5m — and a rule choosing between them. Kafka's
per-message header gives you arbitrary delays on one topic instead. Declarative and fixed,
versus manual and flexible.
Dead-lettering, and who keeps count
When attempts run out, both sides park the message. The difference is who has been counting.
RabbitMQ counts for you. Every time a message is dead-lettered, the broker appends to
its x-death header, so the consumer can simply read the tally:
// KafkaVsRabbitMQ.RabbitMq/RabbitHeaders.cs
if (headers.TryGetValue("x-death", out var history))
{
var deadLetters = CountDeadLetters(history); // the broker kept count
if (deadLetters > 0) return deadLetters;
}
Kafka keeps no such history. A record is immutable and the broker knows nothing about it, so the attempt number is a header you write before publishing the retry — and the dead-letter "queue" is simply another topic you publish to yourself.
Run it and watch both
git clone https://github.com/bobhuang1/KafkaVsRabbitMQ
cd KafkaVsRabbitMQ
docker compose up -d # Kafka on 9092, RabbitMQ on 5672 (+ UI on 15672)
# terminal 1
dotnet run --project src/KafkaVsRabbitMQ.App -- kafka consume
# terminal 2
dotnet run --project src/KafkaVsRabbitMQ.App -- rabbit consume
# terminal 3 - the identical workload, to each broker
dotnet run --project src/KafkaVsRabbitMQ.App -- kafka produce --count 20
dotnet run --project src/KafkaVsRabbitMQ.App -- rabbit produce --count 20
Both terminals show the same three outcomes, because the business rule is literally the same function. Order zero is out of stock on both; order one fails once and then succeeds; the rest are confirmed immediately.
Then watch the retry happen differently. In the RabbitMQ management UI at
orders.placed.retry and then reappear in the main queue. On the Kafka side
there is nothing to watch, because the message is parked in the consumer's memory — which
tells you quite a lot about the two designs.
# Kafka keeps history: read a topic from the beginning, at any time
docker exec kvr-kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic orders.confirmed --from-beginning
So which one?
Kafka when you need to read a message again: reprocess after a bug fix, rebuild a view, let a new service catch up on history. When ordering per customer or per device has to hold at volume. When the messages are an event stream that many systems subscribe to and the broker should not care who is listening.
RabbitMQ when the messages are work: distribute jobs, retry them, drop them when done. When routing is genuinely complex — fan-out, wildcards, header rules, per-queue delays. When per-message acknowledgement and prefetch fit the shape of the problem better than a cursor.
And the question that usually settles it in thirty seconds: after this message is handled, might I need it again? If yes, you want a log. If no, you want a queue, and you will be much happier with the broker doing your retries and dead-lettering for you.
The three parts, in one table
| Kafka (part 1) | Redis Streams (part 2) | RabbitMQ (part 3) | |
|---|---|---|---|
| Model | Partitioned log | In-memory log | Exchange + queues |
| Read by | Pulling, per offset | Pulling, per entry id | Being pushed |
| Ack | Commit a cursor | XACK an entry | ack a message |
| Dead consumer | Automatic | XAUTOCLAIM yourself | Re-delivery on channel loss |
| Delayed retry | Build it | Build it from a sorted set | Configure it |
| Replay history | Yes | Yes, while it is retained | No |
All three give you at-least-once delivery, and in all three the consequence is the same: your handler will sometimes run twice, so make it idempotent and stop worrying about the rest. That rule is the real thing to take away from this series — the brokers differ in what they hand you, not in whether you have to design for redelivery.
All three samples are on GitHub:
KafkaDotNet,
RedisDotNet and
KafkaVsRabbitMQ. Each comes
with a docker compose up, a demo endpoint that reaches every branch, and a
test suite that runs without a broker at all.