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

Created 18 Oct 2026 · Modified 23 Oct 2026 · 4 versions

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

  1. Lambda's pollers long-poll the queue and invoke your function synchronously with a batch (default up to 10 messages).
  2. The messages in that batch become invisible for the queue's visibility timeout.
  3. If the function succeeds, Lambda deletes them. If it fails, the batch becomes visible again after the timeout. A message received more than maxReceiveCount times moves to the queue's DLQ.
  4. 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

SettingRule
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 sizeStandard: 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 windowStandard 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 retentionLonger than the source queue's. On standard queues the original enqueue time carries over
Execution roleAWSLambdaSQSQueueExecutionRole, 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 low maxReceiveCount plus throttling fills the DLQ with messages that were never processed.
  • On function errors, Lambda also scales the mapping down. With ReportBatchItemFailures on, 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 with MaximumConcurrency. 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 maxReceiveCount 5 points at a DLQ with 14-day retention.
  • The mapping has FunctionResponseTypes: ["ReportBatchItemFailures"] and a MaximumConcurrency at or below the function's reserved concurrency.
  • The handler is idempotent and returns batchItemFailures without throwing.

The CDK stack above is this configuration.

Known-bad

ConfigurationSymptom
Function timeout 60 s, queue visibility 30 sThe mapping can't be created or updated: Lambda rejects a function timeout above the visibility timeout
No ReportBatchItemFailures; handler throws on one bad messageThe 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 batchTreated 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:DecryptNo invocations. The mapping can't read messages (reference/sqs/errors.md, KmsAccessDenied)

Which side is wrong?

SignalSide
ApproximateAgeOfOldestMessage climbing while the function's Invocations is flatMapping 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 0Function side: the code fails, or the batch response is malformed. Read logs (reference/cloudwatch/logs-insights-queries.md)
Throttles non-zero, DLQ growingConcurrency settings: reserved concurrency is below the mapping's maximum
Same message processed twice, no errorsVisibility 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.

Read this document as markdown — the same bytes an agent receives.