How Lambda polls Amazon SQS, how batch size and partial batch failure work, how visibility timeouts and dead-letter queues interact, the IAM you need, and how to get the same queue pattern from TypeScript without managing the event source mapping.
Amazon SQS and AWS Lambda are the default pairing on AWS for asynchronous work: put a message on a queue, let Lambda poll it, run a function per batch. The SDK call that sends the message is short, and the behaviour that matters lives in the event source mapping: batch size, visibility timeout, partial batch failure, dead-letter queues and the IAM role that lets Lambda read and delete messages.
This guide walks through that mapping end to end, then shows how Encore (infrastructure from code) gives TypeScript services the same SNS and SQS shape from declarations in code, with handlers that must still be idempotent.
An SQS event source mapping is a Lambda-managed poller. Lambda receives a batch, hides those messages for the queue's visibility timeout (30 seconds by default, up to 12 hours), and invokes your function synchronously with an SQSEvent. If the invocation succeeds, Lambda deletes the messages. If it fails and you have not enabled partial batch responses, every message in the batch becomes visible again when the timeout expires and is retried.
Set the visibility timeout above the slowest expected batch, or the same message can be processed by two concurrent invocations. For long handlers, extend visibility with ChangeMessageVisibility or raise the queue default. Standard and FIFO queues are both supported; FIFO records include MessageGroupId and MessageDeduplicationId.
By default Lambda takes up to 10 messages per batch. Raising the batch size and adding a batch window (up to 5 minutes) cuts invoke count for steady streams. Low-traffic queues can still wait up to about 20 seconds before an invoke even with a shorter window.
Without partial batch reporting, one bad message requeues the successes too. Enable ReportBatchItemFailures on the mapping and return only the failures:
// handler.ts
import type {
SQSEvent,
SQSBatchResponse,
SQSBatchItemFailure,
SQSRecord,
Context,
} from "aws-lambda";
export async function handler(
event: SQSEvent,
_context: Context,
): Promise<SQSBatchResponse> {
const batchItemFailures: SQSBatchItemFailure[] = [];
for (const record of event.Records) {
try {
await processMessage(record);
} catch (err) {
console.error("failed", record.messageId, err);
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
}
async function processMessage(record: SQSRecord): Promise<void> {
const body = JSON.parse(record.body) as { orderId: string };
// Make this safe to run twice for the same orderId.
console.log("processing", body.orderId);
}
Install @types/aws-lambda for the event types. On FIFO queues, stop at the first failure and return that message plus every later unprocessed one in the batch so order inside the message group is preserved. Throwing outside the per-record try/catch still fails the whole batch.
Retries are the visibility timeout loop: undeleted messages reappear and count toward ApproximateReceiveCount. Attach a dead-letter queue with a redrive policy and a maxReceiveCount (for example 5) so poison messages leave the main queue instead of burning concurrency. Alarm on DLQ depth in CloudWatch, then use SQS redrive after you fix the handler.
The function's execution role needs sqs:ReceiveMessage, sqs:DeleteMessage and sqs:GetQueueAttributes on the source queue, plus sqs:SendMessage on the DLQ if Lambda (or the redrive) writes there. Keep the role scoped to those queue ARNs. Producers need sqs:SendMessage on the source queue only.
Beyond the handler you maintain the queue and DLQ in every environment (Terraform, CDK or console), the event source mapping settings, the IAM role, the queue URL or ARN in config, and a local substitute such as LocalStack or a manual send into a shared queue. When one event must reach several consumers, an SNS topic fans out to one queue per consumer, each with its own mapping.
Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions and deploys into your own AWS or GCP account. A Pub/Sub topic and its subscriptions are declared in TypeScript. On AWS Encore provisions an SNS topic with an SQS queue and a dead-letter queue per subscription, as described in the AWS infrastructure docs, and runs each subscription handler for you. There is no event source mapping or polling loop in your code. Services run on containers such as Fargate, not on Lambda.

Run in your terminal to get started locally.
// orders/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("orders");
// orders/orders.ts
import { api } from "encore.dev/api";
import { Topic, Subscription } from "encore.dev/pubsub";
interface OrderPlaced {
orderId: string;
}
export const orderPlaced = new Topic<OrderPlaced>("order-placed", {
deliveryGuarantee: "at-least-once",
});
export const placeOrder = api(
{ expose: true, method: "POST", path: "/orders" },
async ({ orderId }: { orderId: string }): Promise<{ ok: boolean }> => {
await orderPlaced.publish({ orderId });
return { ok: true };
},
);
// Throwing retries per the subscription RetryPolicy, then the DLQ.
const _ = new Subscription(orderPlaced, "send-confirmation", {
handler: async (event) => {
// Idempotent: safe if the same orderId is delivered twice.
console.log("sending confirmation for", event.orderId);
},
retryPolicy: { maxRetries: 5, minBackoff: "1s" },
});
encore run uses an in-memory Pub/Sub, so a POST to /orders reaches the subscription locally without AWS credentials. The local development dashboard shows the publish and the handler. A second consumer is another new Subscription on the same topic. Delivery is still at least once, so handlers must be idempotent the same way as on a raw SQS Lambda. Encore provisions an IAM role for the service, so queue URLs and access keys do not sit in config. Deploying to your account is covered in deploying to your own cloud.
An AI coding agent adding a worker writes a subscription next to the API in the same change and tests it locally.
Best fit: TypeScript teams on AWS who want SQS-backed queues with dead-letter handling and local delivery, declared next to the API.
ApproximateAgeOfOldestMessage and DLQ depth; a silent poison message looks like a stuck queue.You create an event source mapping between the queue and the function. Lambda long-polls the queue, invokes the function synchronously with a batch of messages, and deletes the batch when the invocation succeeds. The function and the queue must be in the same AWS Region.
A function response type that lets you return only the message IDs that failed. Without it, one failure in a batch makes the whole batch visible again after the visibility timeout. With it, Lambda deletes the successes and retries only the failures.
Yes. Event source mappings deliver at least once, so the same message can arrive more than once. Record processed IDs in a database, or make the side effect safe to repeat, before acknowledging success.
With Encore, declare a Topic and a Subscription with a handler in TypeScript. Messages are delivered in memory during local development, and on AWS Encore provisions an SNS topic with an SQS queue and dead-letter queue per subscription and runs the handler for you.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.