Loading content...
In distributed messaging, the acknowledgment is everything. It's the handshake that tells the queue: "I've received this message and processed it successfully—you can delete it now."
Without proper acknowledgment patterns, you face two equally dangerous failure modes:
The acknowledgment mechanism is what transforms a simple data pipe into a reliable work distribution system. In this page, we'll explore acknowledgment patterns, their implications, and how to implement them correctly in production systems.
By the end of this page, you will understand acknowledgment modes and their trade-offs, how to implement reliable consumers, batch acknowledgment for throughput, negative acknowledgment (NACK) patterns, handling acknowledgment failures, and the relationship between acknowledgments and exactly-once processing.
An acknowledgment (ACK) is a signal from the consumer to the queue indicating that a message has been successfully received and processed. Until acknowledged, the queue considers the message "in flight" and may redeliver it.
The contract is simple:
This contract ensures that messages are processed at least once—the foundation of reliable messaging.
If the consumer doesn't acknowledge:
The queue never assumes success—silence is interpreted as failure.
Some messaging libraries offer 'auto-acknowledge' mode where messages are acknowledged immediately upon receipt, before processing. This defeats the purpose of acknowledgments entirely—if processing fails, the message is already gone. Only use auto-ACK for genuinely disposable messages where loss is acceptable.
Different messaging systems offer various acknowledgment modes, each with distinct trade-offs between reliability, performance, and complexity.
Definition: The consumer explicitly sends an acknowledgment after processing completes.
How it works:
ack()Reliability: Highest—message survives consumer failures until acknowledged.
Complexity: Moderate—must handle ACK in all code paths including error handling.
1234567891011121314151617181920212223
// Manual acknowledgment: The reliable patternasync function processWithManualAck(channel: Channel): Promise<void> { channel.consume('orders', async (msg) => { if (!msg) return; try { const order = JSON.parse(msg.content.toString()); // Process the order await processOrder(order); // Only acknowledge after successful processing channel.ack(msg); console.log(`Order ${order.id} processed and acknowledged`); } catch (error) { console.error('Processing failed:', error); // Reject the message - will be redelivered or dead-lettered channel.nack(msg, false, true); // (msg, allUpTo, requeue) } }, { noAck: false }); // Critical: noAck must be false}| Mode | Reliability | Throughput | Complexity | Use Case |
|---|---|---|---|---|
| Manual ACK | Highest | Lower | Moderate | Critical business messages |
| Auto ACK | Lowest | Highest | Lowest | Disposable messages only |
| Batch ACK | High | High | Higher | High-volume processing |
A reliable consumer is one that correctly handles all scenarios: successful processing, failures, crashes, and edge cases. Building a reliable consumer requires careful attention to acknowledgment timing and error handling.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
// A production-ready reliable consumer implementationclass ReliableConsumer<T> { private isShuttingDown = false; private inFlightCount = 0; constructor( private queue: MessageQueue, private processor: MessageProcessor<T>, private config: ConsumerConfig ) { // Handle graceful shutdown process.on('SIGTERM', () => this.shutdown()); process.on('SIGINT', () => this.shutdown()); } async start(): Promise<void> { console.log('Consumer starting...'); while (!this.isShuttingDown) { try { const message = await this.queue.receive(this.config.queueName, { visibilityTimeout: this.config.visibilityTimeout, waitTimeSeconds: 20, }); if (message) { this.inFlightCount++; this.processMessage(message).finally(() => { this.inFlightCount--; }); } } catch (error) { console.error('Receive error:', error); await this.sleep(this.config.errorBackoffMs); } } } private async processMessage(message: QueueMessage<T>): Promise<void> { const startTime = Date.now(); let visibilityExtender: NodeJS.Timeout | null = null; try { // Start extending visibility for long-running tasks if (this.config.enableVisibilityExtension) { visibilityExtender = setInterval(async () => { try { await this.queue.extendVisibility( message.receiptHandle, this.config.visibilityTimeout ); } catch (e) { console.error('Visibility extension failed', e); } }, (this.config.visibilityTimeout * 1000) / 2); } // === PROCESS THE MESSAGE === await this.processor.process(message.body); // === ACKNOWLEDGE ONLY AFTER SUCCESS === await this.queue.delete(message.receiptHandle); console.log(`Processed in ${Date.now() - startTime}ms`); } catch (error) { await this.handleError(message, error); } finally { if (visibilityExtender) { clearInterval(visibilityExtender); } } } private async handleError(message: QueueMessage<T>, error: unknown): Promise<void> { const retryCount = message.attributes?.ApproximateReceiveCount || 0; console.error(`Processing failed (attempt ${retryCount})`, error); if (retryCount >= this.config.maxRetries) { // Max retries exceeded - let it go to DLQ console.error('Max retries exceeded, moving to DLQ'); // Most queues auto-move to DLQ after maxReceiveCount // Optionally delete here if no DLQ configured } else { // Make message visible again with backoff const backoff = Math.min( this.config.baseBackoffSeconds * Math.pow(2, retryCount), this.config.maxBackoffSeconds ); await this.queue.changeVisibility( message.receiptHandle, backoff ); } } private async shutdown(): Promise<void> { console.log('Shutdown initiated...'); this.isShuttingDown = true; // Wait for in-flight messages to complete const timeout = Date.now() + 30000; // 30 second max wait while (this.inFlightCount > 0 && Date.now() < timeout) { console.log(`Waiting for ${this.inFlightCount} in-flight messages...`); await this.sleep(1000); } console.log('Consumer shutdown complete'); process.exit(0); } private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }}Because messages can be redelivered (visibility timeout expiration, network issues, consumer crashes), your processing logic MUST be idempotent. Use unique message IDs to detect duplicates, implement upsert operations instead of inserts, and make state changes based on message content rather than receipt order.
A negative acknowledgment (NACK) is an explicit rejection of a message. Instead of waiting for visibility timeout to expire, the consumer actively tells the queue: "I cannot process this message."
Depending on the system and configuration, NACK can trigger different behaviors:
| System | NACK Option | Behavior |
|---|---|---|
| RabbitMQ | nack(msg, false, true) | Requeue: Message goes back to queue head |
| RabbitMQ | nack(msg, false, false) | Discard/DLQ: Message is rejected permanently |
| RabbitMQ | nack(msg, true, true) | Batch requeue: This and all previous messages |
| AWS SQS | changeVisibility(0) | Immediate redelivery to any consumer |
| AWS SQS | No explicit NACK | Wait for visibility timeout |
| Azure Service Bus | abandon() | Message returns to queue, delivery count increments |
| Azure Service Bus | deadLetter() | Move directly to dead-letter queue |
12345678910111213141516171819202122232425262728293031
// NACK pattern examples // RabbitMQ: Reject with requeuechannel.nack(msg, false, true); // (message, allUpTo, requeue)// Message returns to queue, may be delivered to same or different consumer // RabbitMQ: Reject without requeue (goes to DLQ if configured)channel.nack(msg, false, false);// Message is discarded or moved to dead-letter exchange // AWS SQS: Immediate retry (set visibility to 0)await sqs.changeMessageVisibility({ QueueUrl: queueUrl, ReceiptHandle: message.ReceiptHandle, VisibilityTimeout: 0 // Immediately visible again}).promise(); // AWS SQS: Delayed retry (exponential backoff)const retryCount = parseInt(message.Attributes?.ApproximateReceiveCount || '1');const delaySeconds = Math.min(30 * Math.pow(2, retryCount), 900); // max 15 minawait sqs.changeMessageVisibility({ QueueUrl: queueUrl, ReceiptHandle: message.ReceiptHandle, VisibilityTimeout: delaySeconds}).promise(); // Azure Service Bus: Explicit dead-letter with reasonawait receiver.deadLetterMessage(message, { deadLetterReason: 'ProcessingError', deadLetterErrorDescription: error.message});Use explicit NACK when:
changeVisibilityLet visibility timeout handle it when:
If you NACK with requeue and the message has a fatal error (malformed data, schema violation), the same message will be delivered again, fail again, and be requeued again—infinitely. Always track retry counts and move to dead-letter after max attempts.
The hardest problem in message processing is ensuring that acknowledgment and side effects happen atomically. Consider this scenario:
This is the classic dual-write problem—two systems (database and queue) must be updated consistently, but there's no distributed transaction spanning both.
1. Idempotent Operations
Make your database operations idempotent using message IDs:
1234567891011121314151617181920212223242526272829303132333435363738
// Idempotent processing with deduplicationasync function processOrderIdempotently(message: OrderMessage): Promise<void> { // Use message ID or idempotency key for deduplication const idempotencyKey = message.messageId; // Atomic upsert - only creates if not exists const result = await db.processedMessages.upsert({ where: { messageId: idempotencyKey }, create: { messageId: idempotencyKey, processedAt: new Date(), status: 'processing' }, update: {} // No-op if exists }); if (result.status === 'completed') { console.log('Message already processed, skipping'); return; // Duplicate detected } try { // Process the order await createOrder(message.payload); // Mark as completed await db.processedMessages.update({ where: { messageId: idempotencyKey }, data: { status: 'completed' } }); } catch (error) { await db.processedMessages.update({ where: { messageId: idempotencyKey }, data: { status: 'failed' } }); throw error; }}2. Transactional Outbox (Reverse)
Some systems support transactional acknowledgment where the ACK is part of the same transaction as the database write:
12345678910111213141516171819202122232425262728293031323334
// Kafka Streams / Kafka Exactly-Once Processing// ACK is part of the same transaction as state updates const producer = new Kafka().producer({ transactionalId: 'my-transactional-producer'}); await producer.connect(); // All operations in transaction commit togetherawait producer.transaction(async (txn) => { // Process message const result = processMessage(message); // Write output to downstream topic await txn.send({ topic: 'processed-orders', messages: [{ value: result }] }); // Commit consumer offset (acknowledgment) await txn.sendOffsets({ consumerGroupId: 'my-consumer-group', topics: [{ topic: 'orders', partitions: [{ partition: message.partition, offset: (parseInt(message.offset) + 1).toString() }] }] }); // Both happen atomically, or neither happens});For most systems, true distributed transactions are impractical. The recommended approach is: (1) Make all operations idempotent, (2) Store message IDs in your database, (3) Check for duplicates before processing. This handles 99.9% of cases without complex infrastructure.
What happens when the acknowledgment itself fails? You've processed the message, but the ACK network call times out or returns an error. The queue still thinks the message is in flight.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// Robust acknowledgment with retryasync function acknowledgeWithRetry( queue: MessageQueue, receiptHandle: string, maxRetries: number = 3): Promise<boolean> { let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await queue.delete(receiptHandle); return true; // Success } catch (error) { lastError = error as Error; console.warn(`ACK attempt ${attempt} failed:`, error); // Check if retry makes sense if (isReceiptHandleExpired(error)) { // Message will be redelivered anyway console.warn('Receipt handle expired, message will be redelivered'); return false; } if (isMessageNotFound(error)) { // Message already deleted (by another consumer or cleanup) console.warn('Message already deleted'); return true; // Treat as success } // Exponential backoff for transient errors if (attempt < maxRetries) { await sleep(Math.pow(2, attempt) * 100); } } } // All retries failed console.error('All ACK attempts failed:', lastError); // Message will be redelivered after visibility timeout // Consumer's idempotency will prevent duplicate processing return false;} // Usage in consumertry { await processMessage(message); const acked = await acknowledgeWithRetry(queue, message.receiptHandle); if (!acked) { // Log for monitoring but don't throw // Idempotent processing will handle redelivery metrics.increment('ack_failures'); }} catch (processingError) { // Handle processing error...}In production systems, acknowledge failures happen perhaps 0.01% of the time—but at scale, that's thousands of occurrences daily. Monitor ACK failure rates and investigate spikes. Consistent failures may indicate queue saturation, network issues, or configuration problems (visibility timeout too short).
For high-throughput systems, acknowledging each message individually creates significant overhead. Batch acknowledgment reduces this overhead but requires careful implementation.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Production batch acknowledgment implementationclass BatchAcknowledger { private pendingAcks: Map<string, AckEntry> = new Map(); private flushTimer: NodeJS.Timeout | null = null; constructor( private queue: MessageQueue, private batchSize: number = 10, private flushIntervalMs: number = 1000 ) { this.startFlushTimer(); } addForAcknowledgment(receiptHandle: string, messageId: string): void { this.pendingAcks.set(messageId, { receiptHandle, timestamp: Date.now() }); // Flush if batch is full if (this.pendingAcks.size >= this.batchSize) { this.flush(); } } private startFlushTimer(): void { this.flushTimer = setInterval(() => { if (this.pendingAcks.size > 0) { this.flush(); } }, this.flushIntervalMs); } private async flush(): Promise<void> { if (this.pendingAcks.size === 0) return; // Capture current batch const batch = Array.from(this.pendingAcks.entries()); this.pendingAcks.clear(); try { // Batch delete const entries = batch.map(([id, entry]) => ({ Id: id, ReceiptHandle: entry.receiptHandle })); const result = await this.queue.deleteMessageBatch(entries); // Handle partial failures if (result.Failed && result.Failed.length > 0) { console.error('Partial batch ACK failure:', result.Failed); // Failed messages will be redelivered after visibility timeout metrics.increment('batch_ack_partial_failures', result.Failed.length); } metrics.increment('messages_acknowledged', result.Successful?.length || 0); } catch (error) { console.error('Batch ACK failed entirely:', error); // All messages will be redelivered // They're already processed, so idempotent handling will dedupe metrics.increment('batch_ack_failures'); } } async shutdown(): Promise<void> { if (this.flushTimer) clearInterval(this.flushTimer); await this.flush(); // Final flush }}Most cloud queues allow batch operations of 10 messages. Kafka allows larger batches (configurable). Start with batch size equal to your prefetch count, and tune based on observed throughput and failure rates. Larger batches = higher throughput but more redelivery on failure.
Message acknowledgment is the contract between consumer and queue that enables reliable message processing. Proper acknowledgment handling is what makes distributed messaging actually work.
What's Next:
With acknowledgment patterns mastered, the next page explores Dead Letter Queues—the safety net for messages that can't be processed. We'll cover why they're essential, how to configure them, and patterns for monitoring and recovering from dead-lettered messages.
You now understand message acknowledgment: the handshake that enables reliable distributed processing. Manual ACK with idempotent processing is the gold standard for production systems.