Skip to content
~/sph.sh

AWS Messaging Services: SQS vs SNS vs EventBridge - A Decision Framework

Stop choosing based on features; choose based on your communication pattern. A practical guide to selecting between SQS, SNS, and EventBridge with working CDK examples and cost analysis.

Working with AWS messaging services, I've learned that the choice between SQS, SNS, and EventBridge isn't about which service has better features; it's about matching your communication pattern to the right service model. Each time I see teams choosing based on feature lists rather than architectural needs, the results follow a familiar pattern: eventual refactoring when the mismatch becomes clear.

Understanding the Communication Models

AWS provides three fundamentally different approaches to messaging, each designed for specific patterns:

SQS (Pull Model): Consumers actively poll for messages. The queue provides backpressure control; consumers process at their own pace. A technical detail that matters: polling empty queues still incurs charges. With 5 Lambda pollers at 3 requests per minute each, you get 648,000 polling requests per month even with zero messages.

SNS (Push Model): Publishers send, subscribers receive immediately. Real-time delivery to multiple endpoints works well, but there's a trade-off: no message persistence. If a subscriber is down, the message is lost unless you combine SNS with SQS.

EventBridge (Event-Driven Router): Events are matched against rules and routed to targets. This combines filtering, transformation, and multi-target delivery. No ordering guarantees exist, but archive and replay capabilities provide debugging options that SQS and SNS don't offer.

The Decision Framework

Rather than comparing feature matrices, here's how I approach the decision:

This decision tree reflects patterns I've seen work across different architectures. The key is identifying your primary requirement first, then working through the secondary considerations.

Working Examples

SQS: Point-to-Point with Backpressure Control

When you need reliable message processing with controlled throughput, SQS provides straightforward implementation:

typescript
import * as sqs from 'aws-cdk-lib/aws-sqs';import * as lambda from 'aws-cdk-lib/aws-lambda';import { Duration } from 'aws-cdk-lib';
// Dead letter queue for failed processingconst dlq = new sqs.Queue(this, 'ProcessingDLQ', {  retentionPeriod: Duration.days(14),});
const processingQueue = new sqs.Queue(this, 'ProcessingQueue', {  visibilityTimeout: Duration.seconds(300),  receiveMessageWaitTime: Duration.seconds(20), // Long polling  deadLetterQueue: {    maxReceiveCount: 3,    queue: dlq,  },});
// Lambda consumer with controlled concurrencyconst processor = new lambda.Function(this, 'QueueProcessor', {  runtime: lambda.Runtime.NODEJS_20_X,  handler: 'index.handler',  code: lambda.Code.fromAsset('lambda'),  timeout: Duration.seconds(240), // Less than visibility timeout  reservedConcurrentExecutions: 10, // Control throughput});
processingQueue.grantConsumeMessages(processor);new lambda.EventSourceMapping(this, 'QueueTrigger', {  target: processor,  eventSourceArn: processingQueue.queueArn,  batchSize: 10,  maxBatchingWindow: Duration.seconds(5),});

The critical detail here is the relationship between visibility timeout (300s) and Lambda timeout (240s). Messages must remain hidden longer than processing time to prevent duplicates. Long polling reduces empty poll costs from 180 requests per hour to about 9.

SNS + SQS: Fan-out with Message Filtering

For broadcasting events to multiple services, SNS topic-queue chaining provides durable fan-out:

typescript
import * as sns from 'aws-cdk-lib/aws-sns';import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
const orderTopic = new sns.Topic(this, 'OrderTopic', {  displayName: 'Order Events',});
const inventoryQueue = new sqs.Queue(this, 'InventoryQueue');const shippingQueue = new sqs.Queue(this, 'ShippingQueue');const analyticsQueue = new sqs.Queue(this, 'AnalyticsQueue');
// Subscribe with filtering at the SNS levelorderTopic.addSubscription(  new subscriptions.SqsSubscription(inventoryQueue, {    rawMessageDelivery: true,    filterPolicy: {      orderType: sns.SubscriptionFilter.stringFilter({        allowlist: ['STANDARD', 'BULK'],      }),    },  }));
orderTopic.addSubscription(  new subscriptions.SqsSubscription(shippingQueue, {    rawMessageDelivery: true,    filterPolicy: {      priority: sns.SubscriptionFilter.stringFilter({        allowlist: ['HIGH', 'URGENT'],      }),    },  }));
// Analytics receives everythingorderTopic.addSubscription(  new subscriptions.SqsSubscription(analyticsQueue, {    rawMessageDelivery: true,  }));

The rawMessageDelivery setting matters more than it might appear. Without it, SNS wraps your message in an envelope, requiring JSON.parse(JSON.parse(message)) in your consumer code. With it enabled, SQS receives the message content directly.

EventBridge: Content-Based Routing

When routing logic becomes complex, EventBridge provides capabilities SNS filtering can't match:

typescript
import * as events from 'aws-cdk-lib/aws-events';import * as targets from 'aws-cdk-lib/aws-events-targets';
const eventBus = new events.EventBus(this, 'OrderEventBus', {  eventBusName: 'order-events',});
// Archive for debugging and replaynew events.Archive(this, 'OrderArchive', {  sourceEventBus: eventBus,  eventPattern: {    source: ['orders.service'],  },  retention: Duration.days(30),});
// Rule for high-value orders with multiple targetsnew events.Rule(this, 'HighValueOrderRule', {  eventBus: eventBus,  eventPattern: {    source: ['orders.service'],    detailType: ['OrderCreated'],    detail: {      totalAmount: [{ numeric: ['>', 1000] }],      region: ['US', 'EU'],    },  },  targets: [    new targets.LambdaFunction(fraudCheckFunction),    new targets.SqsQueue(priorityQueue),    new targets.SnsTopic(alertTopic),  ],});
// Rule for inventory updates using nested field matchingnew events.Rule(this, 'InventoryUpdateRule', {  eventBus: eventBus,  eventPattern: {    source: ['orders.service'],    detailType: ['OrderCreated', 'OrderCancelled'],    detail: {      items: {        sku: [{ exists: true }],      },    },  },  targets: [new targets.SqsQueue(inventoryQueue)],});

EventBridge filtering operates on the full event payload, including nested fields. SNS message filtering only works with message attributes. This difference becomes significant when event structure evolves.

FIFO Ordering Patterns

Ordering guarantees require careful service selection. Standard SQS and SNS provide no ordering. EventBridge provides no ordering. For ordered processing, you need FIFO variants:

typescript
// FIFO topic for ordered eventsconst orderEventsTopic = new sns.Topic(this, 'OrderEventsFIFO', {  fifo: true,  contentBasedDeduplication: true,  topicName: 'order-events.fifo',});
// FIFO queue with per-message-group deduplicationconst processingQueue = new sqs.Queue(this, 'ProcessingQueueFIFO', {  fifo: true,  contentBasedDeduplication: true,  queueName: 'order-processing.fifo',  visibilityTimeout: Duration.seconds(300),  deduplicationScope: sqs.DeduplicationScope.MESSAGE_GROUP,  fifoThroughputLimit: sqs.FifoThroughputLimit.PER_MESSAGE_GROUP_ID,});
orderEventsTopic.addSubscription(  new subscriptions.SqsSubscription(processingQueue, {    rawMessageDelivery: true,  }));

The throughput characteristics matter: standard FIFO provides 300 transactions per second, high throughput FIFO provides 3,000 TPS (with batching of 10 messages per request). With message group partitioning (MessageGroupId per customer or entity), you can achieve 30,000+ TPS by creating independent FIFO sequences.

Hybrid Pattern: EventBridge + SQS Buffering

Some of the most effective architectures combine services. EventBridge handles routing, SQS provides buffering:

typescript
const eventBus = new events.EventBus(this, 'CentralBus');
// Service-specific queues with DLQsconst createServiceQueue = (serviceName: string) => {  const dlq = new sqs.Queue(this, `${serviceName}DLQ`, {    retentionPeriod: Duration.days(14),  });
  return new sqs.Queue(this, `${serviceName}Queue`, {    visibilityTimeout: Duration.seconds(300),    receiveMessageWaitTime: Duration.seconds(20),    deadLetterQueue: {      maxReceiveCount: 3,      queue: dlq,    },  });};
const inventoryQueue = createServiceQueue('Inventory');const notificationQueue = createServiceQueue('Notification');
// EventBridge filters, SQS buffersnew events.Rule(this, 'InventoryRule', {  eventBus: eventBus,  eventPattern: {    source: ['orders.service', 'returns.service'],    detail: { requiresInventoryUpdate: [true] },  },  targets: [new targets.SqsQueue(inventoryQueue)],});
// High-priority notifications bypass queuenew events.Rule(this, 'UrgentNotificationRule', {  eventBus: eventBus,  eventPattern: {    source: ['orders.service'],    detail: {      priority: ['URGENT'],      notificationType: ['SMS', 'PUSH'],    },  },  targets: [new targets.LambdaFunction(urgentNotificationHandler)],});

This pattern provides the best of both: EventBridge's advanced filtering reduces unnecessary SQS writes, while SQS provides backpressure protection and rate limiting through Lambda reserved concurrency.

Cost Analysis

The cost differences between patterns are significant. For 10 million events per month with fan-out to 5 services:

SNS + SQS Pattern:

  • SNS publishes: 10M × 0.50/M=0.50/M = 5.00
  • SNS → SQS deliveries: FREE (same region)
  • SQS writes: 50M × 0.40/M=0.40/M = 20.00
  • SQS reads (batched): 5M × 0.40/M=0.40/M = 2.00
  • Total: $27.00/month

EventBridge + SQS Pattern:

  • EventBridge ingestion: 10M × 1.00/M=1.00/M = 10.00
  • SQS writes: 50M × 0.40/M=0.40/M = 20.00
  • SQS reads: 5M × 0.40/M=0.40/M = 2.00
  • Total: $32.00/month (+19%)

EventBridge + Direct Lambda:

  • EventBridge ingestion: 10M × 1.00/M=1.00/M = 10.00
  • Lambda invocations: 50M × 0.20/M=0.20/M = 10.00
  • Lambda compute (128MB, 100ms): ~$10.42
  • Total: $30.42/month (+13%)

EventBridge+SQS costs 19% more than SNS+SQS, while EventBridge with direct Lambda invocation costs 13% more; both buy you advanced filtering, schema registry, archive/replay, and cross-account routing. Whether that's worth the cost depends on whether you use those features.

Common Implementation Issues

Working with these services, several issues appear repeatedly:

Visibility Timeout Too Short: When Lambda timeout is 60 seconds but visibility timeout is 30 seconds, messages reappear while still processing. The rule: visibilityTimeout >= lambdaTimeout + buffer. In practice, Lambda timeout of 240 seconds should use visibility timeout of 300 seconds.

Missing rawMessageDelivery: SNS wraps messages in an envelope. Without rawMessageDelivery: true, your code handles JSON-stringified JSON. Consumer logic becomes fragile.

No DLQ Monitoring: Dead letter queues without CloudWatch alarms mean silent failures. Messages pile up unnoticed.

typescript
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
const dlqAlarm = new cloudwatch.Alarm(this, 'DLQAlarm', {  metric: dlq.metricApproximateNumberOfMessagesVisible(),  threshold: 1,  evaluationPeriods: 1,  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,});
dlqAlarm.addAlarmAction(new cloudwatchActions.SnsAction(alertTopic));

EventBridge Event Size Chunking: Events are billed in 64KB chunks. A 256KB event costs 4× as much as a 64KB event. For large payloads, the claim check pattern stores data in S3 and passes a reference:

typescript
// Store payload in S3const s3Key = await s3.putObject({  Bucket: 'event-payloads',  Key: `events/${eventId}.json`,  Body: JSON.stringify(largePayload),});
// Send small reference via EventBridgeawait eventBridge.putEvents({  Entries: [{    Source: 'orders.service',    DetailType: 'OrderCreated',    Detail: JSON.stringify({      eventId,      s3Bucket: 'event-payloads',      s3Key: `events/${eventId}.json`,    }),  }],});

EventBridge Pipes: Reducing Lambda Glue Code

EventBridge Pipes replaces Lambda functions that only transform and forward messages. For DynamoDB Streams to EventBridge integration:

typescript
import * as pipes from 'aws-cdk-lib/aws-pipes';import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
const ordersTable = new dynamodb.Table(this, 'OrdersTable', {  partitionKey: { name: 'orderId', type: dynamodb.AttributeType.STRING },  stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,});
const pipe = new pipes.CfnPipe(this, 'OrderPipe', {  source: ordersTable.tableStreamArn!,  target: eventBus.eventBusArn,  roleArn: pipeRole.roleArn,
  // Filter at pipe level  sourceParameters: {    dynamoDbStreamParameters: {      startingPosition: 'LATEST',      batchSize: 10,    },    filterCriteria: {      filters: [        {          pattern: JSON.stringify({            eventName: ['INSERT', 'MODIFY'],            dynamodb: {              NewImage: {                status: { S: ['PENDING', 'PAID'] },              },            },          }),        },      ],    },  },
  // Transform inline  targetParameters: {    eventBridgeEventBusParameters: {      detailType: 'OrderChange',      source: 'orders.dynamodb',    },  },});

This eliminates a Lambda function, reducing costs by up to 98% for simple filtering and transformation scenarios. The pipe costs $0.40 per million requests versus Lambda invocation plus compute costs.

Migration Strategies

When moving between services, dual publishing provides zero-downtime migration:

typescript
// Phase 1: Publish to both SNS and EventBridgeasync function publishEvent(event: OrderEvent) {  // Existing SNS flow  await sns.publish({    TopicArn: existingTopic.topicArn,    Message: JSON.stringify(event),  });
  // New EventBridge flow  await eventBridge.putEvents({    Entries: [{      Source: 'orders.service',      DetailType: 'OrderCreated',      Detail: JSON.stringify(event),      EventBusName: eventBus.eventBusName,    }],  });}

Then create parallel consumers, compare results, gradually shift traffic using feature flags, and eventually remove the old path. This approach works but temporarily doubles messaging costs; plan the migration timeline accordingly.

When to Choose Each Service

Use SQS when:

  • Single consumer needs backpressure control
  • Processing rate must be limited
  • Batch processing improves efficiency
  • You need exactly-once processing with FIFO
  • Budget is tight and traffic is high

Use SNS when:

  • Multiple subscribers need immediate notification
  • Mobile push, SMS, or email delivery required
  • Simple message filtering by attributes is sufficient
  • Real-time delivery matters more than durability
  • Fan-out pattern with durable delivery (SNS+SQS)

Use EventBridge when:

  • Content-based routing on nested event fields
  • Cross-account or cross-region event delivery
  • Third-party SaaS integration required
  • Event archive and replay capabilities needed
  • Schema registry for event contracts matters

Use hybrid patterns when:

  • EventBridge routing + SQS buffering for complex filtered fan-out
  • SNS fan-out + SQS durability for standard broadcast patterns
  • EventBridge Pipes for stream processing without Lambda

Key Takeaways

The choice between SQS, SNS, and EventBridge starts with your communication pattern, not feature comparisons. Pull models (SQS) provide backpressure control. Push models (SNS) provide real-time fan-out. Event-driven routing (EventBridge) provides advanced filtering and integration.

FIFO ordering requires FIFO variants of SQS and SNS. EventBridge provides no ordering guarantees. For ordered processing, SNS FIFO + SQS FIFO provides end-to-end ordering with message group partitioning for higher throughput.

Cost differences are meaningful: SNS+SQS costs 19% less than EventBridge+SQS for simple fan-out, but EventBridge provides capabilities that SNS doesn't. Direct Lambda invocation from EventBridge costs about the same as SNS+SQS when using SQS buffering, but eliminates the queue management overhead.

Dead letter queues are mandatory. Monitoring them with CloudWatch alarms is critical. Visibility timeout must exceed Lambda timeout. Long polling reduces costs for low-traffic queues. Message filtering at SNS or EventBridge level reduces unnecessary downstream processing.

Hybrid patterns often outperform single-service approaches. EventBridge routing combined with SQS buffering provides both advanced filtering and backpressure control. The architecture that works depends on your specific requirements, not industry best practices.

Related Posts