Back to Blog

AWS SQS FIFO Queues: Ordering, Deduplication, Message Groups, and Dead Letter Configuration

Master AWS SQS FIFO queues for ordered, exactly-once message delivery. Covers message group IDs for parallel ordering, deduplication IDs, dead letter queues, content-based deduplication, Terraform setup, and TypeScript consumer patterns.

Viprasol Tech Team
April 18, 2027
12 min read

Standard SQS queues are fast and nearly unlimited in throughput, but they don't guarantee message order and can deliver the same message more than once. FIFO (First-In, First-Out) queues guarantee order and exactly-once processing — at the cost of lower throughput (3,000 messages/second with batching) and a few implementation constraints that trip up teams who aren't familiar with them.

This guide covers FIFO queue design, message group IDs for parallel ordering, deduplication, dead letter configuration, and real consumer patterns.

Standard vs FIFO: When to Use Each

RequirementStandard QueueFIFO Queue
Maximum throughput✅ Unlimited❌ 3,000/s (batched)
Message ordering❌ Best-effort✅ Strict per group
Exactly-once delivery❌ At-least-once✅ Yes
Use caseNotifications, fan-outPayments, order processing, ledger events

Terraform: FIFO Queue Setup

# terraform/sqs-fifo.tf

# Dead letter queue (must also be FIFO)
resource "aws_sqs_queue" "orders_dlq" {
  name                        = "orders-dlq.fifo"
  fifo_queue                  = true
  content_based_deduplication = true  # Hash of body as dedup ID

  # DLQ messages expire after 14 days (max)
  message_retention_seconds = 1209600

  tags = { Environment = var.environment }
}

# Main FIFO queue
resource "aws_sqs_queue" "orders" {
  name                        = "orders.fifo"
  fifo_queue                  = true
  content_based_deduplication = false  # We'll provide explicit dedup IDs

  # How long a consumer has to process a message before it reappears
  # Set this LONGER than your expected processing time to avoid double delivery
  visibility_timeout_seconds = 60

  # How long messages wait before expiring (max 14 days)
  message_retention_seconds = 86400  # 1 day for orders

  # Long polling: reduce empty receives (saves cost)
  receive_wait_time_seconds = 20

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.orders_dlq.arn
    maxReceiveCount     = 5  # After 5 failed attempts, move to DLQ
  })

  tags = { Environment = var.environment }
}

# Allow ECS task / Lambda to send and receive
data "aws_iam_policy_document" "orders_queue_policy" {
  statement {
    effect    = "Allow"
    actions   = ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes", "sqs:ChangeMessageVisibility"]
    resources = [aws_sqs_queue.orders.arn]
    principals {
      type        = "AWS"
      identifiers = [aws_iam_role.app.arn]
    }
  }
}

resource "aws_sqs_queue_policy" "orders" {
  queue_url = aws_sqs_queue.orders.id
  policy    = data.aws_iam_policy_document.orders_queue_policy.json
}

output "orders_queue_url" { value = aws_sqs_queue.orders.id }
output "orders_dlq_url"   { value = aws_sqs_queue.orders_dlq.id }

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

TypeScript: Sending Messages

// lib/queues/orders-queue.ts
import { SQSClient, SendMessageCommand, SendMessageBatchCommand } from "@aws-sdk/client-sqs";

const sqs = new SQSClient({ region: process.env.AWS_REGION! });
const QUEUE_URL = process.env.ORDERS_QUEUE_URL!;

export type OrderEvent =
  | { type: "order.created";  orderId: string; userId: string; amount: number }
  | { type: "order.paid";     orderId: string; paymentId: string }
  | { type: "order.shipped";  orderId: string; trackingNumber: string }
  | { type: "order.cancelled"; orderId: string; reason: string };

interface SendOrderEventParams {
  event: OrderEvent;
  // Message group = partition key for ordering
  // All events for the same order use the same group ID
  // → events for order-123 are always processed in order
  // → events for different orders process in parallel
  messageGroupId: string;
  // Deduplication ID: SQS rejects duplicate sends within 5-minute window
  // Use idempotency key or deterministic ID (event type + resource ID + timestamp)
  deduplicationId: string;
}

export async function sendOrderEvent({
  event,
  messageGroupId,
  deduplicationId,
}: SendOrderEventParams): Promise<string> {
  const command = new SendMessageCommand({
    QueueUrl:               QUEUE_URL,
    MessageBody:            JSON.stringify(event),
    MessageGroupId:         messageGroupId,
    MessageDeduplicationId: deduplicationId,
    // Optional: delay message (not available for FIFO — use EventBridge Scheduler instead)
  });

  const response = await sqs.send(command);
  return response.MessageId!;
}

// Helper: deterministic dedup ID
export function makeDeduplicationId(parts: string[]): string {
  const { createHash } = require("crypto");
  return createHash("sha256").update(parts.join(":")).digest("hex").slice(0, 128);
}

// Usage: send an order created event
async function handleOrderCreated(order: { id: string; userId: string; amount: number }) {
  await sendOrderEvent({
    event: {
      type:    "order.created",
      orderId: order.id,
      userId:  order.userId,
      amount:  order.amount,
    },
    // Group ID = order ID: all events for this order are ordered
    messageGroupId: `order-${order.id}`,
    // Dedup ID = event type + order ID: safe to retry API calls
    deduplicationId: makeDeduplicationId(["order.created", order.id]),
  });
}

Batch Send

// Send up to 10 messages in a single API call (reduces cost)
export async function sendOrderEventBatch(
  messages: Array<{ event: OrderEvent; messageGroupId: string; deduplicationId: string; id: string }>
): Promise<void> {
  const command = new SendMessageBatchCommand({
    QueueUrl: QUEUE_URL,
    Entries: messages.map(({ event, messageGroupId, deduplicationId, id }) => ({
      Id:                     id,  // Unique within the batch (not the SQS message ID)
      MessageBody:            JSON.stringify(event),
      MessageGroupId:         messageGroupId,
      MessageDeduplicationId: deduplicationId,
    })),
  });

  const response = await sqs.send(command);

  if (response.Failed && response.Failed.length > 0) {
    throw new Error(
      `Batch send partial failure: ${response.Failed.map((f) => `${f.Id}: ${f.Message}`).join(", ")}`
    );
  }
}

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

TypeScript: Consumer (ECS / Lambda)

// workers/order-consumer.ts
import {
  SQSClient,
  ReceiveMessageCommand,
  DeleteMessageCommand,
  ChangeMessageVisibilityCommand,
} from "@aws-sdk/client-sqs";
import type { OrderEvent } from "@/lib/queues/orders-queue";

const sqs = new SQSClient({ region: process.env.AWS_REGION! });
const QUEUE_URL = process.env.ORDERS_QUEUE_URL!;

// Process a single SQS message
async function processMessage(message: {
  body: string;
  receiptHandle: string;
  messageId: string;
  attributes: Record<string, string>;
}): Promise<void> {
  const event = JSON.parse(message.body) as OrderEvent;
  const approximateReceiveCount = parseInt(message.attributes.ApproximateReceiveCount ?? "1");

  console.log(`Processing ${event.type} (attempt ${approximateReceiveCount})`, {
    messageId: message.messageId,
  });

  switch (event.type) {
    case "order.created":
      await handleOrderCreated(event);
      break;
    case "order.paid":
      await handleOrderPaid(event);
      break;
    case "order.shipped":
      await handleOrderShipped(event);
      break;
    case "order.cancelled":
      await handleOrderCancelled(event);
      break;
    default:
      console.warn("Unknown event type:", (event as any).type);
  }
}

// Main polling loop (runs in ECS task)
async function startConsumer(): Promise<void> {
  console.log("Order consumer started");

  while (true) {
    const command = new ReceiveMessageCommand({
      QueueUrl:            QUEUE_URL,
      MaxNumberOfMessages: 10,   // FIFO: max 10 in flight per group by default
      WaitTimeSeconds:     20,   // Long polling — avoids empty receive cost
      AttributeNames:      ["ApproximateReceiveCount", "MessageGroupId"],
    });

    const { Messages } = await sqs.send(command);

    if (!Messages || Messages.length === 0) continue;

    // Process messages sequentially within each group
    // (FIFO delivers them in order; parallel across groups is safe)
    for (const msg of Messages) {
      const receiptHandle = msg.ReceiptHandle!;

      try {
        await processMessage({
          body:          msg.Body!,
          receiptHandle,
          messageId:     msg.MessageId!,
          attributes:    msg.Attributes ?? {},
        });

        // Delete message only after successful processing
        await sqs.send(new DeleteMessageCommand({
          QueueUrl:      QUEUE_URL,
          ReceiptHandle: receiptHandle,
        }));

      } catch (err) {
        console.error("Failed to process message:", err);

        // Extend visibility timeout on transient errors
        // (lets it retry faster than waiting for original timeout to expire)
        const receiveCount = parseInt(msg.Attributes?.ApproximateReceiveCount ?? "1");
        if (receiveCount < 5) {
          const backoffSeconds = Math.min(30 * receiveCount, 120);
          await sqs.send(new ChangeMessageVisibilityCommand({
            QueueUrl:          QUEUE_URL,
            ReceiptHandle:     receiptHandle,
            VisibilityTimeout: backoffSeconds,
          })).catch(() => {}); // Non-critical — let natural expiry handle it
        }
        // If receive count >= 5, SQS will automatically move to DLQ
      }
    }
  }
}

startConsumer().catch((err) => {
  console.error("Consumer crashed:", err);
  process.exit(1);
});

Lambda Consumer (Event Source Mapping)

# Lambda FIFO consumer (scales to 1 concurrent instance per group)
resource "aws_lambda_event_source_mapping" "orders" {
  event_source_arn = aws_sqs_queue.orders.arn
  function_name    = aws_lambda_function.order_processor.arn
  batch_size       = 10
  # For FIFO: scaling_config not available — Lambda creates 1 concurrent invocation per group
  # With 5 active order groups, Lambda runs 5 concurrent instances
}
// lambda/order-processor/index.ts
import type { SQSEvent, SQSRecord } from "aws-lambda";

export async function handler(event: SQSEvent): Promise<void> {
  // Lambda FIFO: all records in a batch are from the SAME message group
  // If any record fails, the entire batch is retried (respects order)
  for (const record of event.Records) {
    const orderEvent = JSON.parse(record.body);
    await processOrderEvent(orderEvent);
    // No need to delete — Lambda auto-deletes on success
  }
}

// For partial batch failures (some records succeed, some fail):
// Return { batchItemFailures: [{ itemIdentifier: record.messageId }] }
// Only failed items go back to queue

DLQ Monitoring and Alerting

# Alert when DLQ has messages (means orders failed processing)
resource "aws_cloudwatch_metric_alarm" "orders_dlq" {
  alarm_name          = "orders-dlq-not-empty"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "ApproximateNumberOfMessagesVisible"
  namespace           = "AWS/SQS"
  period              = 60
  statistic           = "Sum"
  threshold           = 0
  alarm_description   = "Order DLQ has messages — investigate failed order events"

  dimensions = { QueueName = aws_sqs_queue.orders_dlq.name }

  alarm_actions = [aws_sns_topic.alerts.arn]
  ok_actions    = [aws_sns_topic.alerts.arn]
}

Common Pitfalls

// ❌ PITFALL 1: Not using MessageGroupId correctly
// If all messages go to the same group, you get no parallelism
await sendMessage({ MessageGroupId: "all" }); // Everything sequential!

// ✅ Group by natural ordering unit
await sendMessage({ MessageGroupId: `order-${orderId}` }); // Order per customer

// ❌ PITFALL 2: Visibility timeout shorter than processing time
// Message reappears mid-processing → double processing
// VisibilityTimeout: 10 seconds, but processing takes 30 seconds → duplicate

// ✅ Set visibility timeout to (max processing time) × 2
// If processing takes up to 30s: set 60s visibility timeout

// ❌ PITFALL 3: Deduplication ID collision
// Reusing the same dedup ID for logically different messages → second message silently dropped
// Always include enough entropy: event type + resource ID + version or timestamp

// ❌ PITFALL 4: Consuming FIFO with multiple parallel consumers
// If you spin up 2 consumer threads polling the same queue, ordering within a group
// can be violated if both pick up messages from the same group
// ✅ SQS handles this automatically: only one consumer gets messages from a given group at a time

Cost and Throughput

Queue TypeThroughputCost
StandardNearly unlimited$0.40/million requests
FIFO3,000 msg/s (batching)$0.50/million requests
FIFO high throughput mode30,000 msg/s$0.50/million + $0.01/million for high-throughput requests

At 1 million messages/day: ~$0.50/day for FIFO.

See Also


Working With Viprasol

FIFO queues are the right tool for payment processing, order management, and any workflow where event order matters — but they require care around message groups, deduplication IDs, and visibility timeouts. Our team has built FIFO-based event pipelines for financial SaaS and e-commerce platforms, with DLQ monitoring, alerting, and reprocessing tooling.

What we deliver:

  • Terraform: FIFO queue + DLQ + IAM policy + CloudWatch alarm
  • TypeScript producer with deterministic deduplication IDs
  • ECS long-poll consumer with exponential backoff on failure
  • Lambda event source mapping with partial batch failure handling
  • Per-messageGroup ordering strategy design (partition key selection)

Talk to our team about your event queue architecture →

Or explore our cloud infrastructure services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.