| name | message-queue-patterns |
| description | Async message queue and event streaming patterns — AWS SQS/SNS, Kafka, RabbitMQ. Covers producer/consumer design, idempotency, dead-letter queues, fan-out, ordering guarantees, and backpressure. The reference for service-to-service async communication. |
Message Queue Patterns
Async service-to-service communication. Not the same as WebSockets (client push) or BullMQ (in-process jobs).
When to Activate
- Decoupling services that don't need synchronous responses
- Building event-driven architectures or CQRS pipelines
- Handling traffic spikes with backpressure (queue absorbs bursts)
- Coordinating cross-service workflows without direct coupling
- Choosing between SQS, Kafka, RabbitMQ, or BullMQ
- Implementing idempotent consumers to handle at-least-once delivery without corrupting state on duplicate messages
- Designing dead-letter queues and replay strategies to recover from poison-pill messages without blocking the main queue
Technology Decision Tree
Need guaranteed ordering within a partition?
├── YES → Kafka (or Kinesis)
└── NO
├── Need fan-out to multiple consumers?
│ ├── YES → SNS (fan-out) → SQS (per-consumer queue)
│ └── NO
│ ├── Simple work queue, AWS stack?
│ │ └── YES → SQS Standard
│ ├── Complex routing, exchange patterns?
│ │ └── YES → RabbitMQ
│ └── In-process jobs (same Node.js process)?
│ └── YES → BullMQ (see realtime-patterns)
| System | Ordering | Fan-out | Retention | Throughput | Best for |
|---|
| SQS Standard | No | No (use SNS) | 14 days | High | Work queues, decoupling |
| SQS FIFO | Per message group | No | 14 days | 3K/s | Ordered processing |
| SNS | No | Yes (up to 12.5M/s) | None (fire & forget) | Very High | Broadcast events |
| Kafka | Per partition | Yes (consumer groups) | Configurable (∞) | Very High | Event streaming, audit log |
| RabbitMQ | Per queue | Via exchanges | Until consumed | High | Complex routing, priority queues |
SQS Patterns (AWS)
Basic Producer — TypeScript
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({ region: process.env.AWS_REGION });
interface OrderCreatedEvent {
orderId: string;
userId: string;
amount: number;
}
async function publishOrderCreated(event: OrderCreatedEvent): Promise<void> {
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.ORDER_QUEUE_URL,
MessageBody: JSON.stringify({
type: 'order.created',
version: '1',
timestamp: new Date().toISOString(),
data: event,
}),
}));
}
Basic Consumer with Idempotency
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({ region: process.env.AWS_REGION });
async function processOrders(): Promise<void> {
while (true) {
const { Messages } = await sqs.send(new ReceiveMessageCommand({
QueueUrl: process.env.ORDER_QUEUE_URL,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
VisibilityTimeout: 30,
}));
if (!Messages?.length) continue;
await Promise.allSettled(
Messages.map(msg => processMessage(msg))
);
}
}
async (): <> {
body = .(msg.!);
alreadyProcessed = db.(
,
[msg.]
);
(alreadyProcessed.. > ) {
(msg.!);
;
}
db.( (trx) => {
(body., trx);
trx.(
,
[msg.]
);
});
(msg.!);
}
(): <> {
sqs.( ({
: process..,
: receiptHandle,
}));
}
Dead-Letter Queue (DLQ) Setup
async function replayDLQ(): Promise<void> {
const dlqUrl = process.env.ORDER_DLQ_URL!;
const sourceUrl = process.env.ORDER_QUEUE_URL!;
let count = 0;
while (true) {
const { Messages } = await sqs.send(new ReceiveMessageCommand({
QueueUrl: dlqUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 1,
}));
if (!Messages?.length) break;
for (const msg of Messages) {
await sqs.send(new SendMessageCommand({
QueueUrl: sourceUrl,
: msg.!,
}));
(msg.!);
count++;
}
}
.();
}
SNS Fan-out Pattern
┌──────────────┐
│ SNS │ order.created topic
└──────┬───────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
SQS Queue SQS Queue SQS Queue
(billing) (inventory) (analytics)
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
const sns = new SNSClient({ region: process.env.AWS_REGION });
async function publishEvent(topicArn: string, event: unknown): Promise<void> {
await sns.send(new PublishCommand({
TopicArn: topicArn,
Message: JSON.stringify(event),
MessageAttributes: {
eventType: {
DataType: 'String',
StringValue: 'order.created',
},
},
}));
}
Terraform (SNS → SQS subscription):
resource "aws_sns_topic_subscription" "billing_queue" {
topic_arn = aws_sns_topic.orders.arn
protocol = "sqs"
endpoint = aws_sqs_queue.billing.arn
raw_message_delivery = true # skip SNS envelope wrapping
}
Kafka Patterns
Producer — Node.js (kafkajs)
import { Kafka, Partitioners } from 'kafkajs';
const kafka = new Kafka({
clientId: 'order-service',
brokers: process.env.KAFKA_BROKERS!.split(','),
ssl: true,
sasl: {
mechanism: 'plain',
username: process.env.KAFKA_USERNAME!,
password: process.env.KAFKA_PASSWORD!,
},
});
const producer = kafka.producer({
createPartitioner: Partitioners.DefaultPartitioner,
});
await producer.connect();
await producer.send({
topic: 'orders',
messages: [{
key: event.userId,
value: JSON.stringify(event),
headers: {
eventType: 'order.created',
schemaVersion: '1',
},
}],
});
Consumer Group
const consumer = kafka.consumer({ groupId: 'billing-service' });
await consumer.connect();
await consumer.subscribe({ topic: 'orders', fromBeginning: false });
await consumer.run({
eachMessage: async ({ topic, partition, message, heartbeat }) => {
const event = JSON.parse(message.value!.toString());
try {
await processBillingEvent(event);
} catch (err) {
if (isPoisonPill(err)) {
await sendToDeadLetterTopic(topic, message);
return;
}
throw err;
}
await heartbeat();
},
});
Kafka Topic Design Rules
✅ One topic per event type: orders.created, orders.cancelled, payments.completed
✅ Partition by entity ID: key = orderId or userId (keeps related events ordered)
✅ Retention policy set: production: 7 days min, audit: 90 days
✅ Replication factor: 3 in production (tolerates 2 broker failures)
❌ One giant topic for all events — loses ordering semantics
❌ Partition by timestamp — hot partition, no ordering benefit
❌ No key (random partition) — events for same entity arrive out of order
RabbitMQ Patterns
Direct Exchange (point-to-point)
import amqplib from 'amqplib';
const conn = await amqplib.connect(process.env.RABBITMQ_URL!);
const ch = await conn.createChannel();
await ch.assertExchange('orders', 'direct', { durable: true });
await ch.assertQueue('billing-queue', { durable: true });
await ch.bindQueue('billing-queue', 'orders', 'order.created');
await ch.publish(
'orders',
'order.created',
Buffer.from(JSON.stringify(event)),
{ persistent: true, contentType: 'application/json' }
);
await ch.prefetch(10);
await ch.consume('billing-queue', async (msg) => {
if (!msg) ;
{
(.(msg..()));
ch.(msg);
} {
ch.(msg, , );
}
});
Topic Exchange (pattern routing)
await ch.assertExchange('events', 'topic', { durable: true });
await ch.bindQueue('order-queue', 'events', 'order.#');
await ch.bindQueue('payment-queue', 'events', '*.completed');
Universal Patterns
Envelope Schema
All messages should follow the same envelope regardless of broker:
interface MessageEnvelope<T> {
id: string;
type: string;
version: '1';
timestamp: string;
source: string;
correlationId?: string;
data: T;
}
Idempotency Checklist
Every consumer MUST be idempotent. At-least-once delivery means duplicates will arrive:
✅ Check processed_messages table before side effects
✅ Use upsert (INSERT ... ON CONFLICT DO NOTHING) instead of INSERT
✅ Mark processed atomically with the side effect (same DB transaction)
✅ Use message.id (not content hash) as idempotency key
✅ Design handlers to be safe to call twice with same input
❌ Fire external API calls without idempotency key header
❌ Increment counters directly (use SET instead of +1)
Backpressure
const CONCURRENCY = 10;
const semaphore = new Semaphore(CONCURRENCY);
await Promise.all(messages.map(async (msg) => {
await semaphore.acquire();
try {
await processMessage(msg);
} finally {
semaphore.release();
}
}));
Poison Pill / Dead Letter
Messages that consistently fail should never block the queue. Always configure:
| Broker | Mechanism | Config |
|---|
| SQS | DLQ | maxReceiveCount on source queue |
| Kafka | Dead Letter Topic | Manual: send to {topic}.DLT on N failures |
| RabbitMQ | Dead Letter Exchange | x-dead-letter-exchange queue argument |
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|
| No idempotency | Duplicate messages corrupt state | processed_messages table + upserts |
| Deleting message before processing | Message lost on crash | Delete only after successful processing |
| No DLQ/DLT | Poison pills block queue forever | Always configure DLQ with alert |
| Synchronous calls inside consumer | Cascading failures, timeouts | Queue the side effect, don't call synchronously |
| Huge message bodies | Memory pressure, slow serialization | Store payload in S3/DB, pass reference URL |
| One partition for all events | No parallelism | Partition by entity ID |
| Sharing consumer group across env | Dev consumes prod messages | Always namespace by environment |
| Missing schema versioning | Breaking changes break consumers | Always include version in envelope |