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:

Retries with and without an idempotency keyTry it yourself
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.