Azure Service Bus & Messaging
Queues, topics, dead-letter queues, and competing consumers β how reliable async messaging works in Azure.
Azure Service Bus is a fully managed enterprise message broker built on AMQP 1.0. It decouples producers from consumers using durable queues and topics, guaranteeing at-least-once delivery even if the consumer crashes mid-processing. Unlike storage queues, Service Bus provides message ordering (sessions), duplicate detection, and first-class dead-letter queue support.
A producer creates a ServiceBusSender and calls sendMessages(). The message is durably written to the broker using AMQP 1.0. Messages survive broker restarts because they are stored on disk. The broker assigns a SequenceNumber and tracks EnqueuedTimeUtc.
A receiver calls receiveMessages() or subscribe(). In Peek-Lock mode (the safe default), the broker locks the message for LockDuration (default 60s). The message stays in the queue but is invisible to other consumers. The delivery count increments on each lock acquisition.
The consumer MUST settle the message before the lock expires. CompleteMessage() removes it permanently. AbandonMessage() re-enqueues it (delivery count +1). DeadLetterMessage() moves it to the Dead-Letter Queue (DLQ). If the lock expires without settlement, Service Bus automatically re-enqueues it.
Multiple receiver instances connect to the same queue. Service Bus load-balances: each message goes to exactly ONE consumer. If Consumer A crashes mid-processing, its lock expires and Service Bus redelivers to Consumer B β guaranteed at-least-once delivery.
A Topic accepts messages and fans them out to all matching Subscriptions. Each subscription gets its own independent copy. Consumer group A can be an audit log writer, group B an email sender β both receive the same original message independently.
When MaxDeliveryCount (default 10) is exceeded, Service Bus automatically moves the message to the DLQ β a sub-queue at '<entity>/$DeadLetterQueue'. The DLQ never causes the main queue to back up, but an unchecked DLQ silently accumulates failed messages that must be separately processed or alerted on.
Key Concepts
Queue: competing consumers, each message β one receiver. Topic: fan-out, each Subscription gets a copy. Use queues for work distribution, topics for event broadcast.
Time a peek-locked message is invisible to other consumers (1sβ5min). If CompleteMessage() is not called before expiry, Service Bus re-enqueues the message automatically.
After this many failed deliveries (default 10), Service Bus auto-dead-letters the message. Set per queue/subscription, not per message.
Sub-queue at '<entity>/$DeadLetterQueue'. Holds poison messages. Must be monitored separately β a full DLQ causes silent data loss in some patterns.
RequiresSession=true enables ordered, FIFO processing per SessionId. Only one consumer holds a session lock at a time. Required for stateful workflows.
RequiresDuplicateDetection=true + a MessageId causes Service Bus to deduplicate within a configurable window (default 10min). The broker ignores duplicate MessageIds silently.
The wire protocol Service Bus uses (not HTTP by default). AMQP provides flow control, multiplexed channels, and lower latency than HTTP polling. The SDK uses it automatically.
Standard tier: max 256KB per message, shared infrastructure. Premium tier: dedicated capacity, up to 100MB messages, VNet integration, geo-disaster recovery.
1// Azure Service Bus β Peek-Lock pattern with proper settlement2// @azure/service-bus SDK v734import { ServiceBusClient } from "@azure/service-bus";56const sbClient = new ServiceBusClient(process.env.SERVICEBUS_CONNECTION_STRING!);78// --- SENDER ---9const sender = sbClient.createSender("orders");10await sender.sendMessages({11 body: { orderId: "ord-1234", amount: 99.99 },12 messageId: crypto.randomUUID(), // enables duplicate detection13 sessionId: "customer-42", // session-aware processing14 timeToLive: 60_000, // 1-minute TTL15});1617// --- RECEIVER (Peek-Lock mode β the safe default) ---18const receiver = sbClient.createReceiver("orders", {19 receiveMode: "peekLock", // default; alternative: "receiveAndDelete"20});2122receiver.subscribe({23 async processMessage(msg) {24 try {25 await processOrder(msg.body);26 await msg.completeMessage(); // REQUIRED β releases the lock, removes from queue27 } catch (err) {28 if (isTransient(err)) {29 await msg.abandonMessage(); // re-enqueues; delivery count increments30 } else {31 await msg.deadLetterMessage({ // explicit DLQ β sets DeadLetterReason32 deadLetterReason: "ProcessingFailed",33 deadLetterErrorDescription: String(err),34 });35 }36 }37 },38 async processError(err) {39 console.error("Service Bus error:", err.message);40 },41});4243// --- READ FROM DEAD-LETTER QUEUE ---44const dlqReceiver = sbClient.createReceiver(45 "orders",46 { subQueueType: "deadLetter" } // "$DeadLetterQueue" suffix handled automatically47);
Service Bus enables reliable microservice communication at scale. Because producers and consumers are decoupled, each service can scale independently, be deployed independently, and fail independently. The broker absorbs traffic spikes β a consumer crashing doesn't lose a single message. This is the foundation of every resilient Azure-native architecture.
Common Pitfalls
1E-commerce order processor β DLQ backpressure incident
A high-volume UK retailer processes ~50k orders/day through a Service Bus queue. After a Black Friday deployment, order processing started silently failing. Orders appeared to be accepted but were never fulfilled.
A deserialization bug caused processMessage() to throw on every message. Because the error handler called neither completeMessage() nor deadLetterMessage(), the lock expired repeatedly until MaxDeliveryCount was reached. Messages auto-dead-lettered. The DLQ filled to 200k messages over 6 hours. No alert was configured on DLQ depth.
Added AbandonMessage() in the catch block for transient errors and DeadLetterMessage() for permanent errors. Added an Azure Monitor alert on 'Dead-lettered message count > 100'. Added a separate DLQ drain process that re-plays messages after bugs are fixed. Lock duration was increased from 60s to 5min to match actual processing time.
Takeaway: The DLQ is a silent graveyard by default. Always monitor 'Dead-lettered message count' metric in Azure Monitor. Always explicitly settle messages β never let the lock expire as your error strategy.
2Insurance claims pipeline β session ordering violation
An insurance platform processes claim status updates via Service Bus. Claim state machine: Submitted β UnderReview β Approved/Rejected. Messages arrive per claim in order. Randomly, claims ended up in invalid states (e.g., 'Approved' before 'UnderReview').
The queue had RequiresSession=false with 8 competing consumers. Consumer A received the 'UnderReview' message and took 45 seconds to process it (external API call). Consumer B received 'Approved' for the same claim 2 seconds later β processed it first. The state machine transitioned out of order.
Enabled RequiresSession=true with SessionId set to the claimId. Now only one consumer holds the session lock for a given claim at a time. Processing is strictly ordered per claim, and other claims are processed in parallel by other consumers β no throughput loss.
Takeaway: Sessions in Service Bus are the only correct solution for ordered per-entity processing. Competing consumers WITHOUT sessions will process messages out of order under concurrency. This is a design decision, not a bug.
3Notification fan-out β topic with no subscriptions silently drops messages
A SaaS platform switched from a queue to a topic to support multiple notification channels (email, push, SMS). During a 2-week feature freeze, the topic existed but the subscription for SMS had been deleted for maintenance. SMS notifications were being silently dropped.
Azure Service Bus topics with no matching subscriptions β or subscriptions whose SQL filter matches no messages β silently discard messages with no error. The sender receives a successful acknowledgment from the broker. There is no 'message dropped' event or metric out of the box.
Implemented a 'catch-all' subscription with no filter that writes to a secondary storage queue for auditing. Added an Azure Monitor alert on 'Incoming messages vs messages delivered' ratio. Subscription lifecycle is now managed by the same deployment pipeline as the sender β they cannot diverge.
Takeaway: A Service Bus topic delivers only to live, filter-matching subscriptions. If no subscriptions exist, messages vanish permanently with no error. Always have at least one monitoring subscription and alert on delivery rate anomalies.