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 statusPENDINGin 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.
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' },
);
}
export async function handler(
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> {
let branch: unknown;
try {
branch = JSON.parse(event.body ?? '{}').branch;
} catch {
return json(400, { message: 'Body is not valid JSON' });
}
if (typeof branch !== 'string' || !BRANCH.test(branch)) {
return json(400, { message: 'branch is missing or has an invalid format' });
}
// HTTP APIs deliver header names in lower case.
const key = event.headers?.['idempotency-key'];
if (key !== undefined && (key.length < 8 || key.length > 128)) {
return json(400, { message: 'Idempotency-Key must be 8 to 128 characters long' });
}
const job = newJob(jobIdFor(key), branch);
if (!(await insertJob(job))) {
// Repeated call with the same key: return the existing job, enqueue nothing.
const existing = await loadJob(job.jobId);
if (!existing) return json(404, { message: 'Job has expired' });
if (existing.branch !== branch) {
return json(409, { message: 'Idempotency-Key was already used for a different request' });
}
return accepted(existing);
}
try {
await sqs.send(
new SendMessageCommand({
QueueUrl: process.env.JOBS_QUEUE_URL,
MessageBody: JSON.stringify({ jobId: job.jobId }),
}),
);
} catch (err) {
console.error('Job could not be enqueued', { jobId: job.jobId, err });
await finishJob(job.jobId, { status: 'FAILED', error: 'Enqueue failed' });
return json(503, { message: 'Please try again later' }, { 'retry-after': '30' });
}
return accepted(job);
}
The flow reads from top to bottom: validate the input, create the job, send the message, return 202. The branches are where it gets interesting. If the job already exists because the client resent with the same idempotency key, the function simply returns the existing job and enqueues nothing. If the same key arrives with a different branch, it answers with 409 Conflict, because something went wrong on the client side. You can read more about duplicate events in Idempotency in AWS Lambda.
If sending to SQS fails, the function marks the job as FAILED and responds with 503. That way no job stays stuck in PENDING forever just because the queue was briefly unreachable. It is not completely watertight, though; more on that below.
Step 5: Query the status
// src/get-report.ts
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
import { loadJob } from './jobs.ts';
import { json } from './http.ts';
const JOB_ID = /^[A-Za-z0-9-]{1,64}$/;
export async function handler(
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> {
const jobId = event.pathParameters?.jobId ?? '';
if (!JOB_ID.test(jobId)) return json(400, { message: 'Invalid job ID' });
const job = await loadJob(jobId);
// TTL deletes with a delay: we treat expired jobs as deleted.
if (!job || job.expiresAt < Date.now() / 1000) {
return json(404, { message: 'Job not found' });
}
const { status, createdAt, updatedAt, result, error } = job;
const done = status === 'SUCCEEDED' || status === 'FAILED';
return json(
200,
{ jobId, status, createdAt, updatedAt, result, error },
done ? { 'cache-control': 'max-age=60' } : { 'retry-after': '5', 'cache-control': 'no-store' },
);
}
While the job is running, the function sends Retry-After: 5 and forbids caching. Once it is finished, a proxy may cache the response for a minute, since the result will not change any more. The check on expiresAt is not distrust of DynamoDB but straight from the documentation: expired items are typically deleted only within a few days and still show up in reads until then.
Step 6: The worker
First the actual work. In the example it calculates over a hard-coded stock list. In your project, this is where the part that really takes time goes.
// src/build-report.ts
import type { ReportResult } from './jobs.ts';
interface Book {
title: string;
stock: number;
}
// Sample data. In real life the stock would come from a database or an inventory system.
const INVENTORY: Record<string, Book[]> = {
oldtown: [
{ title: 'The Lighthouse in the Fog', stock: 12 },
{ title: 'Cooking with Leftovers', stock: 2 },
{ title: 'A Short History of Bridges', stock: 0 },
{ title: 'Balcony Gardening', stock: 7 },
],
harbour: [
{ title: 'Knots for Beginners', stock: 4 },
{ title: 'Reading Nautical Charts', stock: 1 },
],
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function buildReport(branch: string, delayMs = 0): Promise<ReportResult> {
const books = INVENTORY[branch];
if (!books) throw new Error(`Unknown branch: ${branch}`);
await sleep(delayMs); // simulates the long-running work
return {
titles: books.length,
units: books.reduce((sum, b) => sum + b.stock, 0),
lowStock: books.filter((b) => b.stock < 3).map((b) => b.title),
};
}
The worker itself only handles the frame around it: claim the job, call the work, store the result, report errors cleanly.
// src/report-worker.ts
import type { SQSBatchResponse, SQSEvent } from 'aws-lambda';
import { buildReport } from './build-report.ts';
import { claimJob, finishJob } from './jobs.ts';
const MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS ?? '5');
export async function handler(event: SQSEvent): Promise<SQSBatchResponse> {
const batchItemFailures: SQSBatchResponse['batchItemFailures'] = [];
for (const record of event.Records) {
const { jobId } = JSON.parse(record.body) as { jobId: string };
try {
const job = await claimJob(jobId);
if (!job) {
console.info('Job already done or unknown, discarding message', { jobId });
continue;
}
const result = await buildReport(job.branch, Number(process.env.REPORT_DELAY_MS ?? '0'));
await finishJob(jobId, { status: 'SUCCEEDED', result });
} catch (err) {
const receiveCount = Number(record.attributes.ApproximateReceiveCount);
console.error('Report failed', { jobId, receiveCount, err });
if (receiveCount >= MAX_ATTEMPTS) {
// Last attempt: record the status for the client. The message then moves to the DLQ.
await finishJob(jobId, { status: 'FAILED', error: (err as Error).message }).catch(() => {});
}
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
}
If buildReport throws, the message ends up in batchItemFailures and comes back after the visibility timeout. On the fifth attempt (ApproximateReceiveCount counts the deliveries) the worker first writes FAILED and the error message into the job. The client learns about the failure, and the message still moves to the DLQ so you can analyse it.
Step 7: Test without an AWS account
The tests replace DynamoDB and SQS with mocks and check the spots where the pattern tends to break: duplicate key, conflict, queue outage, duplicate delivery and the last attempt.
// test/report-jobs.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { mockClient } from 'aws-sdk-client-mock';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { SendMessageCommand } from '@aws-sdk/client-sqs';
import type { APIGatewayProxyEventV2, SQSEvent } from 'aws-lambda';
import { db, jobIdFor, newJob } from '../src/jobs.ts';
import { handler as createReport, sqs } from '../src/create-report.ts';
import { handler as getReport } from '../src/get-report.ts';
import { handler as reportWorker } from '../src/report-worker.ts';
process.env.JOBS_TABLE = 'jobs';
process.env.JOBS_QUEUE_URL = 'https://sqs.example/queue';
const dbMock = mockClient(db);
const sqsMock = mockClient(sqs);
const conditionFailed = () =>
new ConditionalCheckFailedException({ message: 'condition', $metadata: {} });
const post = (body: unknown, key?: string) =>
({
body: JSON.stringify(body),
headers: key ? { 'idempotency-key': key } : {},
}) as unknown as APIGatewayProxyEventV2;
const sqsEvent = (jobId: string, receiveCount = 1) =>
({
Records: [
{
messageId: 'm-1',
body: JSON.stringify({ jobId }),
attributes: { ApproximateReceiveCount: String(receiveCount) },
},
],
}) as unknown as SQSEvent;
beforeEach(() => {
dbMock.reset();
sqsMock.reset();
});
test('POST answers with 202 and Location and enqueues the job', async () => {
dbMock.on(PutCommand).resolves({});
sqsMock.on(SendMessageCommand).resolves({ MessageId: 'x' });
const res = await createReport(post({ branch: 'oldtown' }));
assert.equal(res.statusCode, 202);
const body = JSON.parse(res.body!);
assert.equal(body.status, 'PENDING');
assert.equal(res.headers?.location, `/reports/${body.jobId}`);
assert.equal(sqsMock.commandCalls(SendMessageCommand).length, 1);
});
test('POST rejects an invalid branch with 400', async () => {
const res = await createReport(post({ branch: 'Old Town!' }));
assert.equal(res.statusCode, 400);
assert.equal(dbMock.calls().length, 0);
});
test('same idempotency key returns the same job and enqueues nothing', async () => {
const key = 'stock-oldtown-2026-10';
const existing = newJob(jobIdFor(key), 'oldtown');
dbMock.on(PutCommand).rejects(conditionFailed());
dbMock.on(GetCommand).resolves({ Item: { ...existing, status: 'RUNNING' } });
const res = await createReport(post({ branch: 'oldtown' }, key));
assert.equal(res.statusCode, 202);
assert.equal(JSON.parse(res.body!).jobId, existing.jobId);
assert.equal(JSON.parse(res.body!).status, 'RUNNING');
assert.equal(sqsMock.commandCalls(SendMessageCommand).length, 0);
});
test('same key with a different branch yields 409', async () => {
const key = 'stock-oldtown-2026-10';
dbMock.on(PutCommand).rejects(conditionFailed());
dbMock.on(GetCommand).resolves({ Item: newJob(jobIdFor(key), 'oldtown') });
const res = await createReport(post({ branch: 'harbour' }, key));
assert.equal(res.statusCode, 409);
});
test('if enqueueing fails, the job becomes FAILED and the API returns 503', async () => {
dbMock.on(PutCommand).resolves({});
dbMock.on(UpdateCommand).resolves({});
sqsMock.on(SendMessageCommand).rejects(new Error('SQS unreachable'));
const errorLog = console.error;
console.error = () => {};
const res = await createReport(post({ branch: 'harbour' }));
console.error = errorLog;
assert.equal(res.statusCode, 503);
const update = dbMock.commandCalls(UpdateCommand)[0].args[0].input;
assert.equal(update.ExpressionAttributeValues?.[':status'], 'FAILED');
});
test('GET returns the status with Retry-After while the job runs', async () => {
dbMock.on(GetCommand).resolves({ Item: { ...newJob('abc-123', 'harbour'), status: 'RUNNING' } });
const res = await getReport({ pathParameters: { jobId: 'abc-123' } } as never);
assert.equal(res.statusCode, 200);
assert.equal(JSON.parse(res.body!).status, 'RUNNING');
assert.equal(res.headers?.['retry-after'], '5');
});
test('GET treats expired jobs as unknown (404)', async () => {
const old = newJob('abc-123', 'harbour', new Date('2020-01-01T00:00:00Z'));
dbMock.on(GetCommand).resolves({ Item: old });
const res = await getReport({ pathParameters: { jobId: 'abc-123' } } as never);
assert.equal(res.statusCode, 404);
});
test('worker builds the report and sets SUCCEEDED', async () => {
dbMock
.on(UpdateCommand)
.resolvesOnce({ Attributes: { ...newJob('j1', 'oldtown'), status: 'RUNNING' } })
.resolves({});
const res = await reportWorker(sqsEvent('j1'));
assert.deepEqual(res, { batchItemFailures: [] });
const finish = dbMock.commandCalls(UpdateCommand)[1].args[0].input;
assert.equal(finish.ExpressionAttributeValues?.[':status'], 'SUCCEEDED');
assert.deepEqual(finish.ExpressionAttributeValues?.[':value'], {
titles: 4,
units: 21,
lowStock: ['Cooking with Leftovers', 'A Short History of Bridges'],
});
});
test('worker ignores a duplicate delivery of a finished job', async () => {
dbMock.on(UpdateCommand).rejects(conditionFailed());
const infoLog = console.info;
console.info = () => {};
const res = await reportWorker(sqsEvent('j1'));
console.info = infoLog;
assert.deepEqual(res, { batchItemFailures: [] });
assert.equal(dbMock.commandCalls(UpdateCommand).length, 1);
});
test('worker reports the failure and sets FAILED on the last attempt', async () => {
dbMock
.on(UpdateCommand)
.resolvesOnce({ Attributes: { ...newJob('j2', 'moon'), status: 'RUNNING' } })
.resolves({});
const errorLog = console.error;
console.error = () => {};
const res = await reportWorker(sqsEvent('j2', 5));
console.error = errorLog;
assert.deepEqual(res, { batchItemFailures: [{ itemIdentifier: 'm-1' }] });
const fail = dbMock.commandCalls(UpdateCommand)[1].args[0].input;
assert.equal(fail.ExpressionAttributeValues?.[':status'], 'FAILED');
assert.match(String(fail.ExpressionAttributeValues?.[':value']), /Unknown branch/);
});
npm run typecheck
npm test
Expected result: tsc reports no errors, and the test runner ends with # pass 10 and # fail 0.
Step 8: Deploy and try it out
serverless deploy
After about two minutes, the output lists the endpoints of your HTTP API. Put the base URL into a variable and submit a job with an idempotency key:
API=https://abc123.execute-api.eu-central-1.amazonaws.com
curl -i -X POST "$API/reports" \
-H "content-type: application/json" \
-H "idempotency-key: stock-oldtown-2026-10" \
-d '{"branch":"oldtown"}'
The response arrives immediately. Sample output, shortened:
HTTP/2 202
location: /reports/6f0c3b8e4d2a9f17c5e8b1a2d3f4e5c6
retry-after: 5
{"jobId":"6f0c3b8e4d2a9f17c5e8b1a2d3f4e5c6","status":"PENDING","statusUrl":"/reports/6f0c3b8e4d2a9f17c5e8b1a2d3f4e5c6"}
If you send the same command again, you get the same job ID back, and no second message lands in the queue. Now query the status, once right away and once after about 25 seconds:
curl -s "$API/reports/6f0c3b8e4d2a9f17c5e8b1a2d3f4e5c6"
{"jobId":"6f0c3b8e4d2a9f17c5e8b1a2d3f4e5c6","status":"RUNNING","createdAt":"2026-10-25T13:02:11.204Z","updatedAt":"2026-10-25T13:02:11.873Z"}
{"jobId":"6f0c3b8e4d2a9f17c5e8b1a2d3f4e5c6","status":"SUCCEEDED","createdAt":"2026-10-25T13:02:11.204Z","updatedAt":"2026-10-25T13:02:31.951Z","result":{"titles":4,"units":21,"lowStock":["Cooking with Leftovers","A Short History of Bridges"]}}
Finally, try a branch that does not exist, for example {"branch":"moon"}. The worker fails five times, the status switches to FAILED, and the message ends up in the DLQ. Because the visibility timeout is 720 seconds, this takes roughly an hour. Run serverless remove at the end to clean everything up.
Securing an asynchronous API with Lambda: common mistakes
The gap between DynamoDB and SQS
The intake function writes to two systems: first DynamoDB, then SQS. These writes are not atomic. The code handles the case “SQS returns an error”. It does not handle the case “Lambda dies right between the two calls”, for instance on a timeout or a runtime crash. Then a job sits in PENDING, but there is no message in the queue. This is known as the dual-write problem.
For many applications the risk is small enough to deal with through a simple cleanup job that finds old PENDING jobs and either re-enqueues them or sets them to FAILED. A cleaner approach is to write only to DynamoDB and let the message emerge from the table’s change stream, via DynamoDB Streams or EventBridge Pipes. This outbox pattern is the topic of part 2 of this series.
A visibility timeout that is too short
If the visibility timeout is shorter than the worker’s run time, the message reappears while the first attempt is still working. A second worker starts the same job. The condition in claimJob allows this second run as long as the job is RUNNING. That is intentional, so a crashed attempt can carry on. The second worker therefore computes the report again and only fails at the condition when saving, because the job is SUCCEEDED by then. The result stays correct, but you pay twice, and work with a side effect such as an email would have happened twice. So stick to the six-times rule of thumb, and never let the worker run up against its own timeout.
Polling without limits
A client that checks every 100 milliseconds causes needless cost and load. Retry-After is a request, not a guarantee. Document in your API how often clients may poll, and add throttling in API Gateway if needed. For clients you build yourself: respect Retry-After and increase the interval gradually for long jobs.
An idempotency key without a sender
The example has no authentication, so the job ID depends only on the key. In a real API I would always combine the key with the caller’s identity, for example sha256(clientId + key). Otherwise a client could see someone else’s job by guessing a key. Likewise, do not make job IDs predictable if the status endpoint hands out results without further checks.
Storing large results in the job
A DynamoDB item may be at most 400 KB. That is enough for a small report, but not for a PDF. For large results the worker stores the file in S3, and the status endpoint returns a short-lived presigned URL. Alternatively it answers with 303 See Other and redirects straight to the result resource, as Microsoft suggests in its description of the pattern.
What does an asynchronous API with Lambda cost?
Everything in the example is billed by usage; without jobs there are practically no costs. Per job you roughly get: one intake invocation with a DynamoDB write, one SQS message plus the receives by Lambda, one worker invocation with two writes, and for every status check another function invocation with one read. The biggest item is almost always the worker’s run time. Watch out with the simulation: 20 seconds of sleep are billed in full as compute time.
Do the maths for your case with real numbers: for the worker, memory size and duration count; for DynamoDB and SQS, the number of requests. The pricing pages of each service show current values for your region. If you expect many status checks, a more generous Retry-After saves more than any code optimisation.
My take
I consider the async job pattern one of the underrated tools in everyday Serverless work. It is not elegant in the sense of “cool”, but it is honest: the API only promises what it can deliver, and the client gets a clear answer at any time. For teams coming from synchronous container services in particular, it is often the moment when event-driven thinking becomes tangible.
It is not always the best choice, though. If the client has a server anyway, webhooks are more convenient than polling. For live progress in the browser, WebSockets fit better. And if the work consists of many steps with their own retries, I would replace the worker with Step Functions or Lambda durable functions and use the pattern only as the outer shell: the API accepts, the workflow works, the status endpoint reads. I am critical of variants where the client polls the result URL directly and keeps getting 404 until it exists. Then nobody can tell whether the job is still running or the ID is wrong.
FAQ
What is the difference between 200 and 202?
200 OK means the request is done and the result is in the response. 202 Accepted only means the server has accepted the job. Whether it succeeds, the client learns later through another channel, such as a status endpoint.
How long can a Lambda function behind API Gateway run?
For HTTP APIs the maximum integration timeout is 30 seconds and cannot be increased. The Lambda function itself may run for up to 15 minutes, but the client still gets an error after 30 seconds. Longer work therefore belongs in an asynchronous flow.
Do I need SQS or is an asynchronous Lambda invocation enough?
An asynchronous invocation works too and has built-in retries. SQS gives you more control, though: you limit concurrency, see the backlog in the queue and can redrive failed messages from the DLQ on purpose.
How do I prevent duplicate jobs when the client resends?
Have the client send an idempotency key and derive the job ID from it. With a condition such as attribute_not_exists, DynamoDB creates the job only once. Every further call with the same key gets the existing job back.
Polling, webhook or WebSocket: which is better?
Polling is the simplest and works with any client. Webhooks pay off when the client is reachable itself and starts many jobs. WebSockets or server-sent events fit when a person in the browser is waiting for progress.
Your next step
Deploy the example and break it on purpose: set the visibility timeout to 30 seconds, submit a job and watch in CloudWatch what happens to it. Then look through your own projects for endpoints that regularly scrape against the 30-second limit. Those are your candidates.
How do you handle long tasks in your APIs today: polling, webhooks or the raised timeout after all? Let me know in the comments.
Next in the series: Overview “Serverless patterns in practice” · Next part: outbox pattern with DynamoDB Streams and EventBridge Pipes
Why I use the Serverless Framework here, and when I would pick CDK or SST instead, is what I explain in Serverless Framework vs CDK vs SST: my pick for 2026.
Sources
- Amazon API Gateway: Quotas for HTTP APIs (maximum integration timeout)
- AWS Lambda: Configuring an SQS event source (visibility timeout, maxReceiveCount)
- Amazon DynamoDB: Time to Live (TTL)
- AWS Lambda: Runtimes (nodejs24.x)
- MDN: 202 Accepted
- Microsoft Azure Architecture Center: Asynchronous Request-Reply pattern
- Serverless Framework: SQS events
- Serverless Framework: Building (built-in esbuild)
- Tested with Node.js 22.23, TypeScript 7.0.2, @aws-sdk/* 3.1156.0, aws-sdk-client-mock 4.1.0, esbuild 0.28.2 (tsc, 10 tests, bundle test); serverless.yml validated against the framework schema and packaged. Deploy and curl outputs are marked as samples.
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (1)
The gap between the database write and the queue publish is where this pattern earns or loses its reliability, and it is good to see it named as the biggest pitfall rather than buried. Two separate writes that both have to happen will eventually do only one of them, and the symptom — a job the client can poll forever that no worker will ever see — is confusing precisely because the acceptance looked successful. The outbox-style fix, writing the intent in the same transaction and letting a relay publish it, costs a little complexity and buys back the one property the whole pattern exists for: accepted really means queued.