An asynchronous API with Lambda is my answer to a question that comes up in almost every Serverless project sooner or later: what do you do with work that takes longer than an HTTP request can wait? Think of a stock report or a PDF export. The first reflex is often to raise the timeout. With API Gateway, that reflex quickly runs into a hard limit.

So in this first part of the series “Serverless patterns in practice”, I build the async job pattern end to end: the API accepts a request, answers right away with 202 Accepted and a job ID, a worker does the work in the background, and the client polls for the status. My example is a fictional bookshop that creates stock reports per branch. All code is tested and runs with the Serverless Framework.

In short

Long tasks do not belong inside the HTTP request: the API stores a job in DynamoDB, puts it on an SQS queue and replies at once with 202 and a status URL. A worker Lambda processes the job while the client polls the status. The biggest pitfall is the gap between the database write and the queue.

What you will learn in this article

  • When an asynchronous API beats a longer timeout
  • What 202 Accepted, Location and Retry-After each do
  • Processing jobs reliably with DynamoDB, SQS and a DLQ
  • How an idempotency key prevents duplicate jobs
  • Which gap the pattern has and how to close it

Why build an asynchronous API with Lambda?

With an Amazon API Gateway HTTP API, the integration may take at most 30 seconds. According to the quotas page, this limit cannot be raised. A Lambda function itself may run for up to 15 minutes, but that does not help you if the client receives a 503 after 30 seconds while your function keeps computing in the background.

Even below that limit, long waits are unpleasant. Mobile connections drop, browsers show spinners, and many HTTP clients have their own timeouts that are shorter than yours. Worse still, a client that automatically resends after a timeout starts the expensive work a second time. Without protection you compute the same report twice, and in the worst case you send it out twice as well.

The async job pattern (often called “Asynchronous Request-Reply” in the literature) therefore separates accepting a job from doing it. Up front, the API only checks whether the request is valid, stores it and confirms receipt. The actual work happens elsewhere, and the client finds out how far along it is through a status endpoint.

What 202 Accepted really promises

The status code 202 Accepted says: “I have accepted your request but have not finished processing it.” Nothing more. MDN stresses that the response is non-committal: processing can still fail later, and HTTP offers no way to report the outcome afterwards on the same connection. That is exactly why the pattern needs a second channel.

Two headers have become common for this. Location points to the status resource, the address where the client can check back. Retry-After tells it how many seconds to wait before the next check. As the API provider, you decide how often clients poll instead of leaving it to chance.

The async job pattern at a glance

The pattern consists of four building blocks you already know from other contexts. Only the way they work together is new:

  • Intake function behind POST /reports: validates the input, stores a job with status PENDING in DynamoDB, sends the job ID to an SQS queue and replies with 202.
  • SQS queue with a dead-letter queue (DLQ, a second queue that collects messages which failed too often): buffers jobs and takes care of retries.
  • Worker function: picks jobs from the queue, sets the status to RUNNING, does the work and stores the result or the error.
  • Status function behind GET /reports/{jobId}: reads the job and returns its status and, once finished, the result.

🎬 Flow of the asynchronous API with Lambda – this animated diagram only runs on my site: watch it on sascha-ms.de

The four states of a job

A job moves through a small state machine. It starts as PENDING, switches to RUNNING when the worker first touches it, and ends as either SUCCEEDED or FAILED. What matters is that the final states really are final: a finished job must never jump back to RUNNING, even if SQS delivers the same message a second time. We enforce this later with conditions right inside DynamoDB.

Diagram: job states PENDING, RUNNING, SUCCEEDED and FAILED with transitions and retries

A job moves through four states. SUCCEEDED and FAILED are final.

Why SQS sits between the API and the worker

You could also invoke the worker asynchronously. The queue, however, brings three things you would otherwise have to build yourself. It absorbs load peaks, so twenty simultaneous report requests do not trigger twenty simultaneous database queries. It retries failed attempts automatically, because a message reappears once its visibility timeout (the time during which a received message is hidden from other consumers) has expired. And after too many failures it moves the message to the DLQ, where you can inspect it calmly.

With maximumConcurrency you also limit how many workers run at the same time. This protects downstream systems that do not scale as elastically as Lambda. If you are unsure whether SQS or another service fits better, the AWS service finder helps you decide.

How I would build the asynchronous API with Lambda

For the example I use version 4 of the Serverless Framework. It bundles TypeScript handlers with its built-in esbuild without extra plugins and automatically leaves the AWS SDK v3 out of the package, because the Lambda runtime already includes it. If you prefer the AWS CDK, you can carry the resources over one to one; the handlers stay the same.

Prerequisites

  • Node.js 22.18 or newer (for the tests with built-in TypeScript support); Node.js 24 is recommended
  • An AWS account with permissions for Lambda, API Gateway, DynamoDB, SQS, IAM and CloudFormation
  • The Serverless Framework v4 (npm install -g serverless) and a Serverless login (serverless login)
  • Basic knowledge of TypeScript and Lambda. If DynamoDB is new to you, DynamoDB with Lambda and CDK explains the basics.

Step 1: Create the project

Create an empty folder and install the dependencies. At runtime you only need the three SDK packages; the tests add types, TypeScript and aws-sdk-client-mock.

mkdir bookshop-reports && cd bookshop-reports npm init -y npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sqs npm install --save-dev typescript @types/node @types/aws-lambda aws-sdk-client-mock mkdir src test 

Add two scripts to package.json. The tests run directly with the Node.js test runner, which executes TypeScript files without a build step:

{ "name": "bookshop-reports", "version": "1.0.0", "private": true, "scripts": { "typecheck": "tsc --noEmit", "test": "node --test test/*.test.ts" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.1156.0", "@aws-sdk/client-sqs": "^3.1156.0", "@aws-sdk/lib-dynamodb": "^3.1156.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.164", "@types/node": "^24.0.0", "aws-sdk-client-mock": "^4.1.0", "typescript": "^7.0.2" } } 

The tsconfig.json is set up for a bundler such as esbuild. allowImportingTsExtensions permits imports ending in .ts, which Node.js needs for the tests; erasableSyntaxOnly blocks TypeScript features that Node.js cannot simply strip, such as enum.

{ "compilerOptions": { "target": "ES2023", "module": "preserve", "moduleResolution": "bundler", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "types": [ "node" ] }, "include": [ "src", "test" ] } 

Step 2: The serverless.yml

Half of the architecture lives here: three functions, a table, a queue and its DLQ. Pay particular attention to the numbers on the queue.

# serverless.yml service: bookshop-reports frameworkVersion: '4' provider: name: aws runtime: nodejs24.x architecture: arm64 region: eu-central-1 memorySize: 256 timeout: 10 logRetentionInDays: 14 environment: JOBS_TABLE: !Ref JobsTable JOBS_QUEUE_URL: !Ref JobsQueue MAX_ATTEMPTS: '5' REPORT_DELAY_MS: '20000' iam: role: statements: - Effect: Allow Action: - dynamodb:GetItem - dynamodb:PutItem - dynamodb:UpdateItem Resource: !GetAtt JobsTable.Arn - Effect: Allow Action: sqs:SendMessage Resource: !GetAtt JobsQueue.Arn functions: createReport: handler: src/create-report.handler events: - httpApi: method: POST path: /reports getReport: handler: src/get-report.handler events: - httpApi: method: GET path: /reports/{jobId} reportWorker: handler: src/report-worker.handler timeout: 120 events: - sqs: arn: !GetAtt JobsQueue.Arn batchSize: 1 functionResponseType: ReportBatchItemFailures maximumConcurrency: 5 resources: Resources: JobsTable: Type: AWS::DynamoDB::Table Properties: BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: jobId AttributeType: S KeySchema: - AttributeName: jobId KeyType: HASH TimeToLiveSpecification: AttributeName: expiresAt Enabled: true JobsQueue: Type: AWS::SQS::Queue Properties: VisibilityTimeout: 720 # 6 x worker timeout (120 s) RedrivePolicy: deadLetterTargetArn: !GetAtt JobsDeadLetterQueue.Arn maxReceiveCount: 5 JobsDeadLetterQueue: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 # 14 days 

The worker may run for 120 seconds, so the queue gets a visibility timeout of 720 seconds. This follows the recommendation in the Lambda documentation: at least six times the function timeout, so Lambda still has room for retries when it is throttled. The same page advises a maxReceiveCount of at least 5. After five failed deliveries the message moves to the DLQ, which keeps it for 14 days.

batchSize: 1 is a deliberate choice for long jobs: each invocation handles exactly one job, and a slow report does not hold up others. ReportBatchItemFailures does no harm either. If you raise the batch size later, the worker reports failed messages individually, as described in SQS partial batch response. The framework adds the permissions to receive and delete queue messages for the SQS event itself; the IAM statements above only cover DynamoDB and sending.

REPORT_DELAY_MS makes the worker sleep for 20 seconds. This simulates the long-running work so that you actually get to see the RUNNING status while trying it out. In a real project the variable goes away.

Step 3: Shared access to the jobs

All three functions work with the same table, so the access code lives in its own module. Its four functions map exactly to the state transitions: create, read, claim and finish.

// src/jobs.ts import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand, } from '@aws-sdk/lib-dynamodb'; import { createHash, randomUUID } from 'node:crypto'; export type JobStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED'; export interface ReportResult { titles: number; units: number; lowStock: string[]; } export interface ReportJob { jobId: string; status: JobStatus; branch: string; createdAt: string; updatedAt: string; expiresAt: number; // Unix time in seconds, for DynamoDB TTL attempts?: number; result?: ReportResult; error?: string; } export const RETENTION_DAYS = 7; export const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const table = (): string => { const name = process.env.JOBS_TABLE; if (!name) throw new Error('JOBS_TABLE is not set'); return name; }; // The same idempotency key always yields the same job ID. export function jobIdFor(idempotencyKey?: string): string { if (!idempotencyKey) return randomUUID(); return createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 32); } export function newJob(jobId: string, branch: string, now = new Date()): ReportJob { const iso = now.toISOString(); return { jobId, status: 'PENDING', branch, createdAt: iso, updatedAt: iso, expiresAt: Math.floor(now.getTime() / 1000) + RETENTION_DAYS * 86_400, }; } // Creates the job only if the ID does not exist yet. // Returns true = created, false = already existed. export async function insertJob(job: ReportJob): Promise<boolean> { try { await db.send( new PutCommand({ TableName: table(), Item: job, ConditionExpression: 'attribute_not_exists(jobId)', }), ); return true; } catch (err) { if ((err as Error).name === 'ConditionalCheckFailedException') return false; throw err; } } export async function loadJob(jobId: string): Promise<ReportJob | undefined> { const res = await db.send( new GetCommand({ TableName: table(), Key: { jobId }, ConsistentRead: true }), ); return res.Item as ReportJob | undefined; } // PENDING -> RUNNING. RUNNING is allowed so that a crashed attempt // can carry on with the next delivery. export async function claimJob(jobId: string, now = new Date()): Promise<ReportJob | undefined> { try { const res = await db.send( new UpdateCommand({ TableName: table(), Key: { jobId }, UpdateExpression: 'SET #s = :running, updatedAt = :now, attempts = if_not_exists(attempts, :zero) + :one', ConditionExpression: 'attribute_exists(jobId) AND #s IN (:pending, :running)', ExpressionAttributeNames: { '#s': 'status' }, ExpressionAttributeValues: { ':running': 'RUNNING', ':pending': 'PENDING', ':now': now.toISOString(), ':zero': 0, ':one': 1, }, ReturnValues: 'ALL_NEW', }), ); return res.Attributes as ReportJob; } catch (err) { // Job finished, failed or unknown: ignore the duplicate delivery. if ((err as Error).name === 'ConditionalCheckFailedException') return undefined; throw err; } } export async function finishJob( jobId: string, outcome: { status: 'SUCCEEDED'; result: ReportResult } | { status: 'FAILED'; error: string }, now = new Date(), ): Promise<void> { const field = outcome.status === 'SUCCEEDED' ? 'result' : 'error'; const value = outcome.status === 'SUCCEEDED' ? outcome.result : outcome.error; await db.send( new UpdateCommand({ TableName: table(), Key: { jobId }, UpdateExpression: `SET #s = :status, updatedAt = :now, #f = :value`, ConditionExpression: 'attribute_exists(jobId) AND #s IN (:pending, :running)', ExpressionAttributeNames: { '#s': 'status', '#f': field }, ExpressionAttributeValues: { ':status': outcome.status, ':now': now.toISOString(), ':value': value, ':pending': 'PENDING', ':running': 'RUNNING', }, }), ); } 

Three details matter here. First, jobIdFor derives the job ID from the idempotency key when the client sends one. The same key therefore always yields the same ID, and attribute_not_exists(jobId) prevents a second call from overwriting the first job. Second, claimJob checks the state atomically in DynamoDB: only PENDING or RUNNING may be claimed. A finished job makes the condition fail, and the worker knows it is looking at a duplicate message. Third, every job gets an expiry date in expiresAt for DynamoDB TTL (time to live), so the table does not grow forever.

The small helper for JSON responses keeps the handlers short:

// src/http.ts import type { APIGatewayProxyStructuredResultV2 } from 'aws-lambda'; export function json( statusCode: number, body: unknown, headers: Record<string, string> = {}, ): APIGatewayProxyStructuredResultV2 { return { statusCode, headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body), }; } 

Step 4: Accept the job

The intake function is the part the client feels directly. It has to be fast and still must not enqueue anything twice.

// src/create-report.ts import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs'; import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda'; import { finishJob, insertJob, jobIdFor, loadJob, newJob, type ReportJob } from './jobs.ts'; import { json } from './http.ts'; export const sqs = new SQSClient({}); const BRANCH = /^[a-z0-9-]{2,40}$/; function accepted(job: ReportJob): APIGatewayProxyStructuredResultV2 { const statusUrl = `/reports/${job.jobId}`; return json( 202, { jobId: job.jobId, status: job.status, statusUrl }, { location: statusUrl, 'retry-after': '5' }, );Source:https://dev.to/sascha-ms/asynchronous-api-with-lambda-job-pattern-with-202-and-sqs-e7g

Related posts