Requeue vs Drop #

In message-driven architecture, failure is a certainty we must anticipate from the start. When a consumer application receives a message from a RabbitMQ queue, there’s no guarantee that business logic execution will always run smoothly. Connections to external databases can suddenly drop, data payloads can be corrupted due to serialization failures on the producer side, or the third-party systems we call may be temporarily down. At this critical moment, our consumer application must make a very crucial architectural decision: should we send the message back into the queue to be retried later (Requeue), or should we permanently remove the message from the main queue (Drop)?

The decision between requeueing or dropping is not just a technical matter of writing one line of code. This choice directly determines system stability, business data consistency, processing latency, and even the efficiency of our broker’s computing resource usage. Errors in applying this failure-handling strategy can cause fatal problems, from memory leaks, stuck queues, to endless processing load pile-ups capable of crippling our entire RabbitMQ cluster.

The Anatomy of Message Processing Failures #

Before comparing the technical actions for handling failed messages, we need to understand why a message can fail to process in production. Broadly speaking, message processing failures are classified by the source of the problem. This understanding is very important because different failure types demand different handling responses from our consumer application.

flowchart TD
    Receive["Message Received by Consumer Application"] --> Execute["Business Logic Execution"]
    
    Execute --> TempErr["Temporary Errors<br>- Database Timeout<br>- Third-party API Down<br>- Network Glitch"]
    Execute --> PermErr["Permanent Errors<br/>- Corrupted Payload / Bad JSON<br/>- Schema Validation Failed<br/>- Business Rule Violation"]
    
    TempErr --> Requeue["REQUEUE (Resend to Queue)"]
    PermErr --> Drop["DROP (Dead Letter Exchange)"]

The AMQP 0-9-1 protocol gives consumers full control to manage message status after receipt. Through the acknowledgement mechanism, consumers can actively tell the broker what to do with the message they hold. When processing fails, the consumer has the option to reject that message using the basic.reject command (for one message) or basic.nack (for one or many messages in bulk). Inside that rejection command, there is one vital boolean parameter named requeue that determines the message’s next fate inside the broker.


The Requeue Mechanism (Resending Messages) #

The Requeue mechanism occurs when the consumer application rejects a message by calling basic.reject or basic.nack with the requeue = true parameter. When this instruction is received by the RabbitMQ broker, the broker doesn’t delete the message from memory or disk. Instead, the broker returns the message status from Unacknowledged (being processed) back to Ready (ready to be redelivered to a consumer).

Internally, RabbitMQ tries to preserve message order as much as possible. If the rejecting consumer is the only consumer connected to that queue, the requeued message is usually placed back at the front of the queue (head of the queue). This aims to let the message be reprocessed as soon as possible once the consumer is ready. However, if many consumers are connected (competing consumers), the broker may send the requeued message to another idle consumer.

The Big Danger: Infinite Redelivery Loops #

Although it sounds very practical for guaranteeing data reliability, blindly requeueing carries a huge latent danger in production. If the cause of the message processing failure is permanent (e.g., a corrupted JSON message format that can’t be unmarshaled by the consumer), then every time the consumer tries to process that message, the process always fails.

If the consumer always responds to that failure by sending requeue = true, the following terrifying scenario occurs:

  1. The consumer receives the corrupted message from the queue.
  2. The parsing process fails because of the wrong data format.
  3. The consumer sends basic.nack(requeue=true).
  4. RabbitMQ receives the rejection and places the message back at the queue head.
  5. RabbitMQ immediately redelivers the same corrupted message to the same consumer (or a neighboring consumer).
  6. The consumer receives the message again, fails again, and sends another requeue.

This cycle repeats thousands of times per second endlessly. This phenomenon is known as the Poison Message Problem or Infinite Redelivery Loop. The instant impacts of this condition are CPU usage spikes up to 100% on both the broker and consumer application sides, application logs filled with the same error messages until disk storage space is exhausted, and continuously increasing memory consumption. Worse, because the corrupted message keeps occupying the front queue position, other healthy messages behind it never get a chance to be processed (Head-of-Line Blocking). Our entire message processing system experiences total congestion.

To detect whether a message has been redelivered before, we can check the boolean Redelivered flag property included in every message delivery metadata. Here is a simple consumer-side logic example to detect redelivered messages:

// Basic Redelivered flag check
if d.Redelivered {
    log.Printf("[WARN] Pesan dengan ID %s terdeteksi sebagai redelivered. Waspadai poison message!", d.MessageId)
    // Here we can apply special diversion logic, e.g., drop to the DLQ
}

Although the Redelivered flag helps us detect that the message is a redelivery, its main weakness is that this flag is only a boolean value (true or false). We can’t know whether this is the 2nd, 10th, or 10,000th redelivery. Therefore, requeueing directly to the original queue without a retry-count filter is a very dangerous action for production systems.


The Drop Mechanism (Discarding or Diverting Messages) #

The Drop mechanism occurs when the consumer application rejects a message by calling basic.reject or basic.nack with the requeue = false parameter. By sending this signal, the consumer explicitly states to the RabbitMQ broker: “I can’t process this message, and don’t ever try to send it back to this main queue.”

When the broker receives the requeue = false instruction, it immediately deletes the message from the main queue where it resides. However, this deletion action doesn’t always mean the message is lost forever without a trace. The final fate of a dropped message depends entirely on the queue configuration we set beforehand:

  1. If the Queue Has a Dead Letter Exchange (DLX) Configuration: RabbitMQ doesn’t throw that message into the trash. Instead, the broker routes the message to a special Exchange defined as the Dead Letter Exchange. From that Exchange, the message flows to a failed-message holding queue called the Dead Letter Queue (DLQ). Inside the DLQ, the corrupted message is stored safely for further manual analysis by the development team, or automatically reprocessed by a secondary reconciliation system after the root cause is fixed.
  2. If the Queue Doesn’t Have a Dead Letter Exchange (DLX) Configuration: RabbitMQ permanently deletes the message from the storage system. The message disappears forever from broker memory and disk. This scenario is called a Silent Drop and is very dangerous for important business data, because we lose the trace of failed transaction data without any opportunity for audit or data recovery.
flowchart TD
    Start["requeue = false"] --> Cond{"Does the Queue have a DLX?"}
    Cond -->|"Yes"| DLX["Diverted to DLX/DLQ (Isolation & Audit)"]
    Cond -->|"No"| Delete["Permanently Deleted (Silent Drop)"]

By enabling the DLX configuration, the drop action (requeue = false) becomes a very elegant data rescue mechanism. We can remove problematic messages that could clog the main queue flow without fearing the loss of valuable data. Main system throughput stays high, and our operations team gets full visibility into system failures through metric monitoring on the DLQ.


In-Depth Requeue vs Drop Comparison #

To make it easier for us to choose the right strategy when designing error handling on RabbitMQ consumers, here is an in-depth comparison table between the Requeue and Drop mechanisms:

Comparison DimensionRequeue (requeue = true)Drop (requeue = false)
Concept DefinitionReturns the message to the original queue to be retried soon.Permanently removes the message from the original queue.
Message Order (FIFO)Disrupts the original order if there are parallel consumers or if the message enters a delay queue.The original order is broken for the failed message, but other messages keep flowing.
System ThroughputCan drop drastically if repeated failure loops occur.Stays high because problematic messages are immediately removed from the queue.
CPU & Memory LoadHigh if infinite retries occur from constant processing cycles.Low and stable because failed messages are only handled once in the main queue.
Message StorageThe message stays in the main queue.The message is deleted, or diverted to a dedicated queue (DLQ) via the DLX.
Best Use CasesVery short transient failures (e.g., millisecond network glitches).Permanent failures (corrupted data) or failures needing a long pause.
Main RiskCauses total queue stalls and consumer/broker crashes.Losing data forever if not combined with DLX/DLQ.

The best choice in production isn’t picking one extremely; it’s intelligently combining both based on classifying the error types occurring dynamically in our consumer application code.


Failure Classification: Transient vs Permanent Errors #

The key to a resilient error-handling system lies in our consumer application’s ability to distinguish between Transient Errors and Permanent Errors. When consumer code detects an error, it must evaluate the nature of that error before deciding to call the Ack function, Nack with requeue, or Nack without requeue.

1. Transient Errors #

Transient errors are disruptions caused by dynamic external factors that usually recover by themselves within seconds or minutes. When these errors occur, the message payload is actually very valid and there’s no problem with our application code. The problem purely lies in the surrounding environment not being ready.

Common examples of transient errors include:

  • Database Timeout / Deadlock: The database is busy processing other queries so the consumer connection times out, or a database transaction deadlock occurs that can be resolved by retrying.
  • Network Partition / Glitch: A momentary network disruption cuts the HTTP connection to an external API or service dependency.
  • Rate Limiting: A third-party service limits our requests by returning a 429 Too Many Requests status response.

Handling Strategy: For transient errors, we must not immediately discard the message (drop), because that data is valid and must be processed. However, we also should not do an instant direct requeue (requeue = true without a delay), because if the database is down for 10 minutes, instant requeues only worsen the load on a database struggling to recover.

The best solution is doing a requeue accompanied by a delay retry time delay using a combination of TTL and DLX, or using the Exponential Backoff algorithm discussed in the following articles in this section.


2. Permanent Errors #

Permanent errors are failures caused by internal data or system logic factors that are static. Trying to reprocess these messages a million times will never produce success as long as there’s no application code change or manual fix to the message payload.

Examples of permanent errors include:

  • Malformed Payload (Corrupted Data): The message data format doesn’t conform to standards, e.g., an empty payload, or data that should be JSON format sent as plain text, triggering unmarshal errors.
  • Validation Failure: Payload data violates important schema or business rules, e.g., an invalid email column, or a negative amount transaction value not allowed by the accounting system.
  • Resource Not Found: A message asks to update user data with user_id = 999, but after checking the database, user data with that ID truly never existed in the system.

Handling Strategy: For permanent errors, we must drop the message from the main queue using basic.nack(requeue=false). Keeping this message in the main queue only wastes system resources. However, make sure our main queue is bound to a Dead Letter Exchange (DLX) so dropped messages enter the Dead Letter Queue (DLQ). That way, developers can inspect the corrupted message payload contents, fix application code bugs if any, or contact the message producer team to fix their data, without disturbing our main system operations.


Impact on Message Order (FIFO Ordering) #

One of the important characteristics of RabbitMQ queues is the FIFO (First-In, First-Out) message delivery order guarantee. Messages entering the queue first are guaranteed to be delivered first to connected consumers. However, once we introduce error-handling mechanisms like requeue, this strict FIFO guarantee is disrupted.

Let’s study the following scenario to understand how requeue breaks message order:

  1. A producer sends three sequential messages: Message A (order 1), Message B (order 2), and Message C (order 3).
  2. A consumer receives Message A first. However, while processing Message A, a temporary database connection failure occurs.
  3. Meanwhile, a second consumer receives Message B and successfully processes it because the database connection has recovered.
  4. The first consumer rejects Message A by calling requeue = true.
  5. The broker places Message A back into the queue. Because of the processing and rejection time gap, Message A is now processed after Message B finishes executing. The final processing order occurring in our system becomes: B -> A -> C.
flowchart TD
    subgraph UrutanAsli["Original Order"]
        direction LR
        A["Message A"] --> B["Message B"] --> C["Message C"]
    end

    A -->|"Fails & Requeues"| AR["Message A returned to the Queue"]
    B -->|"Success"| BR["Result: Message B Done"]

    AR -->|"Reprocessed"| AS["Message A Done"]
    AS --> CS["Message C Done"]
    
    BR --> Result["Final Processing Result: B -> A -> C (FIFO Order Broken!)"]
    CS --> Result

If our application heavily depends on absolute message ordering (e.g., bank transaction history queues where debited balances must happen before credited balances), broken FIFO order from requeues can trigger financial data chaos.

Solutions for Maintaining Message Order #

If our system requires very strict message ordering guarantees (Strict FIFO), we can’t use ordinary asynchronous requeue patterns. Some architecture solutions we can apply include:

  • Single Active Consumer (SAC): Limit the queue so only one active consumer processes messages serially. If a message fails, the consumer must stop processing (block), crash itself, or send an emergency alarm to the operations team, without sending ACKs or NACK requeues. The queue freezes until the problem is manually resolved. This is the expensive price paid to maintain absolute data order consistency.
  • Consumer-Side Idempotency: Design consumers to be idempotent. That means, if messages are processed out of order or duplicate processing occurs, the final business effect in the database stays consistent and doesn’t damage data. With idempotency, we don’t need to worry about broken FIFO order from requeues.

Requeue Behavior on Quorum Queues #

When we migrate from Classic Queues to Quorum Queues for high-reliability Raft consensus replication, it’s important to understand that internal requeue behavior changes significantly.

On Classic Queues, the requeue process is very light on the broker side because RabbitMQ only manipulates message status pointers in that node’s local RAM memory. However, on Quorum Queues, every message status change (including message rejection and its return to the queue) must be consistently recorded in the Raft log and replicated to a majority of follower nodes in the cluster before the broker sends confirmation. This triggers a fairly large disk I/O overhead on our broker cluster.

Native Redelivery Tracking Features on Quorum Queues #

To overcome the overhead problem and the danger of very resource-wasteful infinite retry loops, Quorum Queues provide a highly advanced native redelivery tracking feature. Every time a message is redelivered to a consumer after being rejected (nack/requeue), the Quorum Queue broker automatically tracks the delivery count and stores it in the message metadata.

RabbitMQ provides a special argument when declaring Quorum Queues named x-delivery-limit. We can use this parameter to limit the maximum number of message redeliveries automatically on the broker side:

// Illustration of Quorum Queue declaration arguments with a redelivery limit
args := amqp.Table{
    "x-queue-type":       "quorum",
    "x-delivery-limit":   int32(5), // Messages are automatically dropped/dead-lettered after 5 failures
}

If a message fails to process and is requeued repeatedly until it exceeds the specified x-delivery-limit value (in the example above, 5 times), the RabbitMQ broker automatically removes that message from the main Quorum Queue and routes it to the configured Dead Letter Exchange (DLX). This mechanism runs entirely on the broker side, protecting our system from infinite loop dangers caused by code-writing negligence on the consumer side.


Go Code Implementation (Golang) #

For practical understanding, here is a complete consumer code implementation example in Go using the official github.com/rabbitmq/amqp091-go library. This code demonstrates how consumers distinguish between transient (temporary) errors and permanent errors when processing payment transaction JSON payloads.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"

	amqp "github.com/rabbitmq/amqp091-go"
)

// PaymentPayload represents our business message data structure
type PaymentPayload struct {
	TransactionID string  `json:"transaction_id"`
	UserID        string  `json:"user_id"`
	Amount        float64 `json:"amount"`
}

// Define special error types for error-handling classification needs
var (
	ErrDatabaseTimeout = errors.New("temporary database connection timeout")
	ErrInvalidData     = errors.New("permanent invalid business data")
)

func main() {
	// 1. Initialize the physical connection to the RabbitMQ broker
	conn, err := amqp.Dial("amqp://guest:***@localhost:5672/")
	if err != nil {
		log.Fatalf("Gagal membuka koneksi ke RabbitMQ: %v", err)
	}
	defer conn.Close()

	// 2. Create an AMQP communication channel
	ch, err := conn.Channel()
	if err != nil {
		log.Fatalf("Gagal membuka channel AMQP: %v", err)
	}
	defer ch.Close()

	// 3. Set the Prefetch QoS value to limit message pile-ups in consumer memory
	err = ch.Qos(
		10,    // prefetch count: limit a maximum of 10 in-flight messages per consumer thread
		0,     // prefetch size: no byte size limits
		false, // global: the setting applies per active channel
	)
	if err != nil {
		log.Fatalf("Gagal menetapkan Qos Prefetch: %v", err)
	}

	// 4. Register the consumer to the main queue with manual ACK enabled (autoAck = false)
	msgs, err := ch.Consume(
		"payment-processing-queue", // main queue name
		"payment-consumer-service", // unique consumer identity tag
		false,                      // auto-ack: false (we must manage ACK/NACK manually)
		false,                      // exclusive: the queue can be accessed by many parallel consumers
		false,                      // no-local: not supported by RabbitMQ
		false,                      // no-wait: wait for declaration verification from the broker
		nil,                        // additional arguments
	)
	if err != nil {
		log.Fatalf("Gagal mendaftarkan konsumen pada antrean: %v", err)
	}

	log.Println("[INFO] Konsumen pembayaran aktif. Menunggu pesan masuk...")

	// Use a Go context for safe shutdown coordination
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Main goroutine to read the message stream from the RabbitMQ channel
	go func() {
		for d := range msgs {
			log.Printf("[RECEIVED] Memproses pesan ID: %s", d.MessageId)

			// Execute the payment processing business function
			err := processPayment(ctx, d.Body)
			if err != nil {
				// 5. Error Classification: Evaluate the failure type that occurred
				if errors.Is(err, ErrDatabaseTimeout) {
					// CASE A: Temporary Errors (Transient Errors)
					log.Printf("[WARN] Kegagalan sementara pada pesan %s: %v. Mengirimkan NACK dengan Requeue...", d.MessageId, err)
					
					// Send NACK with requeue = true to be retried later
					nackErr := d.Nack(
						false, // multiple: only reject this active message
						true,  // requeue: true (the message returns to the main queue)
					)
					if nackErr != nil {
						log.Printf("[ERROR] Gagal mengirimkan NACK Requeue ke broker: %v", nackErr)
					}
				} else {
					// CASE B: Permanent Errors or Data Parsing Errors
					log.Printf("[ERROR] Kegagalan permanen pada pesan %s: %v. Mengirimkan NACK tanpa Requeue...", d.MessageId, err)
					
					// Send NACK with requeue = false so the message is removed from the main queue.
					// If the payment-processing-queue is configured with a DLX, this message enters the DLQ.
					nackErr := d.Nack(
						false, // multiple: only reject this active message
						false, // requeue: false (the message is dropped/diverted to the DLX)
					)
					if nackErr != nil {
						log.Printf("[ERROR] Gagal mengirimkan NACK Drop ke broker: %v", nackErr)
					}
				}
				continue
			}

			// CASE C: Successful Processing Without Obstacles
			log.Printf("[SUCCESS] Sukses memproses pesan ID: %s. Mengirimkan manual ACK...", d.MessageId)
			ackErr := d.Ack(
				false, // multiple: only confirm this active message
			)
			if ackErr != nil {
				log.Printf("[ERROR] Gagal mengirimkan ACK ke broker: %v", ackErr)
			}
		}
	}()

	// Handle graceful shutdown when receiving an OS interrupt signal (Ctrl+C)
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
	<-sigChan

	log.Println("[INFO] Shutdown sinyal diterima. Menghentikan konsumen secara aman...")
}

// processPayment is a simulated business logic function for payment data processing
func processPayment(ctx context.Context, payloadBytes []byte) error {
	var payment PaymentPayload

	// 1. Validate the JSON Payload Syntax (Permanent Error)
	err := json.Unmarshal(payloadBytes, &payment)
	if err != nil {
		return errors.New("malformed payload: failed to parse JSON structure")
	}

	// 2. Validate Business Logic Rules (Permanent Error)
	if payment.Amount <= 0 {
		return ErrInvalidData
	}

	// 3. Simulate Relational Transaction Execution (Database)
	dbErr := simulateDatabaseQuery(payment.TransactionID)
	if dbErr != nil {
		return dbErr // Returns ErrDatabaseTimeout (Transient Error)
	}

	return nil
}

// simulateDatabaseQuery simulates a database query that could potentially time out
func simulateDatabaseQuery(txID string) error {
	// e.g: For demonstration purposes, if the transaction ID ends with the letter 'E', simulate a db timeout
	if len(txID) > 0 && txID[len(txID)-1] == 'E' {
		return ErrDatabaseTimeout
	}
	return nil
}

Anti-Patterns vs Practical Solutions #

To keep our production system safe from sudden outages and data leaks, avoid the following common mistakes (anti-patterns) when designing requeue vs drop flows:

Anti-Pattern 1: Always Requeueing in the Catch/Error Block Without Exceptions #

Writing consumer code where all caught error types automatically respond by sending the requeue = true instruction to the broker.

// ANTI-PATTERN: Blindly setting requeue = true without checking the error cause
go func() {
    for d := range msgs {
        err := process(d.Body)
        if err != nil {
            // DON'T DO THIS: Messages with JSON unmarshal errors will clog the queue forever
            d.Nack(false, true) 
            continue
        }
        d.Ack(false)
    }
}()

Why is this wrong? #

The code above is the main trigger of infinite redelivery loops that can cause 100% CPU spikes on our RabbitMQ cluster. Wrong-format messages keep spinning in the queue, blocking other valid messages, and flooding disk storage with identical error log lines.

Practical Solution #

Separate error handling strictly. Validate payload data at the start of the process. If data fails format validation, immediately call d.Nack(false, false) so the message is discarded to the Dead Letter Exchange (DLX). Use requeue = true only if we’re sure the error is caused by transient network/database factors that can recover by themselves.


Anti-Pattern 2: Dropping Messages Without Configuring a DLX (Silent Drop) #

Sending message rejection instructions with requeue = false on a queue without a Dead Letter Exchange or Dead Letter Queue binding.

// ANTI-PATTERN: Dropping business messages without a safety net (DLQ)
// The broker permanently deletes the message from disk/RAM
d.Nack(false, false) 

Why is this wrong? #

This pattern causes mysterious transaction data loss (silent data loss). We have no proof or history of which transaction data failed, what caused the failures, and no way to recover or reprocess that data after system issues are fixed.

Practical Solution #

Never use requeue = false unless we’ve ensured the main queue is explicitly configured with the x-dead-letter-exchange argument to an actively monitored Dead Letter Queue. The DLQ acts as the primary safety net ensuring no important data bytes are lost without an audit trail.


Summary #

  • Message Rejection Decisions — When message processing fails, the consumer application must decide whether to Requeue (requeue = true) to retry, or Drop (requeue = false) to remove the message.
  • Infinite Loop Risk — Directly requeueing without limits on corrupted messages (poison messages) triggers vicious redelivery cycles causing 100% CPU and stuck queues.
  • The Role of the Dead Letter Exchange (DLX) — Using the drop action (requeue = false) combined with a DLX lets us isolate failed messages into a Dead Letter Queue (DLQ) for investigation, without fearing business data loss.
  • Mandatory Error Classification — Divide error handling into Transient Errors (like db timeouts, use delay retries) and Permanent Errors (like data parsing errors, instantly drop to the DLQ).
  • FIFO Impact — Requeueing breaks RabbitMQ’s FIFO message order guarantee. If strict ordering is absolutely required, use Single Active Consumer or ensure application-level handling is idempotent.
  • Quorum Queue Advantages — Provides the native x-delivery-limit feature on the broker side to automatically stop requeue cycles and divert messages to the DLX when attempt limits are exceeded.

← Previous: Consumer Acknowledge   Next: Dead Letter Exchange (DLX) →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact