Example project: SQS worker on Lambda with DLQ and alarms (CDK, TypeScript)

A complete CDK v2 TypeScript project that builds — an SQS queue with a DLQ, a Lambda worker with partial batch responses and JSON logs, a log group with retention, and error-rate and DLQ alarms to SNS — with its manifest, the commands to build, synth and deploy, and what the synthesized template contains.

markdownexamplev4SB9W-GKFYstage: buildtrust: officialservice: sqsservice: lambdaservice: cloudwatchservice: snsservice: cdkinterface: iacexample projectCDK TypeScript exampleSQS Lambda worker examplepartial batch response exampleDLQ alarmerror rate alarm CDKLambda JSON logging CDKlog group retention CDKcdk synth example

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

Example: SQS worker on Lambda with DLQ and alarms

Copy the files below into an empty folder, then run npm install and npm run synth. Run npm run deploy only after the account and Region are bootstrapped (guides/cdk/bootstrap-and-deploy.md). Every rule this project applies is explained in patterns/sqs-to-lambda.md and guides/cloudwatch/create-alarms.md.

Manifest

ItemValue
Builds withNode.js 24, TypeScript strict, aws-cdk-lib 2.273.0, CDK CLI 2.1145.0
Creates2 SQS queues (work + DLQ, SSE-SQS), 1 Lambda function (Node.js 24, JSON logs), 1 log group (30 days), an event source mapping, 2 alarms, 1 SNS topic (+ email subscription if ALARM_EMAIL is set)
InputsCDK_DEFAULT_ACCOUNT / CDK_DEFAULT_REGION (set by the CDK CLI from your profile), optional ALARM_EMAIL
Verifiedtsc --noEmit passes. The app was bundled and synthesized with aws-cdk-lib directly (equivalent to cdk synth; the npx tsx entry point in cdk.json wasn't run here), giving visibility timeout 180 s, retention 4 days / 14 days, maxReceiveCount 5, FunctionResponseTypes: ["ReportBatchItemFailures"], MaximumConcurrency 10, LogFormat: JSON, log retention 30 days, both alarms notBreaching → SNS
Not verifiedAn actual deployment (no AWS account in the build environment)
Cost shapeSQS requests, Lambda requests + GB-seconds, CloudWatch alarms and logs, SNS deliveries. No prices typed here

Files

package.json

{
  "name": "orders-worker",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc --noEmit && esbuild src/worker.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker/index.mjs",
    "synth": "npm run build && cdk synth",
    "deploy": "npm run build && cdk deploy --require-approval broadening"
  },
  "dependencies": { "aws-cdk-lib": "2.273.0", "constructs": "^10.0.0" },
  "devDependencies": { "aws-cdk": "2.1145.0", "@types/aws-lambda": "^8.10.0", "@types/node": "^24.0.0", "esbuild": "^0.25.0", "tsx": "^4.0.0", "typescript": "^5.6.0" }
}

tsconfig.json

{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "skipLibCheck": true, "types": ["node"] }, "include": ["bin", "lib", "src"] }

cdk.json

{ "app": "npx tsx bin/app.ts" }

bin/app.ts

import { App } from "aws-cdk-lib";
import { OrdersWorkerStack } from "../lib/stack.js";

const app = new App();
new OrdersWorkerStack(app, "OrdersWorker", {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
  alarmEmail: process.env.ALARM_EMAIL,
});

lib/stack.ts

import { Duration, Stack, type StackProps, RemovalPolicy, CfnOutput } 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 * as logs from "aws-cdk-lib/aws-logs";
import * as sns from "aws-cdk-lib/aws-sns";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
import { EmailSubscription } from "aws-cdk-lib/aws-sns-subscriptions";

export interface OrdersWorkerProps extends StackProps { alarmEmail?: string }

export class OrdersWorkerStack extends Stack {
  constructor(scope: Construct, id: string, props: OrdersWorkerProps) {
    super(scope, id, props);
    const timeout = Duration.seconds(30);

    const dlq = new sqs.Queue(this, "OrdersDlq", {
      retentionPeriod: Duration.days(14),                 // longer than the source queue
      encryption: sqs.QueueEncryption.SQS_MANAGED,
    });
    const queue = new sqs.Queue(this, "Orders", {
      visibilityTimeout: Duration.seconds(timeout.toSeconds() * 6),
      retentionPeriod: Duration.days(4),
      deadLetterQueue: { queue: dlq, maxReceiveCount: 5 },
      encryption: sqs.QueueEncryption.SQS_MANAGED,         // SNS/EventBridge/S3 can still deliver later
    });

    const logGroup = new logs.LogGroup(this, "WorkerLogs", {
      retention: logs.RetentionDays.ONE_MONTH,
      removalPolicy: RemovalPolicy.DESTROY,
    });
    const fn = new lambda.Function(this, "Worker", {
      runtime: lambda.Runtime.NODEJS_24_X,
      handler: "index.handler",
      code: lambda.Code.fromAsset("dist/worker"),
      timeout,
      memorySize: 256,
      logGroup,
      loggingFormat: lambda.LoggingFormat.JSON,
    });
    fn.addEventSource(new SqsEventSource(queue, { batchSize: 10, reportBatchItemFailures: true, maxConcurrency: 10 }));

    const topic = new sns.Topic(this, "Alarms");
    if (props.alarmEmail) topic.addSubscription(new EmailSubscription(props.alarmEmail)); // must be confirmed

    const errorRate = new cloudwatch.MathExpression({
      expression: "100 * FILL(errors, 0) / invocations",
      usingMetrics: {
        errors: fn.metricErrors({ statistic: "Sum", period: Duration.minutes(1) }),
        invocations: fn.metricInvocations({ statistic: "Sum", period: Duration.minutes(1) }),
      },
      label: "Error %",
    }).createAlarm(this, "ErrorRate", {
      threshold: 5, evaluationPeriods: 5, datapointsToAlarm: 3,
      comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
      treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
    });
    const dlqDepth = dlq.metricApproximateNumberOfMessagesVisible({ period: Duration.minutes(1) }).createAlarm(this, "DlqNotEmpty", {
      threshold: 0, evaluationPeriods: 1,
      comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
      treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
    });
    for (const a of [errorRate, dlqDepth]) a.addAlarmAction(new SnsAction(topic));

    new CfnOutput(this, "QueueUrl", { value: queue.queueUrl });
    new CfnOutput(this, "DlqUrl", { value: dlq.queueUrl });
  }
}

src/worker.ts

import type { SQSEvent, SQSBatchResponse, SQSBatchItemFailure } from "aws-lambda";

type Order = { orderId: string; total: number };

export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
  const batchItemFailures: SQSBatchItemFailure[] = [];
  for (const record of event.Records) {
    try {
      const order = JSON.parse(record.body) as Order;
      if (typeof order.orderId !== "string") throw new Error("missing orderId");
      // Idempotent work keyed on order.orderId goes here (e.g. a DynamoDB conditional put).
      console.info(JSON.stringify({ msg: "processed", orderId: order.orderId, messageId: record.messageId }));
    } catch (err) {
      console.error(JSON.stringify({ msg: "failed", messageId: record.messageId, error: String(err) }));
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };
};

After deploying

  • Email subscription: confirm it, or alarm emails never arrive.
  • Topic policy: the CDK added no topic policy for CloudWatch. If alarm notifications don't arrive, add the cloudwatch.amazonaws.com statement from reference/sns/errors.md. Whether same-account alarms need it wasn't settled in the collected sources (UNVERIFIED).
  • Smoke test: aws sqs send-message --queue-url <QueueUrl> --message-body '{"orderId":"o-1","total":10}'. Then aws logs tail <WorkerLogs log-group name> --since 5m. The group has a generated name; find it in the stack's resources.
  • Failure path: send {"total":10}. The worker reports it as failed. After 5 receives it moves to the DLQ, and the DlqNotEmpty alarm fires.

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