Siddhant Deval
Siddhant Deval
backend17 min read

Messaging Foundations: SQS, SNS & Fan-Out Patterns

SNS and SQS solve different problems and fail differently — SNS is a notification fan-out bus that drops messages after delivery attempts; SQS is a durable work queue that holds messages until a consumer explicitly deletes them. This article covers the at-least-once delivery contract, visibility timeout mechanics, DLQ configuration, SNS-to-SQS fan-out, message filtering policies, and FIFO ordering guarantees.

Messaging Foundations: SQS, SNS & Fan-Out Patterns

Every AWS primitive is a tradeoff surface, not a feature toggle. The most common messaging architecture mistake in serverless systems is treating SNS as a durable work queue because "it triggers Lambda." SNS does trigger Lambda — but when Lambda fails and exhausts its retry budget, SNS drops the message. Silently. With no visibility and no recovery path unless you configured an SNS DLQ (which almost no one does, because the Lambda trigger makes it feel handled). SQS, by contrast, holds the message until the consumer explicitly deletes it, and a DLQ captures everything that exhausts maxReceiveCount. These two services solve different problems. Using the wrong one loses work in production.


1. SQS — The Durable Work Queue

SQS guarantees message retention until explicit deletion. A message placed in an SQS queue survives Lambda failures, network partitions, and consumer restarts — it remains in the queue, becoming visible again after the visibility timeout expires.

1.1 The Visibility Timeout Contract

1. Producer: SendMessage → message enters queue (visible)
2. Consumer: ReceiveMessage → message becomes INVISIBLE for visibility_timeout seconds
3. Consumer: processes the message
   → Success path: DeleteMessage → message permanently removed
   → Failure path: handler throws exception → message remains invisible until timeout
4. After visibility_timeout expires: message becomes VISIBLE again → another consumer picks it up
5. After maxReceiveCount failures: message moves to DLQ
TYPESCRIPT
// CDK: SQS queue with DLQ and Lambda ESM
import { Queue } from 'aws-cdk-lib/aws-sqs'
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'

const dlq = new Queue(this, 'OrderProcessorDLQ', {
  retentionPeriod: Duration.days(14), // Keep failed messages for 2 weeks
})

const queue = new Queue(this, 'OrderQueue', {
  // Visibility timeout must be >= 6× the Lambda function timeout
  // If Lambda timeout = 30s, set visibility timeout = 180s minimum
  visibilityTimeout: Duration.seconds(300),
  deadLetterQueue: {
    queue: dlq,
    maxReceiveCount: 3, // After 3 failed attempts, message goes to DLQ
  },
})

orderProcessorFn.addEventSource(new SqsEventSource(queue, {
  batchSize: 10,
  maxBatchingWindow: Duration.seconds(5),
  reportBatchItemFailures: true,
}))
Performance / Safety Warning

The visibility timeout must be at least 6× your Lambda function timeout. The SQS ESM poller uses a 3-minute session timeout internally. If your Lambda timeout is 60 seconds and the visibility timeout is 30 seconds, messages become visible again before Lambda finishes processing them — producing duplicate deliveries to other Lambda instances while the first is still running.

1.2 Dynamic Visibility Timeout Extension

For long-running processing where you cannot predict duration:

TYPESCRIPT
import { SQSClient, ChangeMessageVisibilityCommand } from '@aws-sdk/client-sqs'

const sqs = new SQSClient({ region: process.env.AWS_REGION })

export const handler = async (event: AWSLambda.SQSEvent, context: AWSLambda.Context) => {
  for (const record of event.Records) {
    // Extend visibility timeout every 30s to prevent re-delivery during long processing
    const extensionInterval = setInterval(async () => {
      const remaining = context.getRemainingTimeInMillis()
      if (remaining > 5000) { // Still have time left
        await sqs.send(new ChangeMessageVisibilityCommand({
          QueueUrl: process.env.QUEUE_URL!,
          ReceiptHandle: record.receiptHandle,
          VisibilityTimeout: 60, // Extend by another 60 seconds
        }))
      }
    }, 30_000)

    try {
      await processLongRunningRecord(record)
    } finally {
      clearInterval(extensionInterval)
    }
  }
}

1.3 SQS Standard vs FIFO

Attribute Standard FIFO
Ordering Best-effort Strictly ordered within a Message Group ID
Delivery At-least-once Exactly-once (within deduplication window)
Deduplication None Content-based or explicit deduplication ID (5-minute window)
Throughput Unlimited 300 TPS (3,000 with batching) per queue
Price Lower Higher
TYPESCRIPT
// FIFO queue: orders within a user's account processed in strict order
const fifoQueue = new Queue(this, 'OrderFIFO', {
  fifo: true,
  contentBasedDeduplication: true, // Hash message body for deduplication ID
})

// Producer: assign Message Group ID per user → user's orders are serialized
await sqs.send(new SendMessageCommand({
  QueueUrl: fifoQueueUrl,
  MessageBody: JSON.stringify(orderPayload),
  MessageGroupId: `USER#${userId}`,    // All orders for this user are ordered
  // Different users (different GroupIds) process concurrently
}))
Crucial Requirement

FIFO Message Group IDs create independent parallel ordered streams. USER#u42 and USER#u99 process concurrently. Within USER#u42, messages are processed in strict send order. Set MessageGroupId to the finest granularity that requires ordering — not a global value (which serializes all traffic) and not a per-message value (which defeats ordering).


2. SNS — The Notification Fan-Out Bus

SNS is a push-based, fan-out notification service. When a message is published to an SNS topic, SNS attempts to deliver it to all subscribers simultaneously. The key word is "attempts" — SNS has a delivery retry policy, and after exhausting retries, it drops the message (unless an SNS DLQ is explicitly configured on the subscription).

2.1 Delivery Semantics

SNS delivery retry policy (default for Lambda subscriptions):
  - Immediate delivery attempt #1
  - 1s delay → attempt #2
  - 4s delay → attempt #3 (total: 5s from publish)
  - After 3 failures: message DROPPED

Contrast with SQS:
  - Message remains in queue until deleted or maxReceiveCount exceeded
  - DLQ automatically captures exhausted messages
  - No implicit message loss
TYPESCRIPT
// ❌ SNS → Lambda as a work queue (message loss risk)
// Work item published to SNS → Lambda fails 3 times → message dropped
// No DLQ on Lambda subscription by default → lost work, no visibility

// ✅ SNS → SQS → Lambda (durable fan-out)
// SNS delivers to SQS queue → SQS holds message until successful deletion
// Lambda ESM on SQS: DLQ captures messages after maxReceiveCount
// Zero message loss path

2.2 SNS → SQS Fan-Out Pattern

TYPESCRIPT
// CDK: SNS topic → multiple SQS queues with message filtering
import { Topic } from 'aws-cdk-lib/aws-sns'
import { SqsSubscription } from 'aws-cdk-lib/aws-sns-subscriptions'
import { SubscriptionFilter } from 'aws-cdk-lib/aws-sns'

const orderEventsTopic = new Topic(this, 'OrderEvents')

// Fulfillment queue: receives only CONFIRMED and PAID order events
const fulfillmentQueue = new Queue(this, 'FulfillmentQueue', {
  visibilityTimeout: Duration.seconds(300),
  deadLetterQueue: { queue: fulfillmentDlq, maxReceiveCount: 3 },
})

orderEventsTopic.addSubscription(new SqsSubscription(fulfillmentQueue, {
  filterPolicy: {
    eventType: SubscriptionFilter.stringFilter({
      allowlist: ['ORDER_CONFIRMED', 'ORDER_PAID']
    }),
    region: SubscriptionFilter.stringFilter({
      allowlist: ['us-east', 'us-west'] // Only US orders for this queue
    })
  }
}))

// Analytics queue: receives ALL order events for complete audit
const analyticsQueue = new Queue(this, 'AnalyticsQueue', {
  visibilityTimeout: Duration.seconds(60),
  deadLetterQueue: { queue: analyticsDlq, maxReceiveCount: 5 },
})

orderEventsTopic.addSubscription(new SqsSubscription(analyticsQueue))
// No filter — analytics receives everything

// Publisher
await sns.send(new PublishCommand({
  TopicArn: orderEventsTopic.topicArn,
  Message: JSON.stringify(orderPayload),
  MessageAttributes: {
    eventType: { DataType: 'String', StringValue: 'ORDER_CONFIRMED' },
    region: { DataType: 'String', StringValue: 'us-east' },
  }
}))
Pro Tip & Optimization

SNS message filtering policies are evaluated server-side before delivery — the fulfillment queue only receives messages where eventType matches ORDER_CONFIRMED or ORDER_PAID. Without filters, SNS pushes every message to every subscriber, and the subscriber's Lambda must filter in code — paying invocation cost for messages it immediately discards.

SNS delivery contract (push, retry then drop) vs SQS delivery contract (hold, visibility timeout, retry with DLQ) — failure behavior labeled per service
SNS delivery contract (push, retry then drop) vs SQS delivery contract (hold, visibility timeout, retry with DLQ) — failure behavior labeled per service

3. Long Polling — The Cost-Reduction Default

SQS supports two polling modes. Short polling is the historical default and is actively wasteful:

TYPESCRIPT
// ❌ Short polling (default WaitTimeSeconds=0)
// Lambda ESM or custom consumer polls → might return empty (no messages)
// Empty polls billed at the same rate as populated polls
// At 1 poll/second × 86,400s/day = 86,400 API calls/day from idle waiting
// Cost: $0.40/million × 86,400 ≈ $0.034/day per queue — multiplied across many queues

// ✅ Long polling (WaitTimeSeconds=20) — always use this
// SQS waits up to 20 seconds for a message before returning an empty response
// Reduces empty polls by ~20× on low-traffic queues
// Cost at 1/20s poll rate: 4,320 API calls/day — 95% cost reduction

Lambda ESM uses long polling internally by default — this benefit is automatic for Lambda-triggered queues. For custom consumers (ECS, EC2), explicitly set WaitTimeSeconds=20.


4. DLQ Strategy — What to Monitor and How to Recover

A DLQ is not a passive archive — it is an active operational signal. A non-empty DLQ means work has failed and will not self-heal.

TYPESCRIPT
// CloudWatch alarm: DLQ depth > 0 triggers PagerDuty/Slack
new Alarm(this, 'OrderDLQDepthAlarm', {
  metric: dlq.metricApproximateNumberOfMessagesVisible({
    period: Duration.minutes(1),
    statistic: 'Maximum',
  }),
  threshold: 0,
  evaluationPeriods: 1,
  comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
  alarmDescription: 'Orders are failing processing — DLQ has messages requiring investigation',
  actionsEnabled: true,
})

// DLQ redrive: after fixing the bug, replay DLQ messages to the source queue
// SQS console: Start message move task
// CLI:
await sqs.send(new StartMessageMoveTaskCommand({
  SourceArn: dlq.queueArn,
  DestinationArn: queue.queueArn,
  MaxNumberOfMessagesPerSecond: 100, // Throttle replay to avoid overloading downstream
}))
SNS→SQS fan-out topology: Publisher → SNS topic → two SQS subscriptions with server-side message filter policies → Lambda ESM on each queue → DLQ paths labeled for each consumer
SNS→SQS fan-out topology: Publisher → SNS topic → two SQS subscriptions with server-side message filter policies → Lambda ESM on each queue → DLQ paths label…

Summary

Concept Rule
SNS delivery guarantee Push with retry — drops after retry exhaustion unless DLQ explicitly configured
SQS delivery guarantee Holds until explicit delete — at-least-once with visibility timeout as the delivery contract
Visibility timeout Must be ≥ 6× Lambda timeout; extend dynamically with ChangeMessageVisibility for variable-duration work
SNS → SQS fan-out SNS delivers to SQS → SQS provides durability; best of both services
Message filtering Server-side evaluation — filtered messages never push to subscriber, eliminating unnecessary invocations
FIFO Message Group ID Independent ordered stream per group — different groups process concurrently

What's Next

In Part 10: EventBridge Architecture — Event Mesh, Pipes & Failure Routing, we move from queue-based messaging to event-mesh routing: how EventBridge's content-based rules replace traffic-cop Lambda functions, how EventBridge Pipes eliminate boilerplate source-to-target wiring, and why every service's DLQ semantics require separate alarms and separate redrive procedures.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#SQS#SNS#Fan-Out#DLQ#Messaging#Serverless#AWS
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.