---
cuppy_hub: aws
cuppy_path: patterns/sqs-to-lambda.md
cuppy_section: docs
cuppy_doc: G74H-CXJH
cuppy_doc_uuid: d65856fc-8e62-4449-991d-02722014dd51
cuppy_permalink: https://cuppy.io/aws/d/G74H-CXJH
cuppy_version: 4
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/aws/docs/patterns/sqs-to-lambda
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: 5906ecc0d0b9b73853b9c24abb6f630e284f02f97aee199ce7d16208d44fbea2
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# 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

| 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

```ts
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

```ts
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

| 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`.


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 131 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/aws/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
