Message Ordering in .NET, Solved From First Principles
Learn message ordering in .NET from scratch: why messages arrive out of order, and how partition keys, sessions, and single consumers fix it.
The dabbawala lunch boxes
Think about the famous dabbawalas of Mumbai. Every morning they pick up thousands of hot lunch boxes from homes and deliver each one to the right office by lunchtime. Now imagine one home sends three boxes through the day: breakfast, lunch, and a sweet for after lunch. The office wants them in that order. Eating the sweet before lunch would be strange.
How do the dabbawalas manage it? They do not keep the whole city in one straight line. That would be far too slow. Instead, every box carries a code that says which home and which office it belongs to. All boxes for one home-and-office pair travel along the same route, carried in the right order. Different homes use different routes, all at the same time.
So the system is fast and ordered. Fast, because thousands of boxes move in parallel. Ordered, because each home's boxes stay together on one route, in sequence.
Message ordering in software works the same way. We will build the idea from the ground up: first see why messages go out of order, then learn the one trick that fixes it, and finally write real .NET code for Azure Service Bus, Kafka, and RabbitMQ.
What "ordering" even means
Let us be careful about what we are asking for. There are two very different wishes:
- Global ordering — every message in the system is processed in the exact order it was sent. One giant line.
- Per-key ordering — messages that belong to the same thing (same order, same customer, same account) are processed in order. Many small lines.
Global ordering sounds nice, but it is brutally slow. It means only one message can be worked on at a time, ever. Almost no real system needs it.
Per-key ordering is what the business actually wants. For order A-100, the events Created, Paid, and Shipped must happen in that sequence. But order A-100 and order A-200 have nothing to do with each other, so they can be processed at the same time with no harm.
Two kinds of ordering
Steps
Global ordering
one line, one worker, very slow
Per-key ordering
one line per key, many workers
What you usually want
per-key — fast and correct
For the rest of this post, "ordering" means per-key ordering. That is the goal worth aiming for.
Why messages go out of order
To make work go fast, brokers use a trick called competing consumers. The broker hands messages to many worker copies at once. Each worker grabs the next message, does the job, and asks for another. This is wonderful for speed. It is terrible for order.
Picture two messages for the same bank account: first Deposit ₹1000, then Withdraw ₹800. Two workers are running.
Worker A got the deposit but paused for a moment. Worker B got the withdraw and finished first. Now the withdraw ran before the deposit. The account briefly went negative, and a real bank would reject it. The messages were sent in the right order, but they were processed in the wrong order.
There is a second cause too. Brokers give at-least-once delivery. If a worker crashes after doing the job but before acknowledging, the broker re-sends the message. A re-sent old message can land behind a newer one, scrambling the sequence again.
Here are the main reasons order breaks:
| Cause | What happens | Plain explanation |
|---|---|---|
| Competing consumers | Many workers process in parallel | Faster worker finishes a later message first |
| Retries and re-delivery | Failed message comes back later | Old message slips behind new ones |
| Multiple partitions or queues | Related messages split across lanes | No single lane keeps them in sequence |
| Network and GC pauses | A worker stalls for a moment | Its message falls behind |
The one big idea: same key, same lane, one reader
Every ordering solution, on every broker, is really the same three rules:
- Pick a key. Choose what must stay ordered together — usually an id like the order id, the customer id, or the account number.
- Route by key. Make sure all messages with the same key go into the same lane (a session, a partition, or a queue).
- One reader per lane. Make sure each lane is read by only one worker at a time, so that lane is processed in order.
That is the whole secret. You give up global parallelism inside a single key, but you keep parallelism across keys. With thousands of keys you still get plenty of speed.
Notice that two different keys can share a lane (there are usually fewer lanes than keys), but one key never spreads across two lanes. That single rule is what keeps each key in order.
The three rules in action
Steps
Pick a key
order id, customer id, account no
Route by key
same key to same lane always
One reader per lane
lane processed in sequence
Now let us see how each broker spells these rules.
Azure Service Bus: sessions
Azure Service Bus calls a lane a session. You stamp each message with a SessionId. The broker guarantees that all messages with the same SessionId go together, and a receiver locks the whole session, so it is the only one reading that session until it is done. Same key, same lane, one reader.
The queue must first be created with sessions enabled. Then the sender sets the session id, usually to the business key.
// SENDER — stamp every message with the business key as the SessionId.
// All messages for one order now travel together, in order.
using Azure.Messaging.ServiceBus;
await using var client = new ServiceBusClient(connectionString);
ServiceBusSender sender = client.CreateSender("orders");
var message = new ServiceBusMessage(payloadBytes)
{
SessionId = orderId.ToString(), // <-- the partition key
MessageId = Guid.NewGuid().ToString()
};
await sender.SendMessageAsync(message);On the receiving side, you use a session processor. It locks one session at a time and feeds you that session's messages in order. Different sessions are still handled in parallel by other receivers.
// RECEIVER — a session processor reads one session at a time, in order.
await using var client = new ServiceBusClient(connectionString);
ServiceBusSessionProcessor processor = client.CreateSessionProcessor(
"orders",
new ServiceBusSessionProcessorOptions
{
MaxConcurrentSessions = 8, // 8 different orders at once
MaxConcurrentCallsPerSession = 1 // but ONE message per order at a time
});
processor.ProcessMessageAsync += async args =>
{
// Messages for this SessionId arrive strictly in order.
await HandleAsync(args.Message);
await args.CompleteMessageAsync(args.Message);
};
processor.ProcessErrorAsync += args => Task.CompletedTask;
await processor.StartProcessingAsync();The key line is MaxConcurrentCallsPerSession = 1. That is rule three. It says: inside one order, do one thing at a time. Across orders, MaxConcurrentSessions = 8 keeps things fast.
Kafka: partitions
Kafka calls a lane a partition. A topic is split into a fixed number of partitions. When you send a message, you give it a key. Kafka hashes the key and always maps the same key to the same partition. Inside a partition, messages keep their order. In a consumer group, each partition is read by exactly one consumer. Same key, same lane, one reader.
// KAFKA PRODUCER — set the message Key to the business key.
// Same key -> same partition -> ordered.
using Confluent.Kafka;
var config = new ProducerConfig { BootstrapServers = "localhost:9092" };
using var producer = new ProducerBuilder<string, string>(config).Build();
await producer.ProduceAsync("orders", new Message<string, string>
{
Key = orderId.ToString(), // <-- decides the partition
Value = json
});The consumer side is simpler than Service Bus, because Kafka assigns partitions to consumers for you. You just make sure you process a partition's messages one at a time in the loop.
// KAFKA CONSUMER — read in a loop; each partition is ordered for you.
var config = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "order-workers",
EnableAutoCommit = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe("orders");
while (!token.IsCancellationRequested)
{
var result = consumer.Consume(token);
await HandleAsync(result.Message); // process before moving on
consumer.Commit(result); // mark this offset done
}One important rule: a consumer group can have at most as many active consumers as there are partitions. If you have 6 partitions, the seventh consumer just sits idle. So you choose the partition count up front based on how much parallelism you need.
| Broker | Lane is called | Key is called | One-reader rule |
|---|---|---|---|
| Azure Service Bus | Session | SessionId | Receiver locks a session |
| Apache Kafka | Partition | Message Key | One consumer per partition in a group |
| RabbitMQ (classic) | Queue | Routing/hash key | Single Active Consumer per queue |
RabbitMQ: single active consumer and hash exchanges
RabbitMQ does not have sessions or partitions built in, but you can get the same result two ways.
The simplest is Single Active Consumer (SAC). You declare a queue with x-single-active-consumer set to true. Many consumers may connect, but RabbitMQ lets only one actually receive messages at a time. The rest wait as backups. If the active one dies, a backup takes over. This keeps the queue in order while still giving you failover.
// RABBITMQ — declare a queue with Single Active Consumer.
// Only one consumer is active at a time, so order is preserved.
var args = new Dictionary<string, object>
{
["x-single-active-consumer"] = true
};
await channel.QueueDeclareAsync(
queue: "orders-A-100",
durable: true,
exclusive: false,
autoDelete: false,
arguments: args);But one queue with one active consumer is slow if you have lots of traffic. So the usual trick is to split the traffic by key first, using a consistent-hash exchange (the x-consistent-hash plugin). You publish with the business key as the routing key; the exchange hashes it and drops it into one of several queues. Each queue then uses a single active consumer. You end up with many ordered lanes, exactly like Kafka partitions.
RabbitMQ ordered scaling
Steps
Consistent-hash exchange
key decides the queue
Several queues
one lane each, same key stays put
Single active consumer
one reader per queue, in order
So all three brokers, despite different words, land on the same shape: route by key into lanes, one reader per lane.
Ordering is not the same as no duplicates
A common mix-up: people think that once messages are ordered, duplicates are gone too. They are different problems.
- Ordering answers: in what sequence do messages run?
- Idempotency answers: what if the same message runs twice?
Even with perfect ordering, at-least-once delivery means a message can still be re-delivered after a crash. So you usually pair ordering with an idempotent consumer or the inbox pattern, which remembers ids it has already handled and safely skips repeats. Ordering and idempotency are partners, not the same thing.
If you want the full story on skipping repeats, see the idempotent consumer pattern and the inbox pattern.
Picking a good partition key
The key you choose decides both correctness and speed. A bad key quietly breaks one or the other. Here is how to think about it.
- Match the key to the thing that must be ordered. If events must be ordered per order, use the order id. Per customer, use the customer id.
- Make sure you have many keys. If you pick a key with only a few values (say, the country), all messages pile into a few lanes and you lose parallelism. This is called a hot partition.
- Never spread one logical thing across keys. If order events sometimes use the order id and sometimes the customer id, they can land in different lanes and lose order.
| Key choice | Ordering | Parallelism | Verdict |
|---|---|---|---|
| Order id | Per order, correct | High (many orders) | Great for order events |
| Customer id | Per customer | Medium to high | Good if a customer's events relate |
| Country | Too coarse | Low (few countries) | Hot partitions, avoid |
| Random GUID | None useful | Maximum | No ordering at all |
A simple rule: the key is the smallest thing whose events must stay in line, but no smaller.
A note on libraries
You can do all of this directly with the broker SDKs, as shown above. Many teams instead use a messaging library that wraps these details. Be aware that MassTransit and MediatR moved to a commercial license in 2025; they are no longer free for most production use, so check the terms before adopting them. Free alternatives in the .NET space include Wolverine, NServiceBus (also commercial, but long-established), and Rebus, plus the plain broker SDKs (Azure.Messaging.ServiceBus, Confluent.Kafka, RabbitMQ.Client) which are free and give you full control. Whatever you pick, the underlying idea is the same three rules.
Putting it all together
Here is the whole journey in one picture. A producer stamps a key, the broker routes by key into lanes, one reader drains each lane in order, and a dedup check guards against repeats.
Once you see this shape, every broker looks familiar. The names change — session, partition, queue — but the engine underneath is the same.
Quick recap
- Ordering means per-key ordering, not global ordering. You want each order or customer in sequence, not the whole world in one line.
- Messages go out of order mainly because of competing consumers (parallel workers) and re-delivery after crashes.
- The fix is three rules: pick a key, route the same key to the same lane, and let only one reader drain each lane at a time.
- Azure Service Bus uses sessions (
SessionIdplus a session processor with one call per session). - Kafka uses partitions (message
Keyhashed to a partition, one consumer per partition in a group). - RabbitMQ uses a single active consumer, often combined with a consistent-hash exchange to make many ordered lanes.
- Choose the partition key carefully: small enough to be correct, with enough distinct values to stay fast and avoid hot partitions.
- Ordering is not idempotency. Pair ordering with an idempotent consumer or the inbox pattern to handle duplicates.
References and further reading
- Azure Service Bus message sessions (FIFO) — Microsoft Learn
- Create partitioned topics and queues — Microsoft Learn
- Apache Kafka partition key guide — Confluent
- Kafka keys, partitions and message ordering — Lydtech
- RabbitMQ consumers and Single Active Consumer — RabbitMQ docs
- RabbitMQ 3.8 Single Active Consumer — CloudAMQP
Related Patterns
Idempotent Consumer: Handling Duplicate Messages in .NET
Learn the Idempotent Consumer pattern in .NET to safely handle duplicate messages, prevent double charges, and build reliable message-driven systems.
The Inbox Pattern in .NET: Handle Each Message Exactly Once
Learn the Inbox Pattern in .NET to stop duplicate messages from causing double charges and double emails. Simple real-life examples, EF Core code, diagrams, and how it pairs with the Outbox Pattern.
Event-Driven Architecture in .NET with RabbitMQ: A Beginner's Guide
Learn event-driven architecture in .NET with RabbitMQ using simple words, real-life examples, exchanges, queues, and clean async C# code you can copy.
MassTransit with RabbitMQ and Azure Service Bus: Is It Worth a Commercial License?
MassTransit went commercial in v9. See how it works with RabbitMQ and Azure Service Bus, what the new license costs, and whether it is worth paying for.
Event-Driven Microservices with Azure Service Bus in .NET
A friendly, step-by-step guide to building event-driven microservices in .NET using Azure Service Bus topics, subscriptions, and the ServiceBusProcessor.
Implementing the Saga Pattern with Rebus and RabbitMQ in .NET
Learn the Saga pattern in .NET using Rebus and RabbitMQ with simple real-life examples, diagrams, correlation, compensation, and full C# code you can copy.