Loading content...
When architects say "we'll use a queue," they're often glossing over a critical question: which queue semantics do we need? Not all queues are created equal. The guarantees a queue provides—and doesn't provide—fundamentally shape how you design your system.
Will messages arrive in order? What happens if a message is lost? Can a message be delivered twice? How long will messages wait? These aren't implementation details—they're architectural decisions that ripple through your entire system design.
In this page, we'll dissect the semantic properties of message queues: ordering, delivery guarantees, durability models, and behavioral characteristics. Understanding these semantics is the difference between a system that works reliably and one that fails mysteriously under load.
By the end of this page, you will understand FIFO vs. standard queue ordering, the three delivery guarantee levels (at-most-once, at-least-once, exactly-once), durability configuration options, queue behavioral properties, and how to choose the right semantics for your use case.
Message ordering is perhaps the most misunderstood queue property. Developers often assume queues are strictly FIFO (First-In-First-Out), but the reality is more nuanced.
A FIFO queue guarantees that messages are delivered in the exact order they were sent. If Producer sends Message A, then Message B, the consumer will always receive A before B.
When you need FIFO:
A standard queue provides best-effort ordering. Messages are generally delivered in order, but this is not guaranteed. Under high load, infrastructure scaling, or failure recovery, messages may arrive out of order.
Why would you accept weaker ordering?
| Property | FIFO Queue | Standard Queue |
|---|---|---|
| Ordering Guarantee | Strict: exact producer order preserved | Best-effort: usually in order, not guaranteed |
| Throughput | Lower: ~300-3000 msg/sec typical | Higher: ~thousands to millions msg/sec |
| Duplication | Exactly-once delivery possible | At-least-once: duplicates possible |
| Cost (AWS SQS) | ~$0.50 per million requests | ~$0.40 per million requests |
| Use Case | Financial transactions, event sourcing | General work distribution, notifications |
| Scaling Model | Partitioned by message group | Fully distributed |
Even with a FIFO queue, using multiple competing consumers can break processing order. Message 1 might be delivered to Consumer A and Message 2 to Consumer B. If Consumer B finishes first, you've violated processing order despite delivery order being correct. For true FIFO processing, you need either a single consumer OR message groups with per-group single consumer assignment.
Message Groups (also called "message group IDs" in SQS, "session IDs" in Service Bus, or "partition keys" in Kafka) solve the competing consumers ordering problem by grouping related messages.
Messages with the same group ID are delivered in order to the same consumer. Messages with different group IDs can be processed in parallel by different consumers.
This enables:
123456789101112131415161718192021222324252627282930313233
// Using message groups for ordered delivery// All operations for a customer stay in order interface CustomerOperation { customerId: string; operationType: 'create' | 'update' | 'delete'; data: unknown; timestamp: Date;} class CustomerOperationProducer { async sendOperation(operation: CustomerOperation): Promise<void> { await this.queue.send('customer-operations', { body: operation, // Use customerId as the group ID // All operations for Customer-123 will be: // 1. Delivered in order // 2. Delivered to the same consumer messageGroupId: operation.customerId, // Deduplication ID prevents duplicate sends messageDeduplicationId: `${operation.customerId}-${operation.timestamp.getTime()}`, }); }} // Example usage:// These operations will be processed in order:producer.sendOperation({ customerId: 'cust-123', operationType: 'create', ... });producer.sendOperation({ customerId: 'cust-123', operationType: 'update', ... });producer.sendOperation({ customerId: 'cust-123', operationType: 'update', ... }); // This operation can be processed in parallel (different customer):producer.sendOperation({ customerId: 'cust-456', operationType: 'create', ... });If one message group receives 90% of your traffic (e.g., one giant enterprise customer), that group becomes a bottleneck. All those messages flow through one consumer. Consider subdividing hot groups: use 'customer-123-orders' and 'customer-123-notifications' instead of just 'customer-123'.
The delivery guarantee defines how many times a message might be delivered to consumers. This is one of the most critical semantic properties because it directly affects how you write consumer code.
There are three levels of delivery guarantees:
Definition: A message is delivered one or more times. It will never be lost, but may be duplicated.
How it works: The message is deleted from the queue after processing completes. If the consumer crashes, the message becomes visible again and is redelivered.
Implications:
Use when:
This is the most common guarantee in production systems.
12345678910111213141516171819
// At-Least-Once: Process first, then deleteasync function consumeAtLeastOnce() { const message = await queue.receive({ visibilityTimeout: 300 // 5 minutes }); try { // Process BEFORE deleting // This might be a retry - consumer must be idempotent await processMessage(message.body); // Only delete after successful processing await queue.delete(message.receiptHandle); } catch (error) { // Don't delete - message will be redelivered // after visibility timeout expires console.error('Processing failed, will retry:', error); }}| Guarantee | Message Loss? | Duplicates? | Consumer Requirement | Typical Use |
|---|---|---|---|---|
| At-Most-Once | Possible | Never | None | Metrics, non-critical logs |
| At-Least-Once | Never | Possible | Idempotent processing | Orders, payments, most business logic |
| Exactly-Once | Never | Never (effectively) | Transaction/dedup support | Financial systems, strict consistency needs |
Durability determines whether messages survive system failures. A durable queue persists messages to disk; a non-durable (volatile) queue keeps messages in memory only.
Messages are written to persistent storage (disk, distributed storage, replicated across nodes) before acknowledgment to the producer. If the queue crashes and restarts, messages are recovered.
Messages exist only in memory. They're faster but lost if the broker restarts.
| System | Durable Mode | Non-Durable Mode | Default |
|---|---|---|---|
| RabbitMQ | durable=true, delivery_mode=2 | durable=false | Non-durable |
| AWS SQS | Always durable (managed) | N/A | Durable |
| Redis Lists | AOF persistence, RDB snapshots | No persistence configured | Non-durable |
| ActiveMQ | persistent=true | persistent=false | Configurable |
| Kafka | replication.factor ≥ 2, acks=all | acks=0 or 1 | Durable (acks=1) |
Durability (disk persistence) protects against process crashes. Replication (multiple copies across nodes) protects against machine failures. For production systems, you typically want both: persisted messages replicated across multiple nodes in different availability zones.
Visibility timeout (or lock duration) is the period during which a message is invisible to other consumers after being received. This prevents multiple consumers from processing the same message simultaneously.
The visibility timeout must be longer than your maximum expected processing time. If processing exceeds the visibility timeout, the message becomes visible while still being processed, leading to duplicate delivery.
1234567891011121314151617181920212223242526272829
// Extending visibility timeout for long-running processingclass LongRunningConsumer { private heartbeatInterval: NodeJS.Timeout | null = null; async processMessage(message: QueueMessage): Promise<void> { // Start heartbeat to extend visibility this.heartbeatInterval = setInterval(async () => { try { // Extend visibility by another 30 seconds await this.queue.changeVisibility( message.receiptHandle, 30 // new visibility timeout ); console.log('Extended visibility timeout'); } catch (error) { console.error('Failed to extend visibility:', error); } }, 20000); // Extend every 20 seconds (before 30s timeout) try { await this.doLongRunningWork(message.body); await this.queue.delete(message.receiptHandle); } finally { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } } }}Rule of thumb: Set visibility timeout to 2-3x your 99th percentile processing time. If 99% of messages process in 30 seconds, set timeout to 60-90 seconds. For unpredictable workloads, implement visibility extension (heartbeat pattern) to dynamically extend the timeout during processing.
Message retention defines how long unprocessed messages remain in the queue. TTL (Time-To-Live) defines how long individual messages are valid.
The maximum time messages can sit in the queue before being automatically deleted. This protects against:
| Platform | Maximum Retention | Default | Notes |
|---|---|---|---|
| AWS SQS | 14 days | 4 days | Configurable per queue |
| Azure Service Bus | Unlimited (with quotas) | 14 days | Premium tier has higher limits |
| RabbitMQ | Unlimited | Until consumed | Can set TTL per message or queue |
| Google Cloud Pub/Sub | 7 days | 7 days | Messages expire after this |
| Apache Kafka | Unlimited | 7 days (default) | Log retention, not queue retention |
Individual messages can have their own expiration. When a message's TTL expires, it's either:
Common TTL patterns:
1234567891011121314151617181920212223
// Setting message-specific TTLawait queue.send('notifications', { body: { type: 'verification_code', code: '123456', userId: 'user-123' }, // Message expires in 5 minutes messageAttributes: { TTL: { dataType: 'Number', stringValue: '300' // seconds } }}); // RabbitMQ example: per-message TTLchannel.publish('exchange', 'routing.key', Buffer.from(JSON.stringify(message)), { expiration: '300000' // milliseconds });In some systems, expired messages at the head of the queue can block delivery of valid messages behind them. RabbitMQ, for instance, only checks TTL at the queue head. If message 1 (TTL: 1 hour) is ahead of message 2 (TTL: 1 minute), message 2 won't be dropped until message 1 is processed or expires.
Priority queues allow certain messages to jump ahead of others. Instead of strict FIFO, messages are ordered by priority level.
1. Native Priority Queue Some messaging systems support priority natively (RabbitMQ with x-max-priority). Messages are stored in a priority heap and dequeued by priority.
2. Multiple Queues Create separate queues for each priority level (high-priority-queue, low-priority-queue). Consumers poll high-priority first, then low-priority when high is empty.
3. Weighted Fair Queuing Consumers process N messages from high-priority for every 1 from low-priority. Ensures low-priority messages eventually process even under load.
12345678910111213141516171819202122232425262728293031323334
// Multi-queue priority consumption patternclass PriorityConsumer { private queues = ['high-priority', 'medium-priority', 'low-priority']; async consumeWithPriority(): Promise<void> { while (true) { let message = null; // Check queues in priority order for (const queueName of this.queues) { message = await this.queue.receiveNoWait(queueName); if (message) { break; // Found a message, process it } } if (message) { await this.processMessage(message); } else { // No messages anywhere, wait before polling again await sleep(100); } } }} // Weighted consumption: 5 high : 2 medium : 1 lowclass WeightedPriorityConsumer { private weights = { high: 5, medium: 2, low: 1 }; private counters = { high: 0, medium: 0, low: 0 }; // Reset counters when all weights are exhausted // Ensures fair distribution while maintaining priority}If high-priority messages arrive faster than they're processed, low-priority messages may never be delivered. This is 'starvation.' Mitigate with weighted scheduling, aging (low-priority messages gradually become high-priority), or strict low-priority SLAs.
Queue semantics define the rules of engagement between producers, queues, and consumers. Understanding these rules is essential for building reliable distributed systems.
What's Next:
With queue semantics understood, the next page explores Message Acknowledgment—the handshake between consumers and queues that determines message fate. We'll cover acknowledgment patterns, batch acknowledgment, negative acknowledgment, and building reliable consumer implementations.
You now understand the semantic properties of message queues: ordering guarantees, delivery semantics, durability, visibility, retention, and prioritization. These semantics form the contract between your application and the messaging system.