AWS SQS Consumers
Amazon SQS gives managed queues without operating Redis. Node workers use AWS SDK v3 with long polling, visibility timeouts, and dead-letter queues (DLQ).
Recipe
Quick-reference recipe card - copy-paste ready.
import {
SQSClient,
ReceiveMessageCommand,
DeleteMessageCommand,
} from "@aws-sdk/client-sqs";
const sqs = new SQSClient({});
const queueUrl = process.env.SQS_QUEUE_URL!;
const res = await sqs.send(
new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
VisibilityTimeout: 60,
})
);
for (const msg of res.Messages ?? []) {
await processBody(msg.Body!);
await sqs.send(
new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: msg.ReceiptHandle! })
);
}When to reach for this:
- AWS-native stack without Redis
- Need managed scaling and pay-per-message pricing
- Cross-account or cross-region fan-out with SNS
Working Example
// src/sqs/poll.ts
import {
SQSClient,
ReceiveMessageCommand,
DeleteMessageCommand,
ChangeMessageVisibilityCommand,
} from "@aws-sdk/client-sqs";
const sqs = new SQSClient({ region: process.env.AWS_REGION });
const queueUrl = process.env.SQS_QUEUE_URL!;
async function handleMessage(body: string) {
const payload = JSON.parse(body) as { type: string; orderId: string };
if (payload.type === "fulfill") {
await fulfillOrder(payload.orderId);
}
}
export async function pollForever(signal: AbortSignal) {
while (!signal.aborted) {
const res = await sqs.send(
new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
VisibilityTimeout: 120,
MessageAttributeNames: ["All"],
})
);
for (const msg of res.Messages ?? []) {
try {
await handleMessage(msg.Body ?? "{}");
await sqs.send(
new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: msg.ReceiptHandle!,
})
);
} catch (err) {
console.error({ msg: "sqs_handler_error", messageId: msg.MessageId, err });
// Message returns to queue after visibility timeout
}
}
}
}
// src/worker-main.ts
const ac = new AbortController();
process.on("SIGTERM", () => ac.abort());
await pollForever(ac.signal);What this demonstrates:
- Long poll
WaitTimeSeconds: 20reduces cost and CPU spin - Delete only after successful handler
- Failed handler relies on visibility timeout retry (plus DLQ at infra level)
Deep Dive
Visibility Timeout Math
visibility_timeout >= p99_processing_time * 1.5
- Too short: duplicate processing while first handler still runs
- Too long: slow retry on failure
- Extend with
ChangeMessageVisibilityfor variable-length jobs
Dead-Letter Queue (Infrastructure)
{
"RedrivePolicy": {
"deadLetterTargetArn": "arn:aws:sqs:...:dlq",
"maxReceiveCount": 5
}
}- After 5 failed receives, message moves to DLQ
- Runbook: inspect DLQ, fix bug, redrive messages
FIFO vs Standard
| Type | Ordering | Throughput | Dedup |
|---|---|---|---|
| Standard | Best-effort | Very high | App-level idempotency |
| FIFO | Per message group | 300 TPS/group | Optional content dedup |
- Payments and inventory often use FIFO with
MessageGroupId
SNS Fan-In
// Message body may be SNS wrapper JSON - unwrap Envelope
const outer = JSON.parse(body);
const inner = outer.Message ? JSON.parse(outer.Message) : outer;SDK v3 Batch Partial Failure (Lambda)
- Lambda SQS event source mapping supports batch item failures
- ECS/EC2 long poll loop handles one message at a time or manual batch ack
Gotchas
- Delete before work completes - message loss. Fix: delete last.
- Visibility shorter than job - duplicate fulfillments. Fix: extend visibility or raise timeout.
- No DLQ - poison message infinite loop. Fix:
maxReceiveCount+ DLQ alarm. - Parsing SNS wrapper wrong - silent no-op handlers. Fix: unwrap
Messagefield. - Ignoring idempotency - at-least-once delivery duplicates work. Fix: idempotency keys in DB.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| BullMQ | Redis already running, rich job API | Want zero queue infra |
| Kinesis | Streaming analytics | Simple task queue |
| EventBridge | Event routing rules | Point-to-point worker queue only |
| Google Pub/Sub | GCP stack | AWS-only org |
FAQs
sqs-consumer npm package?
sqs-consumer wraps polling with events. Fine for ECS workers. Understand visibility semantics either way.
How many pollers?
Scale consumers on ApproximateNumberOfMessagesVisible metric. Avoid duplicate tight loops without long poll.
Exactly-once?
No. Standard queue is at-least-once. Design idempotent handlers.
Large payloads?
Use S3 extended client pattern - SQS carries S3 pointer when >256KB.
IAM for worker?
sqs:ReceiveMessage, DeleteMessage, ChangeMessageVisibility on queue ARN only.
Local dev?
LocalStack or ElasticMQ for integration tests. ElasticMQ docker for laptop.
Express API enqueue?
SendMessageCommand from API with small JSON body. Same idempotency rules as BullMQ producer.
Message retention?
Default 4 days. Raise to 14 for replay windows during outages.
FIFO throughput?
Batching and multiple message group ids scale parallel ordered streams.
Cost drivers?
Requests count. Long polling reduces empty receives. Batch receive up to 10 messages.
Related
- Queues Basics - async mental model
- AWS SDK v3 - client setup
- Idempotency Keys - duplicate delivery
- BullMQ - Redis alternative
- Queues Best Practices - DLQ runbooks
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.