Node.js Serverless on AWS Lambda · HTTP APIs on Lambda
Idempotent APIs with Powertools
Why client retries create duplicate orders on Lambda, and how to make write endpoints idempotent.
Scenario
Example app: a checkout API's POST /orders creates an order and charges a card. Mobile clients retry when a request times out, and the API sits behind API Gateway with a 29-second timeout.
What you see in production
Customers occasionally get charged twice and receive two confirmation emails. Each duplicate pair is a few seconds apart, from the same device, during periods of slow responses.
The naive code
export const handler = async (event) => {
const input = JSON.parse(event.body);
const order = await orders.create(input); // new order ID every time
await payments.charge(order.id, input.amount); // a retry charges again
return json(201, order);
};
Reproduce it
A slow first attempt times out on the client, which retries; both requests run:
function makeApi(useIdempotency) {
const charges = [];
const store = new Map(); // idempotencyKey -> { status, response }
return {
charges,
async handle(key, body) {
if (useIdempotency) {
const record = store.get(key);
if (record?.status === "COMPLETED") return { ...record.response, replayed: true };
if (record?.status === "INPROGRESS") return { status: 409, error: "request in progress" };
store.set(key, { status: "INPROGRESS" });
}
const orderId = `ord-${charges.length + 1}`;
charges.push({ orderId, amount: body.amount });
const response = { status: 201, orderId };
if (useIdempotency) store.set(key, { status: "COMPLETED", response });
return response;
},
};
}
for (const useIdempotency of [false, true]) {
const api = makeApi(useIdempotency);
const first = await api.handle("key-9f2", { amount: 49 }); // client timed out waiting
const retry = await api.handle("key-9f2", { amount: 49 }); // client retries with the same key
console.log(useIdempotency ? "with key: " : "without key:", "charges =", api.charges.length, "| retry ->", JSON.stringify(retry));
}
Output
without key: charges = 2 | retry -> {"status":201,"orderId":"ord-2"}
with key: charges = 1 | retry -> {"status":201,"orderId":"ord-1","replayed":true}Root cause
Timeouts and retries are normal in distributed systems: the client cannot tell whether a timed-out request was processed. Without a way to recognise a repeated request, every retry is treated as new work, and side effects such as payments are performed twice.
The fix
Require an Idempotency-Key header from clients on write endpoints, and use Powertools for AWS Lambda Idempotency with a DynamoDB table to record each key's status and response. A repeated key returns the stored response; a concurrent duplicate is rejected while the first is in progress.
import { makeIdempotent, IdempotencyConfig } from "@aws-lambda-powertools/idempotency";
import { DynamoDBPersistenceLayer } from "@aws-lambda-powertools/idempotency/dynamodb";
const persistenceStore = new DynamoDBPersistenceLayer({ tableName: process.env.IDEMPOTENCY_TABLE });
const config = new IdempotencyConfig({ eventKeyJmesPath: 'headers."idempotency-key"', expiresAfterSeconds: 3600 });
export const handler = makeIdempotent(async (event) => {
const input = JSON.parse(event.body);
const order = await orders.create(input);
await payments.charge(order.id, input.amount, { idempotencyKey: order.id }); // the provider deduplicates too
return json(201, order);
}, { persistenceStore, config });
Pass idempotency keys on to downstream providers (Stripe and most payment APIs accept them), and enforce uniqueness in the database where you can.
Trade-offs
- The idempotency table adds a read and write per request and needs TTL cleanup.
- Choose the key carefully: a client-generated UUID per user action, not per HTTP attempt; hashing the payload can wrongly merge two genuinely identical orders.
- In-progress records need an expiry so a crashed invocation does not block retries forever.
Detect it in production
- Count requests that hit an existing key (replays) and concurrent duplicates (409s).
- Reconciliation that flags multiple charges for the same cart or order within a short window.
- Contract tests that send the same request twice and expect one side effect.
Key points
- Retries after timeouts are normal; writes must be idempotent.
- Use an Idempotency-Key plus Powertools Idempotency with DynamoDB.
- Pass keys to downstream providers; add database uniqueness where possible.
Further reading: Powertools for AWS Lambda (TypeScript) · Stripe: idempotent requests
Practice questions
Why do client retries cause duplicate orders, and how does an idempotency key fix it?
Answer
A client that times out cannot know whether the server processed the request, so it retries, and the server treats the retry as new work. With an idempotency key, the server records the key and its result; a repeated key returns the stored result instead of repeating the side effects.
What does Powertools Idempotency store, and why does it need an in-progress state?
Answer
It stores each idempotency key with its status and the response (in DynamoDB). The in-progress state rejects a concurrent duplicate while the first request is still running; records expire so crashed invocations do not block retries forever.
Why is hashing the request body a risky choice of idempotency key?
Answer
Two genuinely separate actions with identical payloads (the same customer buying the same item twice) would be merged into one. Prefer a client-generated key per user action, reused only for retries of that action.
Open in the app to mark lessons complete, take notes and track your progress.