How DynamoDB Works: Partition Management, Request Routing, and Single-Digit Millisecond Reads at Any Scale
A deep dive into DynamoDB internals: consistent-hashing partitions, the request router layer, B-tree storage nodes, read/write capacity modes, single-table design, GSI/LSI tradeoffs, Streams, and transactions.
DynamoDB is easy to misread from the outside. The API is deliberately simple, the pricing is opaque, and the mental model it requires is different enough from relational databases that engineers often reach for it based on benchmarks alone, then hit a wall when their access patterns don’t match the data model. Understanding how DynamoDB actually works internally — how partitions are assigned, how reads are routed, how secondary indexes are maintained — gives you the clarity to make the right call on whether to use it and how to use it correctly when you do.
The Partition Layer
DynamoDB stores data in partitions. A partition is the fundamental unit of storage and throughput. Each partition is a B-tree structure hosted on a storage node, sized at approximately 10 GB of data capacity, with a throughput ceiling of 3,000 read capacity units (RCU) and 1,000 write capacity units (WCU) per second.
Partitions are assigned using consistent hashing. DynamoDB hashes the partition key of each item and maps that hash to a position on a ring. The hash function is MD5 applied to the partition key value. Items with the same partition key always land on the same partition. Items with different partition keys are distributed across the ring.
Partition Key Hash Ring (simplified)
hash(pk="user#A") = 0x1F3... --> Partition 1 [0x000 - 0x3FF]
hash(pk="user#B") = 0x7A2... --> Partition 3 [0x600 - 0x9FF]
hash(pk="user#C") = 0xC01... --> Partition 5 [0xA00 - 0xEFF]
When a table’s total data or provisioned throughput exceeds the per-partition limits, DynamoDB splits partitions automatically. A partition holding [0x000–0x3FF] splits into two partitions covering [0x000–0x1FF] and [0x200–0x3FF]. Once a partition splits it never merges back, even if the data or traffic drops. This is a critical detail for capacity planning covered later.
Replication and Durability
Each partition is replicated to three storage nodes in different availability zones. One node acts as the leader and processes all writes. The other two are replicas. Writes are acknowledged to the client after a durable write to the leader and propagation to at least one replica — this is the eventual consistency default for reads. Strongly consistent reads go to the leader and consume two RCUs instead of one.
The write path follows a write-ahead log (WAL) on the leader storage node. The leader appends the write to its WAL, applies it to the in-memory B-tree, and replicates asynchronously to follower nodes. Checkpoint operations periodically flush the B-tree pages to durable storage.
The Request Router
Clients never talk directly to storage nodes. Every DynamoDB request goes through a fleet of stateless request routers (called the “front-end” layer in Amazon’s published papers on Dynamo and the subsequent DynamoDB architecture).
The request router’s job is straightforward:
- Authenticate and authorize the request against IAM policies.
- Determine which partition owns the requested item by computing
MD5(partitionKey). - Consult the partition metadata service to find the current storage node for that partition.
- Forward the request to the correct storage node.
- Return the response.
Client
|
v
Request Router (stateless fleet, any AZ)
|
|-- MD5(partitionKey) --> Partition Map Lookup
| |
|<-- partition location -------+
|
v
Storage Node (leader for that partition)
|
v
B-Tree read/write, WAL append
The partition map is cached in memory by the request router. When a partition moves (due to a split or node replacement), the router receives an updated map. This is why a cold client or a partition split can occasionally see a short burst of latency as the router refreshes its cache.
Read and Write Capacity Modes
Provisioned Mode
In provisioned mode, you declare a fixed number of RCUs and WCUs for a table. One RCU allows one strongly consistent read (or two eventually consistent reads) of items up to 4 KB per second. One WCU allows one write of an item up to 1 KB per second.
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
const client = new DynamoDBClient({ region: "us-east-1" });
// Strongly consistent read: costs 1 RCU for items up to 4 KB
const response = await client.send(new GetItemCommand({
TableName: "orders",
Key: marshall({ pk: "user#123", sk: "order#456" }),
ConsistentRead: true, // reads from partition leader
}));
const item = response.Item ? unmarshall(response.Item) : null;
Provisioned throughput is enforced per partition. If you provision 1,000 WCUs on a table with 10 partitions, each partition gets 100 WCUs. If all writes land on one partition key, that partition throttles at 100 WCUs even though 900 WCUs sit idle on other partitions.
Adaptive Capacity
Adaptive capacity is DynamoDB’s mechanism for handling temporarily uneven traffic within the provisioned RCU/WCU total. If partition A is receiving more traffic than its allocated share while partition B is idle, DynamoDB allows partition A to “borrow” unused capacity from partition B up to its physical ceiling (3,000 RCU / 1,000 WCU per partition). Adaptive capacity responds within minutes, not seconds. It does not solve sustained hot partitions — it only smooths transient spikes.
On-Demand Mode
On-demand mode removes provisioned capacity management entirely. You pay per request consumed, and DynamoDB scales throughput automatically. Internally, on-demand mode still uses the same partition model but adjusts partition capacity limits dynamically. The tradeoff is cost: on-demand pricing is roughly 6x higher per request than equivalent provisioned capacity at sustained load.
A practical pattern is to start tables in on-demand mode during development and early production, then switch to provisioned with Auto Scaling once you have two weeks of CloudWatch data to establish a baseline.
Single-Table Design
DynamoDB encourages a pattern that surprises most engineers coming from relational databases: storing multiple entity types in a single table. The mechanism is overloaded partition and sort keys.
A sort key (range key) allows multiple items to share the same partition key, ordered lexicographically within that partition. This is what enables single-table design.
// Item shapes colocated on the same partition key
//
// pk="user#123", sk="PROFILE" -> user profile
// pk="user#123", sk="order#2026-01#001" -> one order
// pk="user#123", sk="order#2026-01#002" -> another order
// pk="user#123", sk="session#xyz" -> active session
// Fetch user profile (point read)
const profile = await client.send(new GetItemCommand({
TableName: "app",
Key: marshall({ pk: "user#123", sk: "PROFILE" }),
}));
// Fetch all orders for the user (range query on sort key prefix)
import { QueryCommand } from "@aws-sdk/client-dynamodb";
const orders = await client.send(new QueryCommand({
TableName: "app",
KeyConditionExpression: "pk = :pk AND begins_with(sk, :skPrefix)",
ExpressionAttributeValues: marshall({
":pk": "user#123",
":skPrefix": "order#",
}),
}));
Single-table design forces you to enumerate your access patterns before you write a line of code. The canonical advice is: write out every query your application needs, then design the partition key and sort key schema that satisfies all of them. Access patterns that cannot be satisfied with partition key and sort key alone require secondary indexes.
Secondary Indexes: GSI and LSI
Local Secondary Indexes
A Local Secondary Index (LSI) shares the same partition key as the base table but uses a different sort key. LSIs are defined at table creation time and cannot be added or removed later. They live on the same storage partitions as the base table data, which means reads against an LSI can be strongly consistent.
The critical constraint on LSIs is the item collection size limit: all items with the same partition key, across the base table and all LSIs combined, cannot exceed 10 GB. If you hit this limit, writes fail with an ItemCollectionSizeLimitExceededException. This is the main reason to avoid LSIs when a partition key value might accumulate a large number of items.
Global Secondary Indexes
A Global Secondary Index (GSI) can use any attribute as its partition key and any other attribute as its sort key. GSIs are eventually consistent with the base table — writes propagate to the GSI asynchronously, typically within milliseconds but not guaranteed. GSIs have their own provisioned throughput (or share on-demand mode with the table), their own partitions, and their own storage nodes.
// Base table: pk="product#abc", sk="v1"
// GSI "category-price-index": pk=category, sk=price
//
// Query: "find all electronics products priced 50-200"
const results = await client.send(new QueryCommand({
TableName: "catalog",
IndexName: "category-price-index",
KeyConditionExpression: "category = :cat AND price BETWEEN :lo AND :hi",
ExpressionAttributeValues: marshall({
":cat": "electronics",
":lo": 50,
":hi": 200,
}),
}));
GSI projections control which attributes are copied to the index storage. KEYS_ONLY projects only the base table keys. INCLUDE lets you specify a set of attributes. ALL copies every attribute, which doubles storage cost for the index. The right choice depends on whether you need to read base table attributes after the GSI query. If your query only needs indexed attributes, KEYS_ONLY or INCLUDE reduces cost and write amplification. If you always need the full item, project ALL to avoid a second read.
GSI throttling is one of the more common production failure modes. If a GSI partition key has uneven traffic and the GSI runs out of write capacity for that partition, writes to the base table succeed but GSI propagation falls behind. The table write itself is not throttled — but your index becomes stale and further writes to that GSI partition will eventually start being dropped if the backlog exceeds limits.
DynamoDB Streams
DynamoDB Streams is a change log for each table. When enabled, every write (put, update, delete) produces a stream record. Stream records are ordered within a partition and retained for 24 hours. Each stream record contains the old and/or new image of the item depending on the stream view type:
KEYS_ONLY: only the partition key and sort keyNEW_IMAGE: the item after the writeOLD_IMAGE: the item before the writeNEW_AND_OLD_IMAGES: both, useful for change data capture and auditing
import { DynamoDBStreamsClient, GetRecordsCommand } from "@aws-sdk/client-dynamodb-streams";
// Typical usage: Lambda function triggered by DynamoDB Streams
// Handler receives event.Records[]
export async function handler(event: { Records: DynamoDBStreamRecord[] }) {
for (const record of event.Records) {
if (record.eventName === "INSERT" || record.eventName === "MODIFY") {
const newItem = record.dynamodb?.NewImage
? unmarshall(record.dynamodb.NewImage as Record<string, AttributeValue>)
: null;
if (newItem) {
await syncToSearchIndex(newItem);
}
}
if (record.eventName === "REMOVE") {
const keys = record.dynamodb?.Keys
? unmarshall(record.dynamodb.Keys as Record<string, AttributeValue>)
: null;
if (keys) {
await removeFromSearchIndex(keys.pk as string);
}
}
}
}
Streams integrate with Lambda via event source mappings. Lambda polls the stream shards and batches records. The key production consideration is that stream shards are tied to table partitions. As your table splits into more partitions, the stream also gets more shards. Lambda concurrency scales accordingly, which can create write amplification downstream if you are not careful about the Lambda function’s behavior under fan-out.
Transactions
DynamoDB transactions allow atomic operations across up to 100 items in a single table or across multiple tables, with a total size limit of 4 MB per transaction. The two operations are TransactWriteItems (up to 100 put, update, delete, and condition-check operations) and TransactGetItems (up to 100 get operations).
Internally, DynamoDB transactions use a timestamp ordering protocol. Each transaction is assigned a timestamp at submission. Conflicting transactions (those touching the same items) are serialized using their timestamps. If a transaction cannot acquire the necessary locks within its window, it fails with a TransactionCanceledException and must be retried by the caller.
import { TransactWriteItemsCommand } from "@aws-sdk/client-dynamodb";
// Transfer credits atomically: debit account A, credit account B
// Use condition expressions to prevent negative balances
await client.send(new TransactWriteItemsCommand({
TransactItems: [
{
Update: {
TableName: "accounts",
Key: marshall({ pk: "account#A" }),
UpdateExpression: "SET balance = balance - :amount",
ConditionExpression: "balance >= :amount",
ExpressionAttributeValues: marshall({ ":amount": 100 }),
},
},
{
Update: {
TableName: "accounts",
Key: marshall({ pk: "account#B" }),
UpdateExpression: "SET balance = balance + :amount",
ExpressionAttributeValues: marshall({ ":amount": 100 }),
},
},
],
}));
Transactions cost twice the normal read or write capacity: DynamoDB performs a read phase before the write phase to detect conflicts. A TransactWriteItems of 5 one-KB items consumes 10 WCUs. At high throughput, this cost compounds quickly, and transactions on high-traffic partitions will conflict more often, increasing retry rates.
Tradeoffs Comparison
| Dimension | DynamoDB | PostgreSQL | MongoDB | Cassandra | CockroachDB |
|---|---|---|---|---|---|
| Data model | Key-value / document | Relational | Document | Wide-column | Relational (distributed) |
| Query flexibility | Access-pattern-driven | Arbitrary SQL | Flexible, index-driven | Access-pattern-driven | Arbitrary SQL |
| Horizontal scaling | Native, automatic | Manual sharding or partitioning | Auto-sharding (Atlas) | Native, linear | Native, automatic |
| Write latency at scale | Single-digit ms, predictable | Varies with load | Varies with load | Low, tunable consistency | Higher (Raft consensus overhead) |
| Consistency model | Eventually consistent by default, optional strong reads | Serializable by default | Configurable | Tunable (ONE to ALL) | Serializable by default |
| Transactions | Cross-item, limited to 100 items / 4 MB | Full ACID, no limits | Multi-document ACID | Lightweight transactions only | Full ACID, distributed |
| Operational complexity | Near-zero (fully managed) | High (index, vacuum, config) | Medium (managed or self-hosted) | High (topology, compaction) | Medium (managed available) |
| Schema flexibility | Schemaless (attribute-level) | Strict schema | Schemaless | Strict column families | Strict schema |
| Cost model | Per-request or provisioned | Compute + storage | Compute + storage | Compute + storage | Compute + storage |
| Secondary indexes | GSI (eventual) / LSI (strong) | Any column, multiple index types | Secondary indexes, text search | Materialized views | Secondary indexes |
| Joins | None (by design) | Native | $lookup (limited) | None | Native |
The pattern that emerges from this table: DynamoDB wins when you have well-defined, high-throughput access patterns and want zero operational overhead. It loses when your access patterns are exploratory, when you need complex joins, or when your data relationships are too dynamic to model upfront.
Production Considerations
Hot partition detection. Monitor ConsumedWriteCapacityUnits and SystemErrors per table with CloudWatch. If you see throttling (UserErrors with code ProvisionedThroughputExceededException) and your table-level capacity looks fine, you have a hot partition. The fix is not more throughput — it is redistribution. Common approaches: add a random suffix to the partition key (write sharding), introduce a fan-out write pattern, or redesign the key schema.
GSI throttling and write backlog. A GSI partition that is throttled will start dropping propagation quietly. Monitor OnlineIndexConsumedWriteCapacity for each GSI and ensure GSI capacity is proportional to base table write rate, especially if your GSI partition key is more concentrated than the base table partition key.
Item collection size limits. If you are using LSIs or if your table design has a partition key where one value can accumulate many items (user activity logs, per-customer events), you will eventually hit the 10 GB per-item-collection limit. There is no automatic resolution. Design partition keys with time-bucketing if the item count is unbounded: pk="user#123#2026-05" rather than pk="user#123".
Cost modeling for provisioned vs. on-demand. At stable, predictable load above roughly 1 million requests per day, provisioned with Auto Scaling almost always beats on-demand pricing. The break-even depends on your read/write ratio. Export CloudWatch Contributor Insights to identify which partition keys consume the most RCUs and WCUs — this data both informs provisioning and reveals hot key candidates before they become problems.
Partition pre-splitting with time-series workloads. If you are building a time-series table and you know you will write to it heavily from day one, consider using a partition key that spreads writes across multiple values from the start. Sequential or timestamp-based partition keys written monotonically will always write to the most recent partition, creating a hot spot. Distribute with a bucket suffix: pk="sensor#abc#" + (timestamp % NUM_BUCKETS), then scatter-gather across buckets on read.
Transaction conflict rates at scale. If your transaction cancel rate is above 1%, you have either a hot item or a poorly batched write pattern. Check whether multiple transactions are touching the same item keys concurrently. Exponential backoff with jitter on retry is not optional for production transaction code.
Understanding these failure modes before they occur is what separates a DynamoDB table that handles Black Friday traffic from one that quietly throttles at 3,000 requests per second on a single celebrity user. The storage model is a feature, not a limitation, if you design access patterns first.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.