TL;DR: Azure Service Bus does not support autoforwarding from a session-enabled subscription. A direct workaround is to process each subscription as an application input, but that spreads session locks, retries, monitoring, and shutdown across several receive points. A narrowly scoped transport-owned bridge can instead move each ordered subscription stream into one session-enabled endpoint queue. The application can then apply one recoverability model.
The earlier posts in my Azure Service Bus topology series looked at filters, shared-topic contention, failure boundaries, and incremental migration. This post adds another constraint to that discussion: ordered event streams.
My original sessions article covers the basic session model. The previous post in this mini-series explains why delayed retries need a session hold-back. Here, I want to keep that recoverability behavior in one place while events arrive through topics and subscriptions.
This topology only earns its complexity after the system has answered the question from You don’t need ordered delivery. If business-level state, idempotence, or a saga can handle events in either order, let the pub/sub system keep moving. If the event stream has a strict sequencing invariant, the topology must preserve it through forwarding and retries rather than only on the happy path.
The ordered design starts with one awkward broker rule: a session-enabled subscription cannot set ForwardTo.
Sessions remove the forwarding shortcut
A common endpoint topology uses Azure Service Bus autoforwarding:
publisher
-> topic
-> subscription
-> ForwardTo
-> endpoint input queue
-> message handler
The subscription selects events for one consumer. Azure Service Bus forwards those events to the consumer’s input queue. Commands sent directly to the endpoint and events published through topics meet at that queue. Monitoring, retries, and handler concurrency all line up around one input.
According to the Azure Service Bus autoforwarding documentation, autoforwarding is not supported for session-enabled entities. An ordered subscription must therefore keep its messages until a receiver consumes them.
The shortest workaround is to invoke handlers directly from each subscription. But that turns an infrastructure constraint into an application model. An endpoint with five ordered subscriptions now has five handler inputs, each with session concurrency, recoverability, lifecycle, critical-error, and observability concerns.
Another hop can keep those concerns together.
Keep subscription receivers out of the handler pipeline
The alternative is a transport-owned bridge for every session-enabled subscription. The bridge accepts sessions and moves their messages into the endpoint’s session-enabled input queue.
publisher
-> topic
-> session-enabled subscription, no ForwardTo
-> transactional subscription bridge
-> session-enabled endpoint input queue
-> session-aware message pump
-> message handler
Keep this component boring. It does not invoke handlers, decide retry delays, or block sessions after a processing failure.
It copies the body, headers, and relevant broker properties. It preserves SessionId and the logical message identity, chooses the destination broker identity according to the endpoint’s duplicate-detection policy, and settles the source safely.
That leaves one ordered processing boundary. Commands sent to the endpoint, events bridged from subscriptions, and operational retries all arrive at the input queue. The session-aware pump owns the ordering and recoverability contract once.
Make forwarding transactional
A naive bridge sends a copy and then completes the subscription message. If the process stops between those operations, the source is redelivered, and the destination may receive a duplicate. Reversing the operations creates a loss window instead.
Azure Service Bus supports transactions that span entities in the same namespace. With cross-entity transactions enabled, the bridge can send the copy and complete the source under one transaction.
ServiceBusClient bridgeClient = new(
connectionString,
new ServiceBusClientOptions
{
EnableCrossEntityTransactions = true
});
ServiceBusSender inputQueueSender =
bridgeClient.CreateSender("sales-input");
ServiceBusSessionProcessor bridgeProcessor =
bridgeClient.CreateSessionProcessor(
"orders",
"sales-sub",
new ServiceBusSessionProcessorOptions
{
AutoCompleteMessages = false,
MaxConcurrentSessions = 8,
MaxConcurrentCallsPerSession = 1,
ReceiveMode = ServiceBusReceiveMode.PeekLock
});
bridgeProcessor.ProcessMessageAsync += async args =>
{
ServiceBusReceivedMessage source = args.Message;
ServiceBusMessage copy = new(source)
{
SessionId = args.SessionId
};
using TransactionScope transaction = new(
TransactionScopeAsyncFlowOption.Enabled);
await inputQueueSender.SendMessageAsync(copy);
await args.CompleteMessageAsync(source);
transaction.Complete();
};
The sample keeps the transaction visible by omitting lifecycle management, cancellation, diagnostics, and error handling. Production code must start and stop processors with the endpoint, report repeated bridge failures, and decide which native properties should survive the copy.
The bridge has a narrow responsibility. Building and operating the complete feature is still a sizable transport change.
The spike behind this article verifies cross-entity forwarding against a live namespace, including rollback. It also exercises concurrent producers, multiple retries, and a graceful restart in the input pump. It does not yet prove every hard-kill boundary or duplicate-detection configuration.
Each accepted session is limited to one concurrent call. Several sessions can move at once, but two messages from the same session should not race through one bridge processor.
Keep the ordering promise narrow
A session-enabled subscription owns an ordered stream for each SessionId. The bridge preserves that stream as it copies messages into the endpoint queue. Once copied, the endpoint queue establishes the processing order for that session.
That is not a global ordering guarantee. Suppose one endpoint subscribes to an orders topic and an inventory topic. Each topic has an independent subscription, and both subscriptions contain messages for Customer-123. Azure Service Bus does not coordinate an order across those subscriptions. Two bridges cannot reconstruct one.
A direct command for Customer-123 can also reach the input queue while bridged events are in flight. The input queue has a definite arrival order, but that order does not prove which event happened first across the source entities.
The useful promise is narrower: preserve each source subscription’s per-session order into the input queue, then preserve ordered endpoint processing per session at that queue. Anything stronger would need a distributed ordering coordinator outside the native session model.
The bridge relocates back-pressure
Without ForwardTo, a slow bridge leaves messages in the subscription. Subscriptions share the topic’s storage budget. One stalled ordered subscriber can therefore contribute to topic depth and eventually affect publishers or unrelated subscribers using that topic.
A healthy bridge drains messages into the endpoint input queue. The endpoint’s backlog then consumes the queue’s capacity rather than the topic’s capacity. This moves the normal buffering boundary closer to the consumer who owns the work.
It does not remove back-pressure. If the input queue is full or unavailable, the transactional send cannot commit. The source message remains in the subscription and pressure returns to the topic.
Native autoforwarding can dead-letter at the source when destination problems repeatedly prevent forwarding. A custom bridge needs an equally deliberate escape valve. After a bounded number of failures, it may need to dead-letter the source message with enough diagnostics to explain why the endpoint queue rejected it. Otherwise, the bridge can stop draining forever. The exact source-side dead-lettering policy remains an open production item.
Configuration becomes part of the contract
RequiresSession is fixed when a queue or subscription is created. Enabling ordered processing on an existing endpoint, therefore, needs a migration plan, new entity names, or fail-fast validation. It is not a runtime switch that can update an entity in place.
Every message entering the ordered path also needs a SessionId. That includes published events, direct commands, bridge copies, and operational retries. Rejecting a message before send is safer than discovering the missing identifier when the session-enabled entity refuses delivery.
Partitioning adds another constraint. On partitioned session entities, the session identifier also selects the partition. Any explicit partition or transaction partition key must remain compatible with it. Batching may need to group messages by destination and session rather than destination alone.
The receive mode matters too. Receive-and-delete removes the settlement operations needed for a transactional bridge and for session-aware recovery. Peek-lock is the practical foundation when the system promises recoverability as well as order.
Keep one endpoint queue
It would be simpler to describe this topology as “consume the subscriptions.” It would be harder to operate. Every new subscription would add another place where handlers can fail and another place where the system must understand blocked sessions and retries.
That extra broker hop and its transaction cost preserve a stronger application boundary. The endpoint still has one input queue. That queue owns concurrency, session state, delayed-retry hold-backs, error handling, and operational visibility.
Azure Service Bus cannot autoforward a session-enabled subscription. The application does not have to inherit that limitation as its programming model.
Further reading:
Common questions
These are the questions I would ask before adding ordered subscriptions to an endpoint.
Can a session-enabled subscription use ForwardTo?
No. Azure Service Bus does not support autoforwarding on session-enabled subscriptions, so code must receive those messages.
Does a bridge create global ordering across topics?
No. It can preserve per-session order from one source subscription. Independent topics, subscriptions, commands, and retries do not share a broker-wide ordering authority.
Why use a transaction?
The bridge must send a destination copy and settle the source as one outcome. Without a transaction, a crash between those operations creates a duplicate or loss window.