Node.js Serverless on AWS Lambda · Data, Concurrency and Cost

DynamoDB from Lambda: Pagination and Conditional Writes

Why a 'list all orders' endpoint silently returns a fraction of the data, and how to use the SDK v3 correctly.

Scenario

Example app: an admin endpoint lists a customer's orders from DynamoDB, and a second endpoint marks an order as shipped.

What you see in production

Large customers report that the admin view shows only some of their orders, with no error. Separately, an order was marked "shipped" after it had been cancelled, because two requests updated it at nearly the same time.

The naive code

const res = await doc.send(new QueryCommand({
  TableName: "orders", KeyConditionExpression: "pk = :c", ExpressionAttributeValues: { ":c": `CUSTOMER#${id}` },
}));
return res.Items;                            // only the first page (up to 1 MB of data)

await doc.send(new UpdateCommand({           // overwrites whatever the status was
  TableName: "orders", Key: { pk, sk }, UpdateExpression: "SET #s = :shipped",
  ExpressionAttributeNames: { "#s": "status" }, ExpressionAttributeValues: { ":shipped": "SHIPPED" },
}));

Reproduce it

Pages and conditional updatesTry it yourself
// A fake table returning at most 3 items per page, like DynamoDB's 1 MB page limit.
const items = Array.from({ length: 8 }, (_, i) => ({ sk: `ORDER#${i + 1}` }));
function query(start = 0) {
  const page = items.slice(start, start + 3);
  return { Items: page, LastEvaluatedKey: start + 3 < items.length ? start + 3 : undefined };
}
console.log("first page only:", query().Items.length, "of", items.length);

let all = [], key;
do { const r = query(key); all = all.concat(r.Items); key = r.LastEvaluatedKey; } while (key !== undefined);
console.log("all pages:      ", all.length, "of", items.length);

// Two concurrent updates to the same order
const order = { status: "PLACED" };
function update(newStatus, condition) {
  if (condition && !condition(order.status)) return `rejected (${newStatus}): status is ${order.status}`;
  order.status = newStatus; return `ok -> ${newStatus}`;
}
console.log(update("CANCELLED"), "|", update("SHIPPED"), "| unconditional result:", order.status);
order.status = "PLACED";
console.log(update("CANCELLED", (s) => s === "PLACED"), "|", update("SHIPPED", (s) => s === "PLACED"), "| conditional result:", order.status);

Output

first page only: 3 of 8
all pages:       8 of 8
ok -> CANCELLED | ok -> SHIPPED | unconditional result: SHIPPED
ok -> CANCELLED | rejected (SHIPPED): status is CANCELLED | conditional result: CANCELLED

Root cause

DynamoDB Query and Scan return at most 1 MB per call and indicate more data with LastEvaluatedKey; code that ignores it silently drops results. Updates without a condition are last-writer-wins, so concurrent requests overwrite each other's state transitions.

The fix

Paginate with the SDK v3 paginators (or loop on LastEvaluatedKey), and expose cursors to API clients rather than returning unbounded lists. Guard state transitions with condition expressions:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, paginateQuery, UpdateCommand } from "@aws-sdk/lib-dynamodb";

const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));   // module scope: reused connections

export async function listOrders(customerId) {
  const out = [];
  for await (const page of paginateQuery({ client: doc }, {
    TableName: "orders", KeyConditionExpression: "pk = :c", ExpressionAttributeValues: { ":c": `CUSTOMER#${customerId}` },
  })) out.push(...page.Items);
  return out;
}

export const markShipped = (pk, sk) => doc.send(new UpdateCommand({
  TableName: "orders", Key: { pk, sk },
  UpdateExpression: "SET #s = :shipped",
  ConditionExpression: "#s = :placed",                       // only from PLACED
  ExpressionAttributeNames: { "#s": "status" },
  ExpressionAttributeValues: { ":shipped": "SHIPPED", ":placed": "PLACED" },
}));

Handle ConditionalCheckFailedException as a business outcome (409 Conflict), not a 500.

Trade-offs

  • Returning all pages from an API endpoint can still be too large; prefer cursor-based pagination to clients.
  • Conditions make writes fail under contention; that is the point, but callers must handle conflicts.
  • The document client converts types for you; know its behaviour for numbers (precision) and empty values.

Detect it in production

  • Tests with more data than one page; metrics on result counts per request.
  • Rate of ConditionalCheckFailedException, which indicates contention or client bugs.
  • Data audits for impossible state transitions (shipped after cancelled).

Key points

  • Query/Scan return pages; always follow LastEvaluatedKey.
  • Use condition expressions for state transitions and uniqueness.
  • Create SDK clients once at module scope.

Further reading: Paginating DynamoDB query results · DynamoDB condition expressions

Practice questions

Why might a DynamoDB query in Lambda return only part of the matching items without any error?

Answer

Query and Scan return at most 1 MB of data per call and signal remaining data with LastEvaluatedKey. If the code does not request further pages, the rest is silently omitted.

How do you prevent a cancelled order from being marked shipped by a concurrent request?

Answer

Use a condition expression on the update (for example #s = :placed) so the write succeeds only from the expected state; the losing request gets ConditionalCheckFailedException, which you return as a conflict.

Where should DynamoDB clients be created in a Lambda function?

Answer

At module scope, so they are created once per execution environment and reuse their HTTP connections across invocations.

Open in the app to mark lessons complete, take notes and track your progress.