Skip to content
SP StackPractices
intermediate By Mathias Paulenko

Message Deferral Pattern

Delay message processing to a scheduled time. Move messages that cannot be processed now to a deferred queue or schedule them for later delivery.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

Some messages cannot be processed immediately but should not be discarded. A message might depend on a resource that is temporarily unavailable, need to run at a specific time (scheduled notifications), or require a delay before retry (exponential backoff). The Message Deferral pattern moves these messages to a deferred state and delivers them later when conditions are met or a scheduled time arrives.

When to Use

  • For alternatives, see Dead Letter Channel Pattern.

  • Messages depend on resources that are temporarily unavailable (database maintenance, rate-limited APIs)

  • You need scheduled delivery (send a reminder 24 hours after signup)

  • You implement retry with exponential backoff (retry in 5s, 30s, 2min)

  • Messages must wait for a condition (process order only after payment confirmation)

Solution

Python (Celery + Redis — countdown)

from celery import Celery

app = Celery("tasks", broker="redis://localhost:6379", backend="redis://localhost:6379")

@app.task(bind=True, max_retries=5)
def process_payment(self, payment_id):
    try:
        result = charge_payment(payment_id)
        if result.status == "pending":
            # Defer: retry in 30 seconds
            raise self.retry(countdown=30)
        return result
    except GatewayUnavailable as exc:
        # Exponential backoff: 5s, 10s, 20s, 40s, 80s
        retry_count = self.request.retries
        delay = 5 * (2 ** retry_count)
        raise self.retry(exc=exc, countdown=delay)

def charge_payment(payment_id):
    # Simulate gateway unavailable
    raise GatewayUnavailable("Payment gateway down")

class GatewayUnavailable(Exception):
    pass

# Schedule a notification 24 hours from now
@app.task
def send_reminder(user_id):
    print(f"Sending reminder to user {user_id}")

def schedule_reminder(user_id, delay_seconds=86400):
    send_reminder.apply_async(args=[user_id], countdown=delay_seconds)

JavaScript (BullMQ — delayed jobs)

import { Queue, Worker } from "bullmq";

const paymentQueue = new Queue("payments", {
  connection: { host: "localhost", port: 6379 },
});

// Schedule a job with delay
async function schedulePaymentRetry(paymentId, delayMs) {
  await paymentQueue.add(
    "process-payment",
    { paymentId },
    { delay: delayMs } // BullMQ delivers after delay
  );
}

// Schedule reminder 24 hours from now
async function scheduleReminder(userId, delayMs = 86400000) {
  await paymentQueue.add(
    "send-reminder",
    { userId },
    { delay: delayMs }
  );
}

const worker = new Worker(
  "payments",
  async (job) => {
    try {
      const result = await chargePayment(job.data.paymentId);
      return result;
    } catch (err) {
      // Exponential backoff: 5s, 10s, 20s, 40s, 80s
      const retryCount = job.attemptsMade;
      const delay = 5000 * Math.pow(2, retryCount);
      throw err; // BullMQ retries with backoff config
    }
  },
  {
    connection: { host: "localhost", port: 6379 },
    attempts: 5,
    backoff: { type: "exponential", delay: 5000 },
  }
);

async function chargePayment(paymentId) {
  throw new Error("Payment gateway down");
}

Java (RabbitMQ — dead letter exchange with TTL)

import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

@Component
public class DeferralHandler {

    private final RabbitTemplate rabbitTemplate;

    public DeferralHandler(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    // Deferred queue: messages wait here with a TTL, then routed to main queue
    @Bean
    public Queue deferredQueue() {
        return QueueBuilder.durable("deferred-payments")
            .withArgument("x-dead-letter-exchange", "")
            .withArgument("x-dead-letter-routing-key", "payments")
            .withArgument("x-message-ttl", 30000) // 30 second delay
            .build();
    }

    // Defer a message: send to deferred queue, it will be routed back after TTL
    public void deferPayment(Integer paymentId, int delayMs) {
        rabbitTemplate.convertAndSend(
            "deferred-payments",
            "payment." + paymentId,
            paymentId,
            message -> {
                message.getMessageProperties().setExpiration(String.valueOf(delayMs));
                return message;
            }
        );
        System.out.println("Deferred payment " + paymentId + " for " + delayMs + "ms");
    }

    // Process deferred message when it arrives back in main queue
    public void processPayment(Integer paymentId) {
        try {
            System.out.println("Processing deferred payment " + paymentId);
            // Business logic
        } catch (Exception e) {
            // Re-defer with longer delay (exponential backoff)
            deferPayment(paymentId, 60000);
        }
    }
}

Explanation

There are three main approaches to message deferral:

Broker-native delay: Some brokers support delayed delivery directly. BullMQ uses Redis sorted sets to deliver messages after a delay. SQS has delay queues and message timers. Azure Service Bus supports scheduled enqueuing.

TTL + dead letter exchange: RabbitMQ does not support delayed delivery natively, but you can simulate it. Send a message to a queue with a per-message TTL and a dead letter exchange. When the TTL expires, RabbitMQ routes the message to the dead letter exchange, which points to your main queue. The message arrives in the main queue after the delay.

Application-level scheduling: The consumer stores the message and a scheduled time in a database, then a scheduler picks it up when the time arrives. This is more flexible but adds complexity.

Variants

VariantMechanismUse CaseTradeoff
Broker DelaySQS delay, BullMQ delaySimple scheduled deliveryLimited precision, broker-dependent
TTL + DLXRabbitMQ TTL + dead letterRabbitMQ environmentsComplex setup, per-message TTL is expensive
Scheduled StoreDB + schedulerPrecise scheduling, conditional deliveryMore infrastructure, polling overhead
Exponential BackoffRetry with increasing delayTransient failure recoveryMessages may wait a long time
Fixed ScheduleCron-like triggerRecurring tasksNot message-driven, separate system

What Works

  • Use broker-native delay when available (SQS, BullMQ, Azure Service Bus)
  • Set a max retry count to prevent infinite deferral loops
  • Use exponential backoff for transient failures to avoid overwhelming the system
  • Log deferred messages for visibility and debugging
  • Monitor deferred queue depth — a growing deferred queue indicates systemic issues
  • Set a max deferral time (e.g., 24 hours) after which messages go to a dead-letter queue

Common Mistakes

  • Infinite deferral loops: A message that always fails gets deferred forever. Set a max retry count and route to dead-letter after.
  • Per-message TTL in RabbitMQ: RabbitMQ only checks the head of the queue for TTL expiration. A message with a long TTL blocks messages behind it with shorter TTLs.
  • Not tracking deferred messages: If the broker restarts, deferred messages may be lost. Use durable queues and persistent messages.
  • Deferring instead of fixing: If a message always fails because of a bug, deferral just delays the problem. Fix the root cause.
  • Deferral for messages that should be rejected: Some messages are invalid and will never succeed. Reject them to a dead-letter queue instead of deferring.

FAQ

How is deferral different from retry?

Retry is an automatic mechanism where the broker redelivers a failed message. Deferral is an explicit decision by the consumer to delay processing. Retry is reactive, deferral is proactive.

What is the maximum delay I can set?

SQS supports up to 15 minutes per delay. BullMQ supports arbitrary delays. RabbitMQ TTL + DLX can handle any delay but with caveats. For very long delays (hours, days), use a scheduled store instead.

Should I use a separate queue for deferred messages?

Yes, if using RabbitMQ TTL + DLX. The deferred queue holds messages during the delay period, and the dead letter exchange routes them to the main queue when the TTL expires.

Can I cancel a deferred message?

With broker-native delay (SQS, BullMQ), you cannot cancel a message once submitted. With a scheduled store, you can mark the message as cancelled before the scheduler picks it up.

Advanced Topics

Scenario: Message Deferral for Delayed Retries

// Message deferral: delay processing of a message
// Case: card payment requires 30s wait before retry

// Using Azure Service Bus deferred messages
async function deferPaymentVerification(messageId: string, delaySeconds: number) {
  const sender = serviceBusClient.createSender("payments");
  const message = {
    body: { messageId, retryCount: 0 },
    scheduledEnqueueTimeUtc: new Date(Date.now() + delaySeconds * 1000),
  };
  await sender.sendMessages(message);
}

// Using SQS with delay queue
async function deferWithSQS(message: unknown, delaySeconds: number) {
  await sqs.sendMessage({
    QueueUrl: DELAY_QUEUE_URL,
    MessageBody: JSON.stringify(message),
    DelaySeconds: Math.min(delaySeconds, 900), // max 15 min in SQS
  }).promise();
}

// Worker: process with deferral logic
async function processWithDeferral(msg: PaymentMessage) {
  try {
    const result = await verifyPayment(msg.paymentId);
    if (result.status === "pending") {
      if (msg.retryCount < 5) {
        const delay = Math.pow(2, msg.retryCount) * 10; // 10s, 20s, 40s...
        await deferPaymentVerification(msg.id, delay);
        console.log(`Deferred ${msg.id} for ${delay}s (retry ${msg.retryCount + 1})`);
      } else {
        await moveToDLQ(msg, "Max retries exceeded");
      }
    } else {
      console.log(`Payment ${msg.paymentId} verified: ${result.status}`);
    }
  } catch (err) {
    if (msg.retryCount < 3) {
      await deferPaymentVerification(msg.id, 30);
    } else {
      await moveToDLQ(msg, err.message);
    }
  }
}

// Comparison: deferral strategies
  | Strategy | Implementation | Max delay | Use case |
  |----------|----------------|-----------|----------|
  | SQS DelaySeconds | SQS delay queue | 900s (15min) | Short retries |
  | SQS scheduled | Message timer | 900s | Exact delay retries |
  | EventBridge schedule | Cron/rate | Unlimited | Periodic schedules |
  | Service Bus deferred | Scheduled enqueue | Unlimited | Azure native |
  | RabbitMQ DLX + TTL | Dead letter + TTL | Configurable | RabbitMQ |

Lessons:

  • Deferral delays processing without blocking the worker
  • Exponential backoff: 10s, 20s, 40s, 80s, 160s
  • Max 5 retries: then goes to DLQ
  • SQS DelaySeconds max 900s (15 min): for longer delays, use EventBridge
  • The worker does not block waiting: the message returns to the queue after delay
  • Idempotency: the worker must be idempotent, the message may be processed multiple times

### How do I ensure idempotency with deferral?

Use a unique id (messageId) and a store (Redis/DB) to track state. Before processing, check if already processed: if result.status === "completed", skip. If "processing", wait or skip. Use optimistic locking: UPDATE messages SET status=processing WHERE id=X AND status=pending. If affected_rows=0, someone else processed it. Idempotency is mandatory in distributed systems: the same message may be delivered 1+ times.