Skip to main content
SEMastery

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.

14 min readUpdated December 15, 2025

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 orderingevery 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

Global
Per-key
Choice

Steps

1

Global ordering

one line, one worker, very slow

2

Per-key ordering

one line per key, many workers

3

What you usually want

per-key — fast and correct

Global ordering is one slow line. Per-key ordering is many fast lines, one per key.

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.

Figure 1: Competing consumers break order. Worker B finishes the withdraw before Worker A finishes the deposit.

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:

CauseWhat happensPlain explanation
Competing consumersMany workers process in parallelFaster worker finishes a later message first
Retries and re-deliveryFailed message comes back laterOld message slips behind new ones
Multiple partitions or queuesRelated messages split across lanesNo single lane keeps them in sequence
Network and GC pausesA worker stalls for a momentIts 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:

  1. Pick a key. Choose what must stay ordered together — usually an id like the order id, the customer id, or the account number.
  2. Route by key. Make sure all messages with the same key go into the same lane (a session, a partition, or a queue).
  3. 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.

Figure 2: The core idea. A router sends each key to a fixed lane, and one worker drains each lane in order.

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

Key
Route
Read

Steps

1

Pick a key

order id, customer id, account no

2

Route by key

same key to same lane always

3

One reader per lane

lane processed in sequence

Pick a key, route by key, one reader per lane. Every broker is a version of this.

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.

Figure 3: Service Bus sessions. Each SessionId is locked by one receiver, so its messages stay in order.

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.

BrokerLane is calledKey is calledOne-reader rule
Azure Service BusSessionSessionIdReceiver locks a session
Apache KafkaPartitionMessage KeyOne consumer per partition in a group
RabbitMQ (classic)QueueRouting/hash keySingle 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

Hash
Queues
SAC

Steps

1

Consistent-hash exchange

key decides the queue

2

Several queues

one lane each, same key stays put

3

Single active consumer

one reader per queue, in order

Hash the key into several queues, then put one active consumer on each queue.

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.

Figure 4: Ordering and idempotency together. The lane keeps sequence; the dedup check skips repeats.

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 choiceOrderingParallelismVerdict
Order idPer order, correctHigh (many orders)Great for order events
Customer idPer customerMedium to highGood if a customer's events relate
CountryToo coarseLow (few countries)Hot partitions, avoid
Random GUIDNone usefulMaximumNo 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.

Figure 5: The full ordered pipeline from producer to handler.

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 (SessionId plus a session processor with one call per session).
  • Kafka uses partitions (message Key hashed 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

Related Patterns