Skip to main content

Throughput Tuning

Which Azure Service Bus and NimBus parameters actually control throughput, what NimBus sets today, and what to set them to for different workload shapes.

Read this when an endpoint is falling behind, when you are sizing a new adapter, or before upgrading a namespace tier in the hope that it helps (often it does not — the ceiling is usually client-side concurrency or session-key design).


The four layers​

Throughput is bounded by the lowest of these. Tune in this order — the cheap layers are also the ones that usually bind.

LayerDialChange cost
1. Consumer concurrencyMaxConcurrentSessions, prefetch, replica countConfig / restart
2. PublisherBatching, client reuse, outbox dispatch rateCode
3. Entity + topologySession-key cardinality, lock duration, filter type, partitioningRe-provision (some are create-time only)
4. Namespace tierStandard → Premium, Messaging UnitsCost + migration

The single most common real ceiling in NimBus is session-key cardinality, not any of the numbers below. See Session cardinality is the real ceiling.


What NimBus sets today​

Consumer (Worker receiver)​

NimBusReceiverHostedService builds a ServiceBusSessionProcessor from NimBusReceiverOptions:

SettingNimBus valueNotes
MaxConcurrentSessions8The main throughput dial. Configurable.
MaxAutoLockRenewalDuration5 minConfigurable.
SessionIdleTimeout30 sConfigurable.
AutoCompleteMessagesfalseFixed — NimBus settles explicitly.
MaxConcurrentCallsPerSessionnot set → 1Deliberate: it is what makes per-session ordering hold (ADR-001). Do not raise it.
PrefetchCount0Configurable. Opt-in — off by default because prefetch hurts slow-handler workloads.

Consumer (Azure Functions)​

Concurrency comes from host.json, not from NimBus — see azure-functions-hosting.md. The same dials are available there under different names: maxConcurrentSessions, prefetchCount, sessionIdleTimeout, maxAutoLockRenewalDuration.

Deferred processor​

DeferredMessageProcessorHostedServiceOptions pins MaxConcurrentCalls = 1. That is the replay path's only ordering mechanism. Raising it trades ordered replay for throughput — a real option for endpoints that do not need ordered replay, but make it a conscious decision.

Hosting it yourself​

AddNimBusDeferredProcessorHostedService registers one deferred processor per process. A host that runs several endpoints in one process (the WebApp traffic simulator does) can construct DeferredMessageProcessorHostedService directly, one instance per endpoint, and call StartAsync / StopAsync itself:

var deferred = new DeferredMessageProcessorHostedService(
serviceBusClient,
new DeferredMessageProcessor(serviceBusClient),
new DeferredMessageProcessorHostedServiceOptions(TopicName: endpoint, SubscriptionName: "deferredprocessor"),
loggerFactory.CreateLogger<DeferredMessageProcessorHostedService>());
await deferred.StartAsync(cancellationToken);

The constructor rejects a blank topic or subscription and a MaxConcurrentCalls below 1. Leave MaxConcurrentCalls at 1 unless the endpoint tolerates out-of-order replay: the trigger subscription is non-session, so single concurrency is its only ordering mechanism.

Topology (ServiceBusTopologyProvisioner)​

EntitySettingValue
TopicSupportOrderingtrue
TopicEnableBatchedOperationstrue
TopicMaxSizeInMegabytes5120 (omitted on the emulator)
TopicDuplicateDetectionHistoryTimeWindow10 min — inert: RequiresDuplicateDetection is never set, so duplicate detection is off. No write-path cost, and no dedup either. Idempotent handlers or the inbox pattern remain your dedup story. Leave it off. Since 3.7.0 a rescheduled or replayed copy keeps its original MessageId so the tracking store can recognise it as already answered; with duplicate detection enabled the broker would silently drop that copy inside the detection window and the audit update would be lost. Brownfield namespaces must be checked before deploying.
SubscriptionMaxDeliveryCount10
SubscriptionLockDuration30 s
SubscriptionEnableBatchedOperationstrue
SubscriptionEnableDeadLetteringOnFilterEvaluationExceptionstrue
SubscriptionRequiresSessionPer endpoint definition
TopicEnablePartitioningNever set → non-partitioned

Topics are created non-partitioned. On Standard that caps a single topic at one broker's throughput. Partitioning is create-time only, so a partitioned topic means a new entity, not an update.

Client options​

NimBus does not construct the ServiceBusClient for adapters — your host registers it (AddNimBusPublisher/AddNimBusReceiver resolve it from DI). So ServiceBusClientOptions — TransportType, RetryOptions.TryTimeout, MaxRetries, Mode — are entirely yours to set, and default if you don't.


Layer 1 — Consumer concurrency​

MaxConcurrentSessions (Worker) / maxConcurrentSessions (Functions)​

The number of sessions processed in parallel per host. Per-session ordering is untouched; this only widens parallelism across sessions.

Starting point: MaxConcurrentSessions ≈ target messages/sec × average handler seconds, then round up ~50% for headroom. A handler averaging 200 ms that must sustain 100 msg/s needs ~20 concurrent sessions.

Raise it until one of these binds, then stop:

  • host CPU > ~70%
  • the downstream dependency (SQL, ERP API) starts throttling or its p95 climbs
  • distinct active sessions run out — the hard ceiling below
builder.Services.AddNimBusReceiver(opts =>
{
opts.TopicName = "CrmEndpoint";
opts.SubscriptionName = "CrmEndpoint";
opts.MaxConcurrentSessions = 32;
opts.MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5);
});

Session cardinality is the real ceiling​

Effective parallelism is min(MaxConcurrentSessions, distinct active session IDs). MaxConcurrentSessions = 200 against three session IDs gives you three concurrent messages, and no amount of scale-out or tier upgrade changes that.

Design the session key for the narrowest scope that still preserves the ordering you actually need:

Session keyCardinalityUse when
Per aggregate/entity (customerId, orderId)High — goodThe default. Ordering matters per entity, not globally.
Per tenantMedium — often too lowA big tenant becomes a serial hot spot.
Per endpoint / constant1 — a serial pipeOnly when the endpoint genuinely must be strictly serial.

A hot session (one entity producing a large share of traffic) serializes that share regardless of every other setting. Look for it before touching any dial.

SessionIdleTimeout​

How long a receiver holds an idle session before rotating. With many short-lived sessions, the default 30 s pins slots on drained sessions and starves waiting ones. Drop to 5–10 s for high-cardinality/short-session workloads. Keep it higher when sessions are long-lived and chatty, to avoid churning session locks.

MaxAutoLockRenewalDuration​

Not a throughput dial — it prevents lock loss from causing redelivery. Set above your p99 handler duration (including retries within the handler). Too low and slow messages get reprocessed, which costs throughput indirectly.

Prefetch​

Prefetch pulls messages into memory ahead of processing, removing a broker round-trip per message. It is the biggest single win for small, fast messages. It defaults to 0 (off) on both hosts, because on the wrong workload it makes throughput worse — see the lock caveat below.

builder.Services.AddNimBusReceiver(opts =>
{
opts.TopicName = "CrmEndpoint";
opts.SubscriptionName = "CrmEndpoint";
opts.MaxConcurrentSessions = 32;
opts.PrefetchCount = 320; // ~10x concurrency; fast handlers only
});
  • Starting point: PrefetchCount ≈ concurrency × 10–20, or roughly the number of messages consumed in 2–3 × average handler time.
  • Set 0 when handlers are slow (> ~1 s). Prefetched messages hold locks and their lock timers run while queued locally, so with slow handlers they expire and get redelivered — prefetch makes throughput worse.
  • Set 0 when messages are large; prefetch multiplies memory.

Because prefetch interacts with lock expiry, raise it and MaxAutoLockRenewalDuration/LockDuration together, and watch redelivery (nimbus.message.received climbing faster than nimbus.message.processed) after the change. The effective value is logged at receiver startup.

Scale out vs scale up​

One process with 200 concurrent sessions and one CPU core is worse than four processes with 50 each. Raise concurrency until CPU is the limit, then add replicas. Both scale the same session pool — sessions are distributed across competing receivers automatically.

Share one ServiceBusClient per namespace across all senders, receivers, and processors in the host. The client multiplexes over one AMQP connection; constructing several multiplies connections without adding throughput.


Layer 2 — Publisher​

  • Batch. ISender.Send(IEnumerable<IMessage>) sends in one call, which is worth roughly 5–10× over per-message sends on send-heavy paths. Caveat: NimBus passes the whole list to SendMessagesAsync without size-chunking (Sender.cs), so the Service Bus SDK throws if the batch exceeds the entity's max message size. Chunk large sets yourself (~100 messages, or below 256 KB Standard / 100 MB Premium total).
  • Reuse the client and sender. Senders are cached per entity; do not create one per publish.
  • Payload size dominates. Above a few hundred KB, use a claim-check (blob reference in the message) instead of an inline payload.
  • Outbox. The outbox trades publish latency for atomicity. AddNimBusOutboxDispatcher(pollingInterval, batchSize) defaults to 1 s / 100. The dispatcher drains greedily — a full batch re-polls immediately without waiting (OutboxDispatcherHostedService.cs:46) — so the interval only governs idle latency, not sustained rate. Raise batchSize (250–500) for high-volume publishing; lower pollingInterval (200–500 ms) only if publish latency at low volume matters, and note it costs a store query per tick.

Layer 3 — Entity and topology​

ParameterEffectGuidance
LockDuration (30 s)Too short → locks expire mid-handler → redelivery storms that eat throughputRaise toward 5 min for slow handlers, or rely on auto lock renewal. Max 5 min.
MaxDeliveryCount (10)Poison messages retry 10× before DLQ, burning capacityLower to 3–5 for endpoints with a strong permanent-failure classification (error-handling.md).
Filter typeSQL filters are evaluated per message per subscription and cost real CPUPrefer correlation filters where the routing is an equality match — dramatically cheaper at high volume.
EnablePartitioningSpreads one entity across brokers — the main way to lift a single topic's ceilingCreate-time only, and not set by the NimBus provisioner. On Premium, use namespace partitions instead.
RequiresDuplicateDetectionA write-path index on every sendOff today. Leave it off; dedup on the consumer side with the inbox.
ForwardTo (auto-forward)Each hop adds latency and an operationFine for the NimBus handoff paths; avoid deep chains on hot paths.
Subscription count per topicEvery subscription gets a copy — throughput cost is per subscriptionFan-out multiplies broker work; watch it when adding subscribers to a hot topic.

Layer 4 — Namespace tier​

  • Standard shares throughput across tenants and will throttle under load — the signal is ServiceBusFailureReason.ServiceBusy / HTTP 503 with retries in the client. No client-side setting fixes sustained throttling.
  • Premium gives dedicated Messaging Units (1/2/4/8/16), predictable latency, no throttling, and a 100 MB message limit. This is the fix for a genuinely throughput-bound namespace, and the only tier worth benchmarking against.
  • Premium scales two ways: Messaging Units (vertical) and namespace partitions (horizontal, set at namespace creation).
  • Geo-replication and zone redundancy add resilience, not throughput.

The NimBus topology is tier-agnostic (azure-requirements.md) — moving to Premium needs no code change.


Workload profiles​

Starting points, not benchmarks. Measure, then adjust — see the tuning procedure.

A. High-volume ingest — fast handler, ordering per entity​

Telemetry, change feeds, IoT-shaped events. Handler < 50 ms, small payloads, high session cardinality.

ParameterValue
MaxConcurrentSessions100–200 (Functions), 64–128 (Worker)
PrefetchCount200–500
SessionIdleTimeout5–10 s
LockDuration30 s (default)
MaxAutoLockRenewalDuration1–2 min
MaxDeliveryCount3–5
Session keyPer entity — cardinality in the thousands
TierPremium; partition the topic if a single topic saturates
Scale3+ replicas, then widen concurrency

B. Ordered business events — the typical NimBus adapter​

CRM/ERP integration, handler 100–500 ms, moderate volume, ordering matters per customer/order.

ParameterValue
MaxConcurrentSessions16–32
PrefetchCount0–50
SessionIdleTimeout30 s (default)
LockDuration30 s–1 min
MaxAutoLockRenewalDuration5 min (default)
MaxDeliveryCount10 (default)
Session keyPer aggregate
TierStandard is usually fine; Premium for predictable latency
Scale2 replicas for availability; widen concurrency first

This is what the NimBus defaults (8) target, conservatively. 16–32 is the usual first move for a busy adapter.

C. Slow external I/O — rate-limited upstream​

ERP APIs, SOAP endpoints, anything where the handler waits seconds and the downstream has its own quota.

ParameterValue
MaxConcurrentSessions4–16 — match the upstream's concurrency budget, not the bus's
PrefetchCount0 — prefetched locks would expire while queued
SessionIdleTimeout30–60 s
LockDuration5 min, or rely on lock renewal
MaxAutoLockRenewalDuration10+ min (above p99 handler time)
MaxDeliveryCount3–5, with permanent-failure classification
TierNot the bottleneck — do not upgrade for this
ScaleDo not scale out past the upstream's limit; queue depth is the buffer, that is the point

Here the bus is deliberately not the constraint. Adding concurrency converts a healthy backlog into upstream 429s and retry storms. A custom throttling pipeline behavior is a better lever than a bigger concurrency number.

The Resolver is profile C. Its limit is the Cosmos RU budget, not the bus: every event produces two Resolver messages (the request copy and the response), and each ends in Cosmos writes. It ships at maxConcurrentSessions = 16 (a template-owned app setting in deploy/bicep/deploy.core.bicep, tunable with nb setup --resolver-max-sessions) and prefetchCount = 0 in host.json. It deliberately uses a 1 s SessionIdleTimeout instead of profile C's 30–60 s: the Resolver sees thousands of per-aggregate sessions, and a session slot is held for handler time plus idle timeout per message, so its throughput ceiling is roughly sessions × instances / (t_handler + idle). With a 30 s idle timeout that ceiling collapses to a fraction of a message per second per slot; with 1 s the slot rotates to the next waiting session almost immediately.

D. Low-volume control plane — latency-sensitive​

Commands, request/reply, admin operations. A few messages per second, latency visible to a user.

ParameterValue
MaxConcurrentSessions4–8 (default is fine)
PrefetchCount0 — with low volume it adds latency, not throughput
SessionIdleTimeout5–10 s (faster rotation → lower time-to-first-message)
RetryOptions.TryTimeout10–20 s instead of the 60 s default, so a wedged operation fails fast
TierPremium if p99 latency is a requirement — Standard's tail is noisy
Scale2 replicas for availability

Note: request/reply creates and disposes a session receiver per request and is not built for high throughput (sdk-api-reference.md).

E. Backfill / replay bursts​

Migrations, catch-up after an outage, bulk resubmit from the WebApp.

ParameterValue
MaxConcurrentSessionsTemporarily 2–4× the steady-state value
PrefetchCountHigh (500+) — replay is the ideal prefetch case
MaxDeliveryCountKeep default; a backfill hitting poison data should DLQ, not spin
Deferred processor MaxConcurrentCallsConsider > 1 only if ordered replay is not required
PublisherAlways batch — this is where chunked batch sends pay off most
Outbox batchSize250–500 if the backfill publishes through the outbox
ScaleScale out for the duration, then scale back

Run backfills on a dedicated endpoint or replica set when possible, so the burst does not starve steady-state traffic sharing the same session pool.

F. Fan-out — one topic, many subscriptions​

ParameterValue
FiltersCorrelation filters, not SQL, on every subscription
SubscriptionsEvery subscription is a full copy — cost scales linearly
TierPremium once a hot topic exceeds ~10 subscriptions at volume
Per-subscriber concurrencyTune independently; slow subscribers must not dictate fast ones

Slow subscribers do not block fast ones, but they do accumulate backlog on their own subscription and contribute to topic size. Watch per-subscription depth, not just topic depth.


A tuning procedure that works​

  1. Measure first. Get the baseline: messages/sec, handler p50/p95/p99, backlog growth rate, active session count.
  2. Find the binding constraint. Handler duration? Downstream latency? Session cardinality? Broker throttling? Only one of them is binding — the others are noise.
  3. Change one dial. Concurrency first; it is free and reversible.
  4. Re-measure for at least 10 minutes under representative load. Short runs measure warm-up, not throughput.
  5. Stop when the next constraint appears. Over-tuning past the downstream's capacity converts a queue into a retry storm.

What to watch​

NimBus emits these OpenTelemetry meters (NimBusMeters.cs, see testing.md for wiring):

MetricReads as
nimbus.message.process.durationHandler cost — the input to every concurrency calculation
nimbus.message.queue_waitTime in the broker before consumer entry. Rising = under-provisioned consumers. The clearest under-capacity signal.
nimbus.message.e2e_latencyWhat the business actually experiences
nimbus.message.received vs nimbus.message.processedDivergence = in-flight backlog building
nimbus.message.publish.durationPublish-side cost; spikes here mean broker throttling
nimbus.outbox.dispatch.durationOutbox dispatcher keeping up or not

Alongside them, from Azure Monitor on the namespace: active message count (backlog), throttled requests (tier ceiling), server errors, and CPU/memory on Premium namespaces.


Symptoms and their causes​

SymptomLikely causeFix
Backlog grows, host CPU low, downstream idleSession cardinality too low, or a hot sessionRe-key sessions to a narrower scope
Backlog grows, MaxConcurrentSessions already highHandler is slow, or upstream throttlesProfile the handler; do not raise concurrency
Messages redelivered without handler errorsLock expiry — slow handlers, or prefetch holding locksRaise LockDuration / lock renewal; drop prefetch to 0
ServiceBusBusy / 503 on sendStandard tier throttlingBatch sends; move to Premium
Throughput does not improve after scaling outSession pool exhausted, or downstream is the limitCheck distinct active sessions; check downstream p95
Worker "processes one message at a time"MaxConcurrentSessions = 1, or a single session IDBoth are worth checking — the second is more common
DLQ fills during a load spikeMaxDeliveryCount exhausted by lock expiry, not by real failuresFix lock duration first; do not raise MaxDeliveryCount
Latency fine at low volume, terrible at highPrefetch 0 with small fast messages, or SQL filters at volumeAdd prefetch; convert filters to correlation

Full parameter reference​

ParameterWhereDefaultNimBusThroughput impact
MaxConcurrentSessionsNimBusReceiverOptions / host.json8 (NimBus)ConfigurableHigh — primary dial
MaxConcurrentCallsPerSessionProcessor options1Fixed at 1High, but breaks ordering
PrefetchCountNimBusReceiverOptions / host.json0ConfigurableHigh for small fast messages
SessionIdleTimeoutNimBusReceiverOptions30 sConfigurableMedium — session rotation
MaxAutoLockRenewalDurationNimBusReceiverOptions5 minConfigurableIndirect — prevents redelivery
Replica countHost platform——High — horizontal
Batch sendISender.Send(IEnumerable<>)—Available, unchunkedHigh on send-heavy paths
TransportTypeServiceBusClientOptionsAmqpTcpHost's choiceMedium — keep AmqpTcp
RetryOptions.TryTimeoutServiceBusClientOptions60 sHost's choiceMedium — tail latency
LockDurationSubscription30 s (NimBus)ProvisionerIndirect — redelivery
MaxDeliveryCountSubscription10 (NimBus)ProvisionerIndirect — wasted capacity
Filter typeSubscription rulesSQLPlatform definitionMedium at volume
EnablePartitioningTopic (create-time)offNot setHigh for a single hot topic
RequiresDuplicateDetectionTopic (create-time)offNot setNegative if enabled
Session key designYour event contract——Highest — the real ceiling
Tier / Messaging UnitsNamespaceStandardDeployment choiceHigh once throttled
Deferred MaxConcurrentCallsDeferred processor options1Fixed at 1 by defaultHigh for replay, breaks ordered replay
Outbox batchSizeAddNimBusOutboxDispatcher100ConfigurableMedium — outbox publish rate
Outbox pollingIntervalAddNimBusOutboxDispatcher1 sConfigurableIdle publish latency only (drains greedily)