DynamoDB Scan in Node.js (AWS SDK v3)
The do/while below is not defensive coding. A filtered Scan page can come back with an empty Items array and still have more of the table left, so stopping at the first response is how a scan reports zero matches on a table that has them. Query vs. Scan covers when to avoid the operation entirely.
Code
import {DynamoDBClient, ScanCommand} from '@aws-sdk/client-dynamodb';
const client = new DynamoDBClient({region: 'us-east-1'});
const items = [];
let lastEvaluatedKey;
do {
const response = await client.send(
new ScanCommand({
TableName: 'Music',
FilterExpression: '#filter0 >= :filterValue0',
ExpressionAttributeNames: {
'#filter0': 'Year'
},
ExpressionAttributeValues: {
':filterValue0': {N: '2010'}
},
ExclusiveStartKey: lastEvaluatedKey
})
);
items.push(...(response.Items ?? []));
lastEvaluatedKey = response.LastEvaluatedKey;
} while (lastEvaluatedKey);
console.log(`Matched ${items.length} items`);Two empty pages, 284.5 read units, 8 items
The fixture is 600 songs of roughly 3.9 KB each, of which exactly 8 have Year >= 2010, and they sort last. Here is what the loop above actually receives:
| Round trip | Items.length | ScannedCount | Read units | LastEvaluatedKey |
|---|---|---|---|---|
| 1 | 0 | 271 | 128.5 | set |
| 2 | 0 | 271 | 128.5 | set |
| 3 | 8 | 58 | 27.5 | absent |
Two consecutive pages return nothing and cost 128.5 read units each. Code that does if (!response.Items.length) return reports an empty table. The API reference states the rule plainly: "a scan result can result in no items meeting the criteria and the Count will result in zero", and separately that "a FilterExpression is applied after the items have already been read; the process of filtering does not consume any additional read capacity units".
Read that second sentence the way your invoice does. The filter is free, and everything it discarded is not: 284.5 read units to deliver 8 items, the same bill you would pay with no filter at all.
Measured 2026-07-28 against DynamoDB Local (amazon/dynamodb-local) with @aws-sdk/client-dynamodb 3.1095.0 on node v24.18.0. Counts and capacity are the engine's own response fields.
Explanation
ExclusiveStartKey: lastEvaluatedKeyisundefinedon the first pass. The v3 serialiser dropsundefinedmembers, so one object literal covers the first request and every follow-up. Passing{}instead fails withValidationException: The provided starting key is invalid.response.Items ?? []— doing real work. Combine it with the table above: the nullish coalescing keeps the accumulator honest on the pages that matched nothing, and thewhilekeeps the loop alive past them.#filter0is not decoration.Yearis on AWS's reserved-word list, and using it unaliased returnsValidationException: Invalid FilterExpression: Attribute name is a reserved keyword; reserved keyword: Year.Limit— counts items read, not items returned. With this filter,Limit: 10yieldsCount: 0andScannedCount: 10. It is a throttling knob for capacity spikes, not a way to ask for ten results.Segment/TotalSegmentssplit a full-table scan across workers. That divides the wall clock, not the cost — the same 284.5 units are spent, just faster and more concurrently.
What that costs on a real table
284.5 read units for 8 items is the shape of the problem, and it scales linearly with the table, not with the result. Before shipping a filtered scan on a hot path, price the full-table read at your item size and traffic in the DynamoDB pricing calculator, then compare it to a GSI that turns the same access pattern into a Query.
To explore tables in a GUI, with filtered and paginated result grids, download DynoTable instead of scanning blind from a script.
Related guides
- Query vs. Scan — when (rarely) a
Scanis justified. - Why is my DynamoDB Scan slow and expensive? — the cost model and how to avoid it.
- DynamoDB ProvisionedThroughputExceededException — what a full-table scan does to a provisioned table's capacity.
- DynamoDB ThrottlingException — the other throttle, and how exponential backoff handles it.