SQS to Lambda (event source mapping)
Wire an SQS queue to a Lambda function so messages are neither lost nor endlessly retried — visibility timeout at least six times the function timeout, partial batch responses (ReportBatchItemFailures) in TypeScript and the CDK, the DLQ on the queue via maxReceiveCount, batch size and window, scaling and maximum concurrency versus reserved concurrency, FIFO group ordering, provisioned pollers, KMS, and the metrics that show it's broken.
markdownpatternv4G74H-CXJHstage: buildtrust: officialservice: sqsservice: lambdainterface: iacinterface: sdkSQS Lambda triggerevent source mapping SQSvisibility timeout six times function timeoutfunction timeout exceeds visibility timeout errorReportBatchItemFailuresbatchItemFailures itemIdentifierpartial batch responseSQS DLQ LambdamaxReceiveCountMaximumBatchingWindowInSecondsbatch size 10000MaximumConcurrencyreserved concurrency SQS throttlingFIFO MessageGroupId Lambdaprovisioned mode pollersAWSLambdaSQSQueueExecutionRolekms:Decrypt SQS LambdaSqsEventSource CDKApproximateAgeOfOldestMessagemessages processed twice
SQS to Lambda
Turn on ReportBatchItemFailures and return the failed message IDs, set the queue's visibility timeout to at least six times the function timeout, and put the DLQ on the queue (redrive policy), not on the function. Without partial batch responses, one bad message makes the whole batch reappear, successes included.
How the handoff works
- Lambda's pollers long-poll the queue and invoke your function synchronously with a batch (default up to 10 messages).
- The messages in that batch become invisible for the queue's visibility timeout.
- If the function succeeds, Lambda deletes them. If it fails, the batch becomes visible again after the timeout. A message received more than
maxReceiveCounttimes moves to the queue's DLQ. - Delivery is at least once, so handlers must be idempotent.
The queue and function must be in the same Region; they can be in different accounts.
Settings that matter
| Setting | Rule |
|---|---|
| Queue visibility timeout | ≥ 6 × function timeout (+ MaximumBatchingWindowInSeconds if you use a window). Lambda refuses to create or update the mapping if the function timeout exceeds the visibility timeout |
| Batch size | Standard: up to 10,000; FIFO: up to 10. Over 10 requires MaximumBatchingWindowInSeconds ≥ 1. The batch, with metadata, must fit the 6 MB synchronous payload, so batches can arrive smaller |
| Batch window | Standard queues only, up to 5 minutes. With very low traffic Lambda may still wait up to 20 s |
FunctionResponseTypes | ["ReportBatchItemFailures"] |
| Redrive policy (on the queue) | {"deadLetterTargetArn": "<dlq-arn>", "maxReceiveCount": 5}. The default count is 10. Too low (1) sends messages to the DLQ after one throttled or failed attempt |
| DLQ retention | Longer than the source queue's. On standard queues the original enqueue time carries over |
| Execution role | AWSLambdaSQSQueueExecutionRole, plus kms:Decrypt on the queue's key if it's encrypted |
Handler with partial batch response
import type { SQSEvent, SQSBatchResponse, SQSBatchItemFailure } from "aws-lambda";
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const batchItemFailures: SQSBatchItemFailure[] = [];
const isFifo = event.Records[0]?.eventSourceARN.endsWith(".fifo") ?? false;
for (const [i, record] of event.Records.entries()) {
try {
const order = JSON.parse(record.body) as { orderId: string };
await processOnce(order.orderId, record.messageId); // idempotent: key on messageId or a business key
} catch (err) {
console.error(JSON.stringify({ messageId: record.messageId, error: String(err) }));
if (isFifo) {
// FIFO: stop at the first failure and return it plus everything after it, to keep group order.
for (const r of event.Records.slice(i)) batchItemFailures.push({ itemIdentifier: r.messageId });
break;
}
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures }; // empty list = whole batch succeeded
};
async function processOnce(orderId: string, messageId: string): Promise<void> {
void orderId; void messageId; // e.g. a DynamoDB conditional put on messageId, then the side effect
}
Lambda treats the whole batch as failed if the handler throws, returns invalid JSON, or returns an itemIdentifier that is empty, null, under the wrong key, or not a message ID from the batch. Don't throw after collecting failures.
The same wiring in the CDK
import { Duration, Stack, type StackProps } from "aws-cdk-lib";
import type { Construct } from "constructs";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
export class OrdersQueueStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const fnTimeout = Duration.seconds(30);
const dlq = new sqs.Queue(this, "OrdersDlq", { retentionPeriod: Duration.days(14) });
const queue = new sqs.Queue(this, "Orders", {
visibilityTimeout: Duration.seconds(fnTimeout.toSeconds() * 6),
retentionPeriod: Duration.days(4),
deadLetterQueue: { queue: dlq, maxReceiveCount: 5 },
encryption: sqs.QueueEncryption.SQS_MANAGED,
});
const fn = new lambda.Function(this, "Worker", {
runtime: lambda.Runtime.NODEJS_24_X,
handler: "index.handler",
code: lambda.Code.fromAsset("dist/worker"),
timeout: fnTimeout,
});
fn.addEventSource(new SqsEventSource(queue, {
batchSize: 10,
reportBatchItemFailures: true,
maxConcurrency: 20, // ESM-level cap (2–1,000); keep reserved concurrency ≥ the sum of these
}));
}
}
Synthesizing this produced an AWS::Lambda::EventSourceMapping with FunctionResponseTypes: ["ReportBatchItemFailures"] and ScalingConfig.MaximumConcurrency: 20, and a queue with a RedrivePolicy and a 180-second visibility timeout. The function's role got sqs:ReceiveMessage, ChangeMessageVisibility, GetQueueUrl, DeleteMessage and GetQueueAttributes on the queue.
Scaling and throttling
- Standard queues: Lambda starts with 5 concurrent invokes and adds up to 300 per minute while messages remain, up to 1,250 per mapping.
MaximumConcurrency(2–1,000) caps one mapping, at no charge. Reserved concurrency caps the function. If reserved concurrency is lower than the sum of the mappings' maximums, "Lambda might throttle your messages".- On throttling, Lambda reduces concurrency and keeps retrying a message until its visibility timeout passes, then drops it from that attempt. The message becomes visible again, and each attempt counts toward
maxReceiveCount(the hub's reading). That's why a lowmaxReceiveCountplus throttling fills the DLQ with messages that were never processed. - On function errors, Lambda also scales the mapping down. With
ReportBatchItemFailureson, it doesn't scale polling down for failed invocations. - FIFO: order is kept within each
MessageGroupId. Concurrency ≤ min(number of active groups,MaximumConcurrency). One stuck message blocks its group until it's deleted or reaches the DLQ. - Provisioned mode gives dedicated pollers (
--provisioned-poller-config '{"MinimumPollers":5,"MaximumPollers":100}'), each handling about 1 MB/s or 10 concurrent invokes. It can't be combined withMaximumConcurrency. AWS pages disagree on the maximum poller count (2,000 vs 10,000), so check the API.
Known-good
- The queue's visibility timeout is 6 × the function timeout (180 s for a 30-s function).
- A redrive policy with
maxReceiveCount5 points at a DLQ with 14-day retention. - The mapping has
FunctionResponseTypes: ["ReportBatchItemFailures"]and aMaximumConcurrencyat or below the function's reserved concurrency. - The handler is idempotent and returns
batchItemFailureswithout throwing.
The CDK stack above is this configuration.
Known-bad
| Configuration | Symptom |
|---|---|
| Function timeout 60 s, queue visibility 30 s | The mapping can't be created or updated: Lambda rejects a function timeout above the visibility timeout |
No ReportBatchItemFailures; handler throws on one bad message | The whole batch reappears. Successful messages are processed again, and good messages reach the DLQ alongside the bad one |
ReportBatchItemFailures on, but the handler throws or returns an ID that isn't in the batch | Treated as a complete batch failure: same symptom as above |
maxReceiveCount 1 with throttling (reserved concurrency below the mapping's concurrency) | The DLQ fills with messages your code never saw. The function's Throttles metric is non-zero |
| DLQ retention equal to the source's (standard queue) | Messages vanish from the DLQ soon after arriving, because the original enqueue time carries over |
Queue on SSE-KMS, role without kms:Decrypt | No invocations. The mapping can't read messages (reference/sqs/errors.md, KmsAccessDenied) |
Which side is wrong?
| Signal | Side |
|---|---|
ApproximateAgeOfOldestMessage climbing while the function's Invocations is flat | Mapping or permission side: the mapping is disabled, the role lacks SQS or KMS rights, or the mapping is throttled. Check aws lambda get-event-source-mapping --uuid … → State, StateTransitionReason, LastProcessingResult |
Invocations steady, Errors high, NumberOfMessagesDeleted near 0 | Function side: the code fails, or the batch response is malformed. Read logs (reference/cloudwatch/logs-insights-queries.md) |
Throttles non-zero, DLQ growing | Concurrency settings: reserved concurrency is below the mapping's maximum |
| Same message processed twice, no errors | Visibility side: processing outlives the timeout. Raise it, and keep the handler idempotent |
SQS API errors: reference/sqs/errors.md. Producer side: guides/sqs/send-and-receive.md. A complete project with this wiring plus alarms: examples/sqs-lambda-worker-cdk.md.