← All posts

DynamoDB patterns for high-throughput workloads

DynamoDB will happily serve millions of requests per second — if you designed the table for the queries you actually run. Most of the painful DynamoDB stories we get called into start the same way: someone modeled entities the way they would in Postgres, shipped it, and met the throttling errors six months later under real traffic. These are the patterns that have held up for us in production.

Design keys around access patterns, not entities

The single biggest mindset shift: you do not model your data, you model your queries. Before writing a line of schema, list every read and write the application performs — "fetch all open orders for a customer, newest first", "get device state by device id", "count events per tenant per hour". Each of those maps to a partition key and, usually, a sort key range. If a query cannot be expressed as "one partition, one key condition", the design is not done. Entities that never get queried together do not belong in the same item collection, no matter how related they look on a whiteboard.

Single-table design is a tool, not a religion

Single-table design pays off when you need multiple related entity types in one request — a customer, their subscriptions, and their recent invoices in a single Query call, no joins, one round trip. That is a real win at high throughput. It is overkill when your service has three access patterns and two entity types. The overloaded-key gymnastics (PK=TENANT#123, SK=ORDER#2026-02-11#...) carry a permanent readability tax: every engineer who touches the table must hold the key schema in their head. For simple services we deliberately run two or three plain tables and sleep fine.

GSIs: every index is a shadow table you pay for

A global secondary index is a full copy of the projected attributes, with its own write capacity consumed on every mutation of an indexed item. Five GSIs on a write-heavy table means paying for six writes per put. Project KEYS_ONLY or a narrow attribute set unless you have measured that you need more. And watch GSI throttling: a hot GSI partition backpressures writes on the base table, which surprises people because the base table metrics look healthy.

Hot partitions and write sharding

A single partition key caps out around 1,000 WCU and 3,000 RCU regardless of what the table is provisioned for. Adaptive capacity helps with skew, but it does not repeal the per-partition limit. When one logical key must absorb more — a global counter, a celebrity tenant, a time-bucketed event stream — shard the key: append a suffix (EVENTS#2026-02-11#7, with the shard picked randomly or by hash) and fan reads back in across the shards. Sequential keys like timestamps as raw partition keys are the classic self-inflicted hot partition; bucket them or prefix them with something high-cardinality.

Capacity economics

On-demand is roughly 6-7x the per-request price of well-utilized provisioned capacity. The break-even is utilization: spiky, unpredictable, or low traffic — on-demand wins and removes an entire class of throttling incidents. Steady, forecastable load above roughly 15-20 percent average utilization — provisioned with auto scaling is meaningfully cheaper. In practice we start new workloads on on-demand, watch the consumption metrics for a few weeks, then switch the steady tables to provisioned. One caveat: on-demand is not infinitely elastic. It serves double your previous peak instantly, but a cold table hit with a 10x burst will still throttle until capacity scales out.

TTL and streams: the cheap parts people skip

  • TTL deletes are free — no WCU consumed. For session data, idempotency keys, and event logs it replaces an entire cleanup pipeline with one attribute. Deletion lags expiry by up to a couple of days, so filter expired items in queries if staleness matters.
  • Streams turn the table into an event source: aggregate counters, sync to OpenSearch, trigger downstream Lambdas. Combined with TTL, the stream even emits the expiry deletes, which makes a tidy "process then archive" pattern.
  • Streams give per-shard ordering and at-least-once delivery — make every consumer idempotent.

Failure modes we keep seeing

  • Burst throttling on provisioned tables: auto scaling reacts in minutes, traffic spikes in seconds. Retries with jitter mask it until they amplify it.
  • The Scan temptation: one "quick" analytics Scan can eat the read capacity of a production table. Export to S3 and query there instead.
  • Item-size creep: items that started at 2 KB grow denormalized blobs until every read costs multiple RCUs and every write fans out to all GSIs. Cap item size in code review, not in a postmortem.

None of this is exotic. It is the discipline of writing the access patterns down first, measuring capacity consumption honestly, and treating every index and every attribute as something you pay for on every write. Do that, and DynamoDB is the most boring component in the stack — which is exactly what you want at scale.

More posts Start a project