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
// 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.