AWS Databases

DynamoDB Throttling & Hot Partitions: The Complete Troubleshooting Playbook

At 03:12 the pager fires: checkout writes are failing with ProvisionedThroughputExceededException. You open the DynamoDB console, look at the table’s ConsumedWriteCapacityUnits graph, and it is sitting at 40% of provisioned. The table is not out of capacity. Yet a specific slice of your traffic is being rejected, retried, backed-off, and eventually dropped, and orders are stalling. This is the single most misunderstood failure in Amazon DynamoDB, and the reason it is so maddening is that the number you instinctively check — table-level capacity — is the wrong number. DynamoDB does not throttle at the table; it throttles at the physical partition, and a table-wide average can hide one partition pinned at its hard ceiling.

This is the diagnostic playbook for that entire failure family. DynamoDB spreads your items across physical partitions by hashing the partition key, and every physical partition has a fixed ceiling — roughly 3,000 read capacity units (RCU) and 1,000 write capacity units (WCU) per second — regardless of how much capacity you provisioned for the whole table. Funnel traffic onto one key value (a hot key) or one narrow key range (a hot partition) and you hit that ceiling while the rest of the table idles. On top of this sit three mechanisms that everyone half-remembers and misapplies: adaptive capacity (which auto-isolates and boosts hot partitions but cannot exceed the partition max), burst capacity (banked unused throughput you can briefly spend), and the on-demand 2x rule (an on-demand table absorbs up to double your previous peak, then throttles a bigger spike). Get these wrong and you “fix” throttling by scaling the table up — spending money to make a problem that scaling cannot solve.

By the end you will stop guessing. You will read ThrottledRequests, ReadThrottleEvents/WriteThrottleEvents and Consumed-vs-Provisioned the way an ER doctor reads a monitor, use CloudWatch Contributor Insights to name the exact hot key instead of theorising, and apply the right fix for the right cause: write sharding, on-demand vs provisioned + auto-scaling, DAX caching, GSI capacity, and disciplined exponential backoff with jitter. The prose explains the mechanism; the tables — a ≥16-row symptom→cause→confirm→fix playbook, a capacity-math reference, an exception reference and a metric reference — are what you keep open at 03:12.

What problem this solves

Throttling is DynamoDB doing exactly what it promised: enforcing a per-partition throughput contract so one noisy key cannot starve the whole table or a neighbour’s table on shared infrastructure. The problem is that the symptom (ProvisionedThroughputExceededException, elevated latency, dropped writes) is generic, while the cause is specific and physical, and the two are separated by a layer — the partition — that the console does not show you by default. So teams misdiagnose. They see throttling, they see table capacity is not maxed, and they conclude “DynamoDB is flaky” or “we need to scale up.” Both conclusions are wrong and both are expensive.

What breaks without this knowledge: an on-call engineer doubles the table’s provisioned capacity (or flips to on-demand), the throttling on the hot key persists because the ceiling is per-partition not per-table, and now the bill is higher for no benefit. Or someone adds a Global Secondary Index to “speed up a query,” the GSI is under-provisioned, and suddenly base-table writes start throttling — because a throttled GSI applies backpressure to the writes that feed it — and nobody connects the new index to the write failures. Or a nightly Scan job quietly consumes the read capacity that the live API needs, and the API throttles at 09:00 every day with no code change to blame.

Who hits this: anyone running DynamoDB at real traffic. It bites hardest on time-series or sequential keys (today’s date, an auto-increment, a single “global counter” item), celebrity/hot-tenant workloads (one customer, one product, one game match with 100x the traffic of the median), write-heavy tables with GSIs, on-demand tables facing flash launches, and workloads that lean on Scan. The fix is almost never “add capacity” — it is “find the hot key and spread it, then read the metrics that prove you did.”

To frame the field before the deep dive, here is every throttle class this article covers, what it looks like, and the first place to look:

Throttle class What you see First question First place to look Most common single cause
Hot key / hot partition Throttle while table capacity looks idle Is one key taking most traffic? Contributor Insights → MostThrottledKeys A single partition-key value with 100x traffic
Adaptive-capacity ceiling Throttle persists after minutes on one key Is it one key or many? Contributor Insights + per-partition math Single key exceeds 1,000 WCU / 3,000 RCU
GSI write backpressure Base-table writes throttle Did you recently add/undersize a GSI? WriteThrottleEvents on the index dimension Under-provisioned or hot GSI
On-demand > 2x spike Throttle right after a traffic step-change Was there a flash > 2x previous peak? Throttle burst timing vs launch event Sudden spike beyond double previous peak
Provisioned under-capacity Sustained throttle, table Consumed = Provisioned Is Consumed pinned at the ceiling? Consumed vs Provisioned overlay Genuinely too little provisioned capacity
Auto-scaling lag Throttle for minutes, then self-heals Did a spike outrun scaling? Scaling activity + Consumed slope Auto-scaling reacts in minutes, spike is seconds
Scan starvation Read throttle correlated to a job Is a Scan running? ConsumedRead spike + CloudTrail Scan calls Full-table Scan eating shared RCU
Client-side (SDK) App logs throttle, table metrics clean Do table metrics even show it? App retry logs vs ThrottledRequests Retries exhausted before DynamoDB is at fault

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand DynamoDB basics: a table has a partition key (the hash key) and optional sort key (range key); items are addressed by that key; you read with GetItem/Query and write with PutItem/UpdateItem/DeleteItem. You should know the two capacity modesprovisioned (you set RCU/WCU, optionally with auto-scaling) and on-demand (pay-per-request, elastic). You should be comfortable running the aws CLI and reading JSON. If any of that is shaky, start with DynamoDB tables, keys and capacity modes.

This sits in the Databases / operations track. The design-time counterpart is DynamoDB single-table design and data modeling — write sharding and key design (the real cure for hot partitions) live there, and this article points back to it repeatedly. When the fix is a partition-count or account-level ceiling rather than key design, AWS service quotas and limit increases is the process to raise it. For the broader “is DynamoDB even the right store” question, see RDS vs DynamoDB vs Aurora compared.

A quick map of who owns what during a throttling incident, so you page the right person:

Layer What lives here Who usually owns it Throttle classes it causes
App / SDK Retry config, backoff, request shape App / dev team Client-side exhaustion, retry storms
Access pattern Query vs Scan, key selection App / data-modeling Scan starvation, hot-key selection
Key design Partition key cardinality, sharding Data modeling / architect Hot key, hot partition
Table capacity Provisioned/on-demand, auto-scaling Platform / owning team Under-capacity, scaling lag, 2x spike
Indexes GSI capacity, projection, key Data modeling GSI backpressure
Physical partitions The 3,000/1,000 ceilings AWS (managed) The hard limit everything else maps onto

Core concepts

Six mental models make every later diagnosis obvious.

DynamoDB throttles at the partition, not the table. When you write an item, DynamoDB hashes the partition key and routes the item to one physical partition. Table capacity is (roughly) the sum of what all partitions can do, but each individual partition has a fixed physical ceiling. If your traffic is uneven — one key hotter than the rest — you can pin one partition at its ceiling while the table average is low. The table-level graph is an average; the throttle is local.

A capacity unit is a rate against an item size. One WCU = one write per second of an item up to 1 KB; a 2.5 KB item costs 3 WCU (round up). One RCU = one strongly consistent read per second up to 4 KB; an eventually consistent read is half (0.5 RCU per 4 KB); a transactional read or write is double. Get the rounding or the consistency wrong and your real consumption is 2-4x your estimate — a common “why am I throttling?” that is pure arithmetic.

Adaptive capacity isolates and boosts — but is still capped at the partition max. DynamoDB continuously watches partition heat. It isolates frequently accessed items (splitting a hot partition so the hot key gets its own partition) and boosts a hot partition by lending it unused capacity from cold partitions. This is automatic and usually invisible — it absorbs moderate imbalance for free. What it cannot do is exceed the per-partition ceiling. If one key value needs more than 1,000 WCU or 3,000 RCU, adaptive capacity cannot save it; splitting does not help because you cannot split a single key across partitions. This is the number-one senior-level gotcha.

Burst capacity is a short-lived savings account. DynamoDB banks up to five minutes (300 seconds) of your unused throughput and lets you spend it in bursts above your provisioned rate. It smooths brief spikes — and it is why throttling sometimes appears only after a sustained load (you drained the bank). It is best-effort and not something to design around.

On-demand is elastic, but only up to double your recent peak. An on-demand table instantly serves up to twice the previous peak it has seen in the trailing window. Exceed that (a launch, a flash sale, a thundering-herd retry) and it throttles until it scales up over the next minutes. The fix is to pre-warm: ramp gradually (no more than doubling every ~30 minutes) or set warm throughput ahead of a known event.

The exception is a signal, not a failure — until retries run out. ProvisionedThroughputExceededException is DynamoDB saying “slow down.” The AWS SDKs treat it as retryable and automatically retry with exponential backoff and jitter. A well-behaved client rides through a brief throttle transparently; you only see errors when the throttle is sustained (real capacity problem) or your retry policy is wrong (too few retries, no jitter → synchronized retry storm).

The vocabulary in one table

Term One-line definition Where it lives Why it matters to throttling
Partition key Hash key that routes an item to a partition Table/item schema Low cardinality → hot partition
Physical partition The unit DynamoDB actually throttles Managed by AWS Hard 3,000 RCU / 1,000 WCU ceiling
RCU / WCU Read/write capacity unit (rate × size) Table/index capacity Wrong math = surprise throttle
Adaptive capacity Auto isolate + boost of hot partitions Managed Absorbs moderate skew; capped at part max
Burst capacity Up to 300s of banked unused throughput Managed Smooths brief spikes; then throttles
On-demand mode Pay-per-request, elastic capacity Table setting Throttles on > 2x previous-peak spikes
Provisioned mode You set RCU/WCU (+ auto-scaling) Table setting Throttles when Consumed = Provisioned
GSI Global Secondary Index (own partitions/capacity) On the table Throttled GSI throttles base writes
DAX In-memory write-through cache for DynamoDB Separate cluster Absorbs hot reads (not writes)
Write sharding Spreading a hot key across N suffixes Key design The real cure for a hot key
Contributor Insights CloudWatch feature naming hot/throttled keys CloudWatch Turns “some key” into the exact key
Hot key One partition-key value taking outsized traffic Access pattern Pins a single partition at its ceiling

The physics of a partition: 3,000 RCU / 1,000 WCU

Everything starts here. A DynamoDB table is stored as a set of physical partitions; each holds up to 10 GB of data and can serve up to 3,000 RCU and 1,000 WCU. DynamoDB decides how many partitions you have from two inputs: your provisioned throughput and your stored data size. It creates enough partitions to satisfy whichever is larger, then distributes items by hashing the partition key. Two consequences matter for throttling: (1) if all your traffic lands on one key, it lands on one partition, and (2) partitions, once split, are not merged back — so a table that was briefly scaled to huge capacity keeps its higher partition count.

The per-partition limits you cannot exceed

Dimension Limit (per physical partition) Scope Note / gotcha
Read throughput ~3,000 RCU/s One partition Hard ceiling; adaptive cannot exceed it
Write throughput ~1,000 WCU/s One partition Hard ceiling; a single hot key caps here
Storage 10 GB One partition Exceed → automatic split
Single partition-key value ~3,000 RCU / ~1,000 WCU One key value You cannot split one key across partitions
Item size 400 KB Per item Includes attribute names + values
Partition key length 2,048 bytes Per item Long keys waste space, not throughput
Sort key length 1,024 bytes Per item
Item collection (with LSI) 10 GB One partition-key value LSIs make a collection share one partition

The single most important row is “single partition-key value.” Adaptive capacity can isolate a hot key onto its own partition, but that partition is still one partition with a 1,000 WCU / 3,000 RCU cap. A key that legitimately needs 5,000 WCU cannot be served, full stop, without spreading it across multiple key values (sharding). No amount of table capacity, and no feature, changes that.

How partition count is derived

Driver Formula (approx) Example Result
By provisioned throughput ceil(RCU/3,000) and ceil(WCU/1,000) 9,000 RCU, 2,000 WCU ≥ 3 partitions (reads), ≥ 2 (writes)
By data size ceil(dataGB / 10) 45 GB ≥ 5 partitions
Effective count max(throughput-derived, size-derived) above max(3, 5) = 5 partitions
Per-partition share provisioned ÷ partitions 9,000 RCU ÷ 5 ~1,800 RCU each (if evenly used)

The “per-partition share” row is the trap: if capacity is divided across 5 partitions but your traffic hits 1, that 1 partition can burst on adaptive/burst capacity but ultimately caps at the physical ceiling, not at 9,000. Even distribution is an assumption, not a guarantee.

Capacity math: get the units right

Most “phantom” throttling is arithmetic. Round item size up to the unit boundary, then multiply by consistency and transaction factors.

Operation Consistency Cost formula Worked example
Read (GetItem/Query) Strongly consistent ceil(itemKB / 4) RCU 8 KB item → 2 RCU
Read Eventually consistent ceil(itemKB / 4) × 0.5 RCU 8 KB item → 1 RCU
Read Transactional (TransactGetItems) ceil(itemKB / 4) × 2 RCU 8 KB item → 4 RCU
Write (PutItem/Update/Delete) ceil(itemKB / 1) WCU 2.5 KB item → 3 WCU
Write Transactional (TransactWriteItems) ceil(itemKB / 1) × 2 WCU 2.5 KB item → 6 WCU
Query Eventually consistent (default) sum of matched items ÷ 4 KB × 0.5 Charged on items matched by key, then filtered
Scan Eventually consistent (default) sum of scanned items ÷ 4 KB × 0.5 Charged on items read before the filter
GSI write replication ceil(projectedItemKB / 1) WCU on the GSI Every base write that touches projected attrs
BatchWriteItem per-item WCU, no transaction discount Partial failures return UnprocessedItems

Two rows cause the most surprises. Query charges for every item the KeyConditionExpression matches, before a FilterExpression removes any — so filtering a large partition down to one row still costs the full read. Scan charges for every item it reads across the whole table, filter or not. If your consumed RCU is wildly larger than the rows you get back, you are almost always doing a Scan or filtering after a broad Query.

Read/write consistency and its capacity cost

Setting RCU multiplier Latency When to use Throttle implication
Eventually consistent read 0.5× Lowest Default; most reads Half the RCU → half the throttle risk
Strongly consistent read Low Read-after-write correctness Doubles RCU vs eventual; can’t use DAX item cache the same way
Transactional read Higher All-or-nothing multi-item reads 4× an eventual read; easy to under-provision
Transactional write Higher ACID multi-item writes 2× WCU; a hot transacted key throttles fast

How each API consumes capacity (and where it bites)

Different read APIs have very different throttle profiles even for the “same” data, because they differ in what they charge you for.

API Charged on Consistency options Throttle profile
GetItem The one item’s size Eventual or strong Cheapest; hot only if the item is hot
BatchGetItem Sum of returned items Eventual or strong Per-item; returns UnprocessedKeys on partial throttle
Query Items matched by key condition (pre-filter) Eventual (default) or strong Filtering a big partition still costs the full read
Scan Every item read (pre-filter), whole table/index Eventual (default) or strong Worst; scales with table size; starves live reads
TransactGetItems 2× the items’ size Serializable Doubles RCU; small transacted reads add up fast
PutItem/UpdateItem/DeleteItem The item’s size (rounded to 1 KB) Write ceiling is 1,000 WCU/partition
TransactWriteItems 2× the items’ size, ≤ 100 items ACID Doubles WCU; conflicts throw TransactionConflictException
BatchWriteItem Per item, ≤ 25 items, no txn Returns UnprocessedItems on partial throttle

Throughput-related quotas and defaults

Some throttling is not your key design at all — it is an account, table, or item quota. Know these numbers before you assume the problem is a hot key. When a genuine ceiling is the cause, raise it via AWS service quotas and limit increases.

Quota / limit Default (soft unless noted) Scope Throttle / error when hit
Per-partition read ~3,000 RCU (hard) Physical partition ProvisionedThroughputExceededException
Per-partition write ~1,000 WCU (hard) Physical partition ProvisionedThroughputExceededException
Per-table throughput High soft default (e.g., 40,000 RCU / 40,000 WCU) Table ThrottlingException / RequestLimitExceeded
Per-account throughput Soft, region-level Account/region RequestLimitExceeded
Item size 400 KB (hard) Per item ValidationException (item too large)
GSIs per table 20 (soft) Table LimitExceededException on create
LSIs per table 5 (hard) Table (at creation only) Create-time validation error
Projected attributes across all indexes 100 (hard) Table Create-time validation error
Concurrent control-plane ops Small (e.g., limited concurrent index creates) Account LimitExceededException
Item-collection size (with LSI) 10 GB (hard) Partition-key value ItemCollectionSizeLimitExceededException

Adaptive capacity, burst capacity, and their limits

These three mechanisms are the reason DynamoDB usually absorbs uneven traffic — and the reason engineers develop a false sense that it always will.

Adaptive capacity — what it does and what it can’t

Property Behaviour Limit / caveat
Isolate frequently accessed items Splits a hot partition so the hot key gets its own partition Cannot split a single key value across partitions
Boost Lends unused capacity from cold partitions to a hot one Still capped at 3,000 RCU / 1,000 WCU per partition
Activation Automatic, always on, no config Reacts in seconds-to-minutes, not instantly
Best case Absorbs moderate skew invisibly and for free A single scorching key still throttles
Observability Largely invisible; you infer it from throttle patterns No direct “adaptive is active” metric

Read the limit column twice. Adaptive capacity is brilliant at the moderate case: 20 partitions, one running 3x the others — it boosts and you never notice. It is powerless against the pathological case: one key that needs more than a whole partition can give. If Contributor Insights shows a single MostThrottledKeys entry dominating, adaptive capacity has already done all it can; the ball is in your court to shard.

Burst capacity

Property Value Practical effect
Banked window Up to 300 seconds (5 min) of unused throughput Short spikes ride over provisioned rate
Consumption Best-effort, spent automatically You cannot reserve or guarantee it
Depletion symptom Throttling appears after sustained above-rate load The bank drained; now you’re at the hard rate
Design guidance Do not architect around it Treat as a bonus, not capacity

Adaptive/burst vs the real fix — a decision table

If you see… It’s probably… Do this
Brief throttle that self-heals in seconds Burst bank drained then adaptive kicked in Nothing, or add small headroom
One key dominating MostThrottledKeys Single-key hot spot beyond a partition Write-shard the key (design fix)
Many keys throttling, table Consumed = Provisioned Genuine under-provisioning Raise capacity / enable auto-scaling / on-demand
Throttle only during a nightly job Scan or bulk job starving live traffic Rate-limit the job, run off-peak, use a GSI
Throttle right after a launch on on-demand > 2x previous-peak spike Pre-warm / gradual ramp / warm throughput

On-demand vs provisioned throttling

The two capacity modes throttle for different reasons, and confusing them sends you down the wrong fix.

The comparison that decides your mode

Dimension Provisioned (+ auto-scaling) On-demand
You set RCU/WCU targets (+ min/max/target%) Nothing — pay per request
Throttles when Consumed reaches Provisioned (per partition) Spike exceeds ~2x previous 30-min peak
Elastic Only as fast as auto-scaling reacts (minutes) Instantly up to 2x peak, then scales up
Cost model Cheapest for steady, predictable load Cheapest for spiky/unknown load; ~6-7x unit price
Pre-warm Set high min capacity before a launch Set warm throughput / ramp gradually
Best for Baseline production traffic you can forecast New tables, spiky, unpredictable, dev/test
Hot-key behaviour Still capped at partition max Still capped at partition max

The last row is the punchline: switching from provisioned to on-demand does not fix a hot key. On-demand removes the “I forgot to provision enough total capacity” class of throttling; it does nothing for the “all my traffic is on one key” class.

On-demand scaling behaviour

Scenario On-demand response Throttle? Fix
New table, first traffic Serves an initial baseline instantly Only if you exceed the baseline immediately Pre-warm before launch
Traffic ≤ 2x previous peak Absorbed instantly No
Sudden spike > 2x previous peak Scales up over minutes; excess throttles meanwhile Yes, briefly Ramp gradually, or set warm throughput
Steady growth Peak tracks up; headroom grows with you No
Retry storm after an outage Thundering herd can exceed 2x Yes Backoff + jitter; pre-warm before failover

Pre-warming, warm throughput and ramps

For a known event (a product launch, a migration cutover, a Black-Friday open), you must raise the ceiling before the traffic arrives.

Technique Mode How When
High minimum provisioned Provisioned Set auto-scaling min to your expected peak ahead of time Scheduled, predictable peaks
Scheduled scaling Provisioned Application Auto Scaling scheduled actions raise min at a time Recurring daily/weekly peaks
Warm throughput Both UpdateTable with a target read/write units the table can immediately serve Ahead of a one-off flash
Gradual ramp On-demand Drive synthetic traffic doubling every ~30 min up to target When you cannot set warm throughput
Max throughput cap On-demand Set MaxReadRequestUnits/MaxWriteRequestUnits to bound cost Guard against runaway spend, not against throttling

If pre-warming still leaves you against an account-level ceiling (per-table or per-account throughput default), that is a quota, not a design problem — raise it via AWS service quotas and limit increases well before the event, because throughput increases are not always instant.

GSI throttling and write backpressure

This is the nastiest cause because the failure surfaces somewhere other than where the problem is. A Global Secondary Index has its own partitions and its own capacity, independent of the base table. Every base-table write that changes a projected attribute is asynchronously replicated to the GSI, consuming GSI WCU. Here is the trap: if the GSI cannot keep up — under-provisioned, or itself hot on its index key — DynamoDB applies backpressure and throttles the base-table writes that feed it. Your base table has plenty of capacity; your writes fail anyway; and nothing in the base-table capacity graph explains it.

GSI mechanics and failure modes

Property Behaviour Throttle implication
Own capacity GSI has separate RCU/WCU (or shares on-demand) Under-provisioned GSI throttles independently
Write replication Base write → async GSI write of projected attrs GSI WCU consumed per relevant base write
Backpressure Throttled GSI throttles base-table writes Base writes fail though base capacity is fine
GSI hot key GSI partition key can be hotter than base An index key with low cardinality → GSI hot partition
Projection cost ALL projects every attribute → biggest GSI writes Narrow to KEYS_ONLY/INCLUDE to cut GSI WCU
Read isolation GSI reads consume GSI RCU, not base RCU A hot GSI read does not throttle base reads

Projection types and their write cost

Projection What’s copied to the GSI GSI write cost When to use
KEYS_ONLY Base + index keys only Lowest You only need keys, then GetItem on base
INCLUDE Keys + a named subset of attributes Medium Query needs a few specific attributes
ALL Every attribute Highest Query needs the full item; write-heavy = expensive

Choosing ALL on a write-heavy table is a classic self-inflicted throttle: every base write now writes a full second copy, doubling effective WCU and giving the GSI more chances to fall behind and backpressure the base. Narrow the projection to what the query actually reads.

GSI vs LSI (why LSIs have their own throttle shape)

Aspect GSI LSI (Local Secondary Index)
Partition key Any attribute (own partitions) Same as base (shares the partition)
Capacity Separate RCU/WCU Shares base-table capacity
Consistency Eventually consistent only Strong or eventual
Created Any time Only at table creation
Throttle shape Backpressure to base writes Counts against base capacity; 10 GB item-collection cap
Item-collection limit None 10 GB per partition-key valueItemCollectionSizeLimitExceededException

The exception and retry model

DynamoDB communicates throttling and other conditions through specific exceptions. Knowing which are retryable (let the SDK handle) versus terminal (fix the code) is half the battle.

Exception / error reference

Exception HTTP Meaning Retryable? Fix
ProvisionedThroughputExceededException 400 Request rate exceeded partition/table capacity Yes (SDK auto-retries) Reduce hot-key rate; shard; add capacity
ThrottlingException 400 Control-plane or account-level throttling Yes Back off; check account limits
RequestLimitExceeded 400 Account throughput ceiling reached Yes Raise account quota; spread load
TransactionConflictException 400 Concurrent write conflict on a transacted item Yes (with care) Reduce contention on the hot item
ItemCollectionSizeLimitExceededException 400 LSI item collection > 10 GB No Redesign; remove LSI; new partition key
ValidationException (item size) 400 Item exceeds 400 KB No Split item; store blob in S3 + pointer
ConditionalCheckFailedException 400 A condition expression was false No Expected; handle in app logic
InternalServerError 500 Transient server-side error Yes Retry with backoff
ServiceUnavailable 503 Service temporarily unavailable Yes Retry with backoff
LimitExceededException 400 Too many control-plane ops (e.g., tables, concurrent index creates) Yes (slowly) Serialise control-plane ops
ResourceInUseException 400 Table/index busy (e.g., still CREATING) Sometimes Wait for ACTIVE
UnrecognizedClientException / AccessDenied 400/403 Auth/permissions, not throttling No Fix IAM (don’t retry blindly)

A subtle but critical point: on an on-demand table, data-plane throttling still surfaces as ProvisionedThroughputExceededException (the name is historical). Do not assume “on-demand can’t throttle” because the exception mentions “Provisioned” — a > 2x spike throttles on-demand too, with this same exception.

SDK retry behaviour and backoff

Aspect Default behaviour Tune it when
Retry on throttle AWS SDKs auto-retry ProvisionedThroughputExceededException Almost never disable
Default max attempts ~3 (standard) / adaptive mode available Raise for bursty tolerant workloads; lower for latency-critical
Backoff Exponential with jitter Always keep jitter on to avoid retry storms
Adaptive retry mode Client-side rate limiting on top of backoff High-throttle environments
Batch APIs Return UnprocessedItems/UnprocessedKeys (not an exception) You must re-submit these yourself with backoff

The jitter point deserves emphasis. Without jitter, every throttled client retries at the same computed instant, producing a synchronized wave that re-throttles the table — a retry storm. “Full jitter” — sleep = random(0, min(cap, base × 2^attempt)) — de-synchronizes clients and is what the SDKs do by default. If you write your own retry loop, replicate it exactly.

Backoff math (base = 50 ms, cap = 20 s)

Attempt Exponential ceiling With full jitter, actual sleep is…
1 100 ms random(0, 100 ms)
2 200 ms random(0, 200 ms)
3 400 ms random(0, 400 ms)
4 800 ms random(0, 800 ms)
5 1,600 ms random(0, 1,600 ms)
n min(cap, 50 ms × 2^n) random(0, that ceiling)

The metrics that tell the truth

You cannot fix what you cannot see, and the DynamoDB console’s headline graph (table-level Consumed) is the least useful view for hot-partition throttling. These are the metrics to actually read.

CloudWatch metric reference

Metric What it tells you Read it against Throttle signal
ConsumedReadCapacityUnits Actual RCU used ProvisionedReadCapacityUnits Pinned at provisioned = under-capacity
ConsumedWriteCapacityUnits Actual WCU used ProvisionedWriteCapacityUnits Below provisioned but throttling = hot key
ProvisionedRead/WriteCapacityUnits Your configured ceiling Consumed The overlay that reveals the gap
ReadThrottleEvents Count of throttled read requests Per table or per index > 0 = read throttling somewhere
WriteThrottleEvents Count of throttled write requests Per table or per index > 0 on index dim = GSI backpressure
ThrottledRequests Requests throttled (by operation) Operation dimension The top-line “are we throttling?”
OnlineIndexConsumedWriteCapacityUnits WCU used backfilling a new GSI During index creation Backfill competing with live writes
SuccessfulRequestLatency p50/p99 latency by operation Baseline Latency creep before hard throttle
SystemErrors 5xx server errors Baseline Transient; retryable
UserErrors 4xx client errors (excl. throttle) Baseline Validation/auth, not capacity
ConditionalCheckFailedRequests Failed condition expressions Baseline Contention, not throttle
AccountMaxReads / AccountMaxTableLevelReads Account/table throughput ceilings Consumed Approaching an account quota

The one row to internalise: ConsumedWriteCapacityUnits below Provisioned while WriteThrottleEvents > 0 is the signature of a hot partition. The table has capacity; a partition does not.

Contributor Insights — naming the hot key

CloudWatch Contributor Insights for DynamoDB builds rules over the request stream and surfaces the top keys. This is how you go from “some key is hot” to “key TENANT#42 is hot.”

Rule What it ranks Use it to
MostAccessedKeys (partition) Partition-key values by request count Find the hot key
MostAccessedKeys (partition+sort) Full key by request count Find the hot item
MostThrottledKeys (partition) Partition-key values by throttle count Prove which key is being throttled
MostThrottledKeys (partition+sort) Full key by throttle count Pinpoint the throttled item

Enable it per table (and optionally per index) with update-contributor-insights. It costs per event monitored, so enable it while diagnosing and consider disabling on very high-traffic tables when you are done.

Metric → diagnosis decision table

If the metrics show… Diagnosis Confirm with Fix
Consumed < Provisioned, ThrottleEvents > 0 Hot partition / hot key Contributor Insights MostThrottledKeys Shard the key
Consumed = Provisioned, throttle sustained Under-provisioned Consumed overlay pinned Raise capacity / auto-scale / on-demand
WriteThrottleEvents on index dimension GSI backpressure Index-scoped metric Raise GSI capacity, narrow projection
ConsumedRead spike, few rows returned Scan starvation CloudTrail Scan events + --return-consumed-capacity Rate-limit / Query / off-peak
Throttle burst after a launch (on-demand) > 2x peak spike Timing vs event Pre-warm / gradual ramp
App logs throttle, table metrics clean Client-side / retries exhausted Compare app logs to ThrottledRequests Fix retry config / backoff

Architecture at a glance

The diagram traces one request from the application SDK, through table-level capacity and adaptive capacity, down onto the physical partitions where throttling actually happens, across to the GSI (whose backpressure reaches back to base writes), and finally into the observability plane where CloudWatch and Contributor Insights let you name the cause. Read it left to right: the CLIENT issues PutItem/Query with backoff; TABLE CAPACITY routes by hashing the key; the PHYSICAL PARTITIONS panel shows the whole story — P1 is pinned at its 1,000 WCU ceiling and throttling while P2 and P3 sit idle, which is exactly why the table average looks healthy; INDEX + MITIGATE shows GSI backpressure alongside the two real fixes (write sharding and DAX); and OBSERVE shows the metrics and Contributor Insights that close the loop back to the client’s backoff-and-reshard behaviour. Each numbered badge marks where a throttle class bites, and the legend narrates each as symptom · confirm · fix.

DynamoDB throttling and hot-partition architecture: a request flows from the application SDK through table-level and adaptive capacity onto three physical partitions where P1 is pinned at its 1,000 WCU ceiling and throttling while P2 and P3 idle, then across to a GSI whose backpressure throttles base writes, with write sharding and DAX as mitigations, and CloudWatch metrics plus Contributor Insights MostThrottledKeys in the observability plane; six numbered badges mark hot key, adaptive-capacity ceiling, GSI backpressure, on-demand 2x spike, Scan starvation, and metric-driven diagnosis.

Real-world scenario

Northwind Live runs a multi-tenant order-management SaaS on a single DynamoDB table, orders-prod, using single-table design: partition key TENANT#<id>, sort key ORDER#<ts>. The table is provisioned at 8,000 WCU with auto-scaling, comfortably above their average of ~4,500 WCU. For eighteen months, no throttling.

Then they onboard Meridian Retail, a customer 30x larger than their median tenant. Meridian’s Friday batch pushes ~6,000 orders/second — all under partition key TENANT#MERIDIAN. At 17:00 the first Friday, checkout starts failing. On-call opens the console: table ConsumedWriteCapacityUnits is ~5,200 against 8,000 provisioned. The table has 2,800 WCU of headroom. Yet WriteThrottleEvents is spiking and the app is throwing ProvisionedThroughputExceededException. On-call, seeing headroom, assumes a transient blip and raises provisioned to 12,000 WCU. No change. They raise it to 20,000. Still throttling. The bill is now climbing and orders are still failing.

The breakthrough comes from Contributor Insights, which someone enables at 17:40. MostThrottledKeys shows a single dominating entry: TENANT#MERIDIAN, taking ~99% of the throttles. The physics clicks: Meridian’s 6,000 WCU of writes all hash to one partition, which caps at 1,000 WCU. Adaptive capacity had already isolated TENANT#MERIDIAN onto its own partition and boosted it — and it still can’t exceed 1,000 WCU. No table-level number will ever fix this, because the ceiling is per-partition and the traffic is one key.

The real fix is write sharding, borrowed from their single-table design playbook. They change large-tenant writes to use a partition key of TENANT#MERIDIAN#<shard> where <shard> is 0..15 chosen by hash(orderId) % 16. Now Meridian’s 6,000 WCU fans across 16 partitions — ~375 WCU each, comfortably under the 1,000 ceiling. Reads that previously did one Query on TENANT#MERIDIAN become a scatter-gather: 16 parallel Queries, one per shard, merged in the app. They drop provisioned capacity back to 8,000 WCU (the sharding, not the extra capacity, was the cure) and add a CloudWatch alarm on WriteThrottleEvents > 0 for 1 minute so the next hot tenant is caught in seconds, not after a scaling spiral. Post-incident, they codify a rule: any tenant projected above ~800 WCU is provisioned onto a sharded key from day one. Cost went down (they reverted the panic scaling), reliability went up, and the runbook now starts with “enable Contributor Insights, read MostThrottledKeys” instead of “raise capacity.”

Advantages and disadvantages

The trade-off table below is really about the fixes, because throttling itself is not optional — it is the enforcement of a contract. The question is which mitigation you reach for.

Approach Advantages Disadvantages
Write sharding The only real cure for a hot key; unbounded write scale Reads become scatter-gather (N queries); app complexity
On-demand mode No capacity planning; absorbs spiky load instantly (≤2x) ~6-7x unit price; still throttles > 2x spikes and hot keys
Provisioned + auto-scaling Cheapest for steady load; predictable cost Scales in minutes; a fast spike outruns it; still per-partition capped
DAX Microsecond reads; absorbs hot read keys; cuts read RCU Reads only (not writes); eventual consistency; extra cost + VPC
Exponential backoff + jitter Rides through brief throttles transparently; free Adds latency; masks a real capacity problem if over-tuned
Raise capacity Fixes genuine under-provisioning fast Does nothing for hot keys or GSI backpressure; costs money

When each matters: reach for sharding whenever one key legitimately exceeds a partition’s ceiling (there is no substitute). Use DAX when the hot spot is reads of a small set of popular items (a product catalog, a leaderboard read path). Use on-demand when traffic is genuinely unpredictable and you cannot forecast; use provisioned + auto-scaling when you can. Keep backoff + jitter always on. Reach for raise capacity only after the metrics prove the table (not a partition) is the bottleneck.

Hands-on lab

You will provoke throttling with a deliberately hot key on a low-capacity provisioned table (free-tier-friendly), confirm it via WriteThrottleEvents and Contributor Insights, then fix it with write sharding and disciplined backoff. Everything runs from your shell with the aws CLI, plus a tiny Python load generator (boto3). Region is us-east-1; adjust as needed.

⚠️ Cost note: a small provisioned table (5 WCU / 5 RCU) is within the DynamoDB free tier (25 WCU + 25 RCU perpetual free). Contributor Insights and CloudWatch alarms cost a few cents while enabled. The optional DAX step creates a cluster that costs money per hour (no free tier) — only run it if you want to see caching, and delete it immediately after.

Step 1 — Create a deliberately low-capacity table

Provisioned at 5 WCU so a single hot key throttles almost instantly (this makes the hot-key signature easy to see even on a small table).

aws dynamodb create-table \
  --table-name throttle-lab \
  --attribute-definitions AttributeName=pk,AttributeType=S \
  --key-schema AttributeName=pk,KeyType=HASH \
  --billing-mode PROVISIONED \
  --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \
  --region us-east-1

aws dynamodb wait table-exists --table-name throttle-lab --region us-east-1
echo "table ACTIVE"

Expected: after ~10-20s, table ACTIVE.

The Terraform equivalent (for the record — do not apply both):

resource "aws_dynamodb_table" "throttle_lab" {
  name         = "throttle-lab"
  billing_mode = "PROVISIONED"
  read_capacity  = 5
  write_capacity = 5
  hash_key     = "pk"

  attribute {
    name = "pk"
    type = "S"
  }
}

Step 2 — Enable Contributor Insights

So you can name the hot key later.

aws dynamodb update-contributor-insights \
  --table-name throttle-lab \
  --contributor-insights-action ENABLE \
  --region us-east-1

Expected: JSON echoing "ContributorInsightsStatus": "ENABLING". It becomes ENABLED within a minute.

Step 3 — Provoke throttling with a hot key

This script hammers a single partition-key value (HOTKEY) far above 5 WCU, forcing ProvisionedThroughputExceededException and WriteThrottleEvents.

# hot.py — pip install boto3
import boto3, time, uuid
from botocore.config import Config
from concurrent.futures import ThreadPoolExecutor

# Standard retry mode WITH jitter (SDK default), 3 attempts — realistic client.
ddb = boto3.client("dynamodb", region_name="us-east-1",
                   config=Config(retries={"max_attempts": 3, "mode": "standard"}))

throttled = 0
def put(_):
    global throttled
    try:
        ddb.put_item(TableName="throttle-lab",
                     Item={"pk": {"S": "HOTKEY"},          # <-- the hot key
                           "sk": {"S": str(uuid.uuid4())},
                           "payload": {"S": "x" * 500}})
    except ddb.exceptions.ProvisionedThroughputExceededException:
        throttled += 1

end = time.time() + 60
with ThreadPoolExecutor(max_workers=25) as ex:
    while time.time() < end:
        list(ex.map(put, range(200)))
print("throttled (after SDK retries):", throttled)
python hot.py

Expected: after 60 seconds, a nonzero throttled count — writes that failed even after the SDK’s exponential backoff retried them. That is a real, sustained throttle, not a blip.

Step 4 — Confirm via CloudWatch metrics

aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB \
  --metric-name WriteThrottleEvents \
  --dimensions Name=TableName,Value=throttle-lab \
  --start-time "$(date -u -v-10M +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 60 --statistics Sum \
  --region us-east-1

Expected: Datapoints with Sum > 0 for the minutes you ran the load. Now overlay consumed vs provisioned:

aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB --metric-name ConsumedWriteCapacityUnits \
  --dimensions Name=TableName,Value=throttle-lab \
  --start-time "$(date -u -v-10M +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 60 --statistics Sum --region us-east-1

Divide Sum by the period (60) to get per-second WCU; it will hover near your 5 WCU ceiling — the table is at its (tiny) provisioned limit, and the throttle is real.

Step 5 — Confirm the exact hot key in Contributor Insights

In the console: DynamoDB → Tables → throttle-lab → Monitor → Contributor Insights, rule Most throttled items / keys. You will see HOTKEY dominating. (Contributor Insights writes CloudWatch rules; give it a few minutes of traffic to populate.)

Verification Expected result What it proves
throttled count from hot.py > 0 Throttle survived SDK retries (sustained)
WriteThrottleEvents Sum > 0 DynamoDB rejected writes
MostThrottledKeys top entry HOTKEY The hot key is named, not guessed

Step 6 — Fix 1: write sharding

Spread the hot key across 10 suffixes so writes fan across partitions. On a 5-WCU toy table you won’t see multi-partition scaling, but the code is the exact pattern you deploy in production (where it turns one 6,000-WCU partition into sixteen 375-WCU partitions).

# shard.py
import boto3, uuid, random
ddb = boto3.client("dynamodb", region_name="us-east-1")
SHARDS = 10

def put_sharded():
    shard = random.randint(0, SHARDS - 1)          # random write shard
    ddb.put_item(TableName="throttle-lab",
                 Item={"pk": {"S": f"HOTKEY#{shard}"},   # <-- fan-out key
                       "sk": {"S": str(uuid.uuid4())},
                       "payload": {"S": "x" * 500}})

# Read path becomes scatter-gather across all shards:
def query_all():
    items = []
    for s in range(SHARDS):
        r = ddb.query(TableName="throttle-lab",
                      KeyConditionExpression="pk = :p",
                      ExpressionAttributeValues={":p": {"S": f"HOTKEY#{s}"}})
        items += r["Items"]
    return items

The design lesson: writes get cheap (fan-out), reads get more expensive (scatter-gather across N shards). Choose N to cover your peak per-key WCU divided by ~800 (leaving headroom under 1,000). Deterministic sharding (e.g., hash(orderId) % N) lets you read a specific item without scatter-gather; random sharding maximizes write spread but forces scatter-gather for every read. The full treatment is in DynamoDB single-table design and data modeling.

Step 7 (optional, costs money) — Fix 2: DAX for hot reads

If your hot spot were reads of popular items, a DAX cluster absorbs them in-memory. Sketch only (delete promptly):

resource "aws_dax_cluster" "cache" {
  cluster_name       = "throttle-lab-dax"
  iam_role_arn       = aws_iam_role.dax.arn
  node_type          = "dax.t3.small"   # smallest; still billed hourly
  replication_factor = 1
  subnet_group_name  = aws_dax_subnet_group.this.name
}

Your app points the DAX client at the cluster endpoint; cache hits never touch the table, so hot-read RCU collapses. DAX is write-through and read caches are eventually consistent — it does nothing for write throttling.

Step 8 — Add a throttle alarm (so next time you’re paged in seconds)

aws cloudwatch put-metric-alarm \
  --alarm-name ddb-throttle-lab-writes \
  --namespace AWS/DynamoDB --metric-name WriteThrottleEvents \
  --dimensions Name=TableName,Value=throttle-lab \
  --statistic Sum --period 60 --evaluation-periods 1 \
  --threshold 0 --comparison-operator GreaterThanThreshold \
  --treat-missing-data notBreaching \
  --region us-east-1
resource "aws_cloudwatch_metric_alarm" "ddb_write_throttle" {
  alarm_name          = "ddb-throttle-lab-writes"
  namespace           = "AWS/DynamoDB"
  metric_name         = "WriteThrottleEvents"
  dimensions          = { TableName = "throttle-lab" }
  statistic           = "Sum"
  period              = 60
  evaluation_periods  = 1
  threshold           = 0
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"
}

Step 9 — Teardown

aws cloudwatch delete-alarms --alarm-names ddb-throttle-lab-writes --region us-east-1
aws dynamodb update-contributor-insights --table-name throttle-lab \
  --contributor-insights-action DISABLE --region us-east-1
# If you created DAX (Step 7), delete it FIRST — it bills hourly:
# aws dax delete-cluster --cluster-name throttle-lab-dax --region us-east-1
aws dynamodb delete-table --table-name throttle-lab --region us-east-1
echo "cleaned up"

Expected: alarm deleted, Contributor Insights disabling, table deleting. Confirm with aws dynamodb list-tables --region us-east-1 (no throttle-lab).

Common mistakes & troubleshooting

This is the heart of the article. Read the prose once, then keep the playbook open during an incident. The single most important habit: when you see throttling, do not look at table-level Consumed first — enable Contributor Insights and read MostThrottledKeys. The table average lies; the key list does not.

The playbook

# Symptom Root cause Confirm (exact command / console path) Fix
1 ProvisionedThroughputExceededException, table Consumed ~40% Hot key: one partition-key value on one partition at its 1,000 WCU / 3,000 RCU cap Contributor Insights → MostThrottledKeys shows one dominant key; WriteThrottleEvents>0 while ConsumedWCU<Provisioned Write-shard the key (KEY#0..#N); redesign for higher cardinality
2 Throttle persists after minutes on the same single key Adaptive capacity already isolated + boosted the key; still capped at partition max One key in MostThrottledKeys; per-partition math (needed WCU > 1,000) Shard; do not wait for adaptive; do not raise table capacity
3 Base-table writes throttle; base capacity looks fine GSI backpressure — under-provisioned or hot GSI throttles base writes WriteThrottleEvents with the GSI/index dimension > 0 Raise GSI capacity (or on-demand); narrow projection to KEYS_ONLY/INCLUDE
4 Throttle burst right after a launch on an on-demand table Spike exceeded ~2x previous 30-min peak before the table scaled Throttle timing aligns with launch; on-demand table Pre-warm: gradual ramp / set warm throughput ahead of the event
5 Sustained throttle, Consumed pinned at Provisioned Genuine under-provisioning of the whole table ConsumedWCU == ProvisionedWCU on the overlay Raise provisioned capacity / enable auto-scaling / switch to on-demand
6 Throttle for a few minutes then self-heals Auto-scaling lag — a spike outran the (minutes-scale) scaling reaction Scaling activity history shows a delayed scale-up Higher min capacity; scheduled scaling; on-demand for spiky load
7 Read throttle every night at the same time A Scan / bulk export eats shared RCU CloudTrail shows Scan; --return-consumed-capacity TOTAL RCU ≫ rows Rate-limit the Scan; run off-peak; use a GSI; add Limit + pagination
8 RCU far exceeds rows returned on a Query FilterExpression after a broad KeyCondition — you pay for matched-then-filtered items Compare ScannedCount vs Count; --return-consumed-capacity TOTAL Tighten the key condition; add a GSI so the selection is by key
9 App logs throttling; DynamoDB metrics are clean Client-side: retries exhausted, or a client-side rate limiter App retry logs vs ThrottledRequests (near zero) Fix SDK retry/backoff config; raise max attempts; add jitter
10 Retry storm — throttling gets worse under load No jitter: all clients retry in sync, re-throttling the table Bursty, periodic throttle spikes aligned to retry intervals Use full-jitter backoff (SDK default); adaptive retry mode
11 ValidationException: Item size has exceeded the maximum Item > 400 KB (often an unbounded appended array) Item size trend; the exact exception string Split into multiple items (adjacency list); store blobs in S3 + pointer
12 ItemCollectionSizeLimitExceededException on writes An LSI item collection (one partition-key value) exceeded 10 GB Exception on write to a table with an LSI Redesign key; remove LSI; move to a GSI
13 TransactionConflictException spikes on one item Write contention on a single transacted item (a counter) Metric TransactionConflict; MostAccessedKeys on that item Shard the counter; reduce transaction scope; optimistic retries
14 New GSI added, live writes start throttling Backfill (OnlineIndexConsumedWriteCapacityUnits) competes + GSI under-provisioned That metric > 0 during index CREATING Create indexes off-peak; provision GSI generously; throttle backfill
15 On-demand table throttles though “on-demand can’t throttle” > 2x spike or a hot key — on-demand is not immune to either ProvisionedThroughputExceededException on an on-demand table Pre-warm for spikes; shard for hot keys
16 BatchWriteItem silently loses writes UnprocessedItems returned (partial throttle) but never re-submitted Inspect the API response for UnprocessedItems Loop re-submitting UnprocessedItems with backoff
17 Reads throttle only for strongly-consistent calls Strong reads cost 2x eventual RCU; you sized for eventual Compare consistency flag; RCU math Use eventual consistency where correctness allows; add DAX for hot reads
18 Throttle after a big provisioned scale-down Fewer partitions after a manual/scheduled scale-in concentrate traffic Recent capacity decrease; partition math Scale down gradually; keep headroom; watch throttle after scale-in
19 Global Tables replica region throttles Replica writes (replication traffic) exceed replica capacity WriteThrottleEvents in the replica region Provision replica capacity to match; on-demand on all replicas
20 Everything throttles, all keys, suddenly Account/region throughput quota reached RequestLimitExceeded; AccountMaxReads/AccountMaxTableLevelReads near ceiling Raise the account quota (Service Quotas); spread load across tables

The three nastiest, in prose

1. The hot key that adaptive capacity cannot save (rows 1-2). This is the incident that burns hours because the instinct — “throttling + headroom = raise capacity” — is exactly wrong. Traffic concentrated on one partition-key value lands on one physical partition, capped at 1,000 WCU / 3,000 RCU, no matter how much you provision for the table. Adaptive capacity will have already isolated that key onto its own partition and boosted it with cold-partition capacity, and it still can’t exceed the per-partition ceiling, because you cannot split a single key value across partitions. The tell is Contributor Insights MostThrottledKeys showing one dominant entry while table ConsumedWCU sits below Provisioned. The only fix is to change the key so the traffic spreads: write sharding (KEY#0..#N), or a genuinely higher-cardinality key. Raising capacity, switching to on-demand, and waiting for adaptive all fail identically. Internalise the per-partition math: if one key needs more than 1,000 WCU, it needs more than one partition, which means more than one key value.

2. GSI backpressure — the throttle that surfaces on the wrong table (rows 3, 14). A Global Secondary Index has its own capacity and its own partitions. When it can’t keep up with the writes replicating into it — under-provisioned, or hot on its own index key — DynamoDB throttles the base-table writes that feed it. So you see write failures on orders-prod, you check orders-prod capacity, it’s fine, and you’re stuck — because the bottleneck is an index you may have forgotten you added. The confirmation is precise: pull WriteThrottleEvents scoped to the index dimension (or GlobalSecondaryIndexName), and you’ll see the throttle living on the GSI, not the table. Two things make it worse: a Projection of ALL on a write-heavy table (every write copies the whole item into the GSI, doubling effective WCU), and creating the index during peak (the backfill, visible as OnlineIndexConsumedWriteCapacityUnits, competes with live writes). Fix by provisioning the GSI to match base write rate (or putting the whole table on-demand so both scale together), narrowing the projection to KEYS_ONLY/INCLUDE, and only creating indexes off-peak.

3. The on-demand > 2x spike (rows 4, 15). On-demand feels like infinite capacity, so teams assume it can’t throttle. It can. An on-demand table serves up to double its previous 30-minute peak instantly; a launch, a flash sale, or a thundering-herd retry after an outage can blow past 2x before the table finishes scaling, and those excess requests throttle with the same ProvisionedThroughputExceededException (the name is historical, not a mode indicator). The confirmation is timing: a throttle burst that coincides with a step-change in traffic on an on-demand table. The fix is to raise the ceiling before the traffic: set warm throughput ahead of a known event, or ramp synthetic traffic up gradually (no more than doubling every ~30 minutes) so the previous-peak the table remembers is already high when real users arrive. For predictable recurring peaks, provisioned capacity with scheduled scaling can actually be steadier than on-demand, because you set the floor explicitly rather than relying on the 2x memory.

Best practices

Security notes

Cost & sizing

Throttling and cost are two sides of the same capacity coin: under-provision and you throttle, over-provision (or panic-scale) and you overpay. Sizing correctly is the fix for both.

What drives the bill

Cost driver How it’s charged Throttle link
Provisioned RCU/WCU Per unit-hour, whether used or not Too low → throttle; too high → waste
On-demand requests Per million read/write request units Spiky load; ~6-7x provisioned unit price
GSI capacity Same RCU/WCU pricing, separately Under-sized GSI → base-write backpressure
Storage Per GB-month More data → more partitions (can help spread)
DAX Per node-hour (no free tier) Cuts hot-read RCU but adds fixed cost
Contributor Insights Per event monitored Cheap while diagnosing; disable at scale
Streams / backups / Global Tables Separate line items Replica capacity must match to avoid replica throttle

On-demand vs provisioned — rough break-even

Load shape Cheaper mode Reasoning
Steady, predictable, high utilization Provisioned + auto-scaling You pay for what you continuously use
Spiky, unpredictable, low average On-demand No idle capacity; pay per request
New table, unknown pattern On-demand first Learn the pattern, then consider provisioned
Dev/test, intermittent On-demand Near-zero when idle
Rule of thumb Provisioned wins above ~15-20% sustained utilization On-demand’s per-request premium overtakes idle provisioned cost

Free tier and rough figures (us-east-1, indicative)

Item Free tier Rough cost beyond free tier
Provisioned capacity 25 WCU + 25 RCU, always free ~$0.00013/WCU-hr, ~$0.00013/RCU-hr
Storage 25 GB always free ~$0.25/GB-month
On-demand writes ~$1.25 per million WRUs (~₹105)
On-demand reads ~$0.25 per million RRUs (~₹21)
DAX (dax.t3.small) none ~$0.04-0.05/node-hour → ~₹2,500-3,000/month if left on
Contributor Insights none ~$0.90 per million events monitored

The single biggest cost mistake in a throttling incident is panic-scaling capacity to “fix” a hot key — it doesn’t work and it’s expensive. Confirm the cause first; sharding a hot key often lets you lower capacity afterward, as Northwind Live did.

Interview & exam questions

Q1. Why does a DynamoDB table throttle when its table-level consumed capacity is well below provisioned? Because DynamoDB enforces capacity at the physical partition, not the table. Traffic concentrated on one partition-key value hits one partition’s ~1,000 WCU / ~3,000 RCU ceiling while other partitions idle, so the table average looks healthy. (SAA-C03, DVA-C02)

Q2. What are the per-partition throughput limits, and can adaptive capacity exceed them? Roughly 3,000 RCU and 1,000 WCU per physical partition. Adaptive capacity isolates and boosts hot partitions but cannot exceed the per-partition maximum, so a single hot key beyond that ceiling still throttles.

Q3. How does adaptive capacity actually help, and where does it stop? It automatically isolates frequently accessed items (splitting them onto their own partition) and boosts a hot partition with unused capacity from cold ones. It stops at the per-partition ceiling and cannot split a single key value across partitions — the imbalance persists for a truly hot key.

Q4. Explain GSI write backpressure. A GSI has its own capacity; base writes replicate into it. If the GSI is under-provisioned or hot, DynamoDB throttles the base-table writes that feed it — so base writes fail even though base capacity is fine. Confirm via WriteThrottleEvents on the index dimension; fix with GSI capacity and a narrower projection.

Q5. Why can an on-demand table still throttle? On-demand serves up to 2x the previous 30-minute peak instantly; a sudden spike beyond that throttles until it scales. It also cannot beat a hot-key partition ceiling. Pre-warm (warm throughput / gradual ramp) for spikes; shard for hot keys.

Q6. What is burst capacity and why does throttling sometimes appear only after sustained load? DynamoDB banks up to 300 seconds of unused throughput to spend on brief spikes. Under sustained above-rate load you drain the bank, then throttle at the hard rate — so the throttle can appear a few minutes in.

Q7. How do you find which key is hot? Enable CloudWatch Contributor Insights for the table (and index) and read the MostAccessedKeys / MostThrottledKeys rules, which rank partition-key (and partition+sort) values by request and throttle counts.

Q8. What’s the capacity difference between a strongly consistent, eventually consistent, and transactional read of an 8 KB item? Eventually consistent = 1 RCU (0.5 × 2), strongly consistent = 2 RCU, transactional = 4 RCU. Consistency and transactions multiply RCU, a common under-provisioning error.

Q9. Why is Scan dangerous for capacity? Scan consumes RCU for every item read before any FilterExpression, scaling with table size, not result size. A large Scan can starve live reads. Prefer Query/GetItem; rate-limit unavoidable Scans and run them off-peak.

Q10. What is write sharding and what does it cost you? Adding a suffix (KEY#0..#N) to a hot partition key so writes fan across N partitions, beating the per-partition ceiling. The cost is read complexity: reads become scatter-gather across all shards (unless you shard deterministically and know the shard).

Q11. Your client logs throttling but DynamoDB’s ThrottledRequests is ~0. What happened? The throttling is client-side — SDK retries exhausted, or a client-side rate limiter — not DynamoDB rejecting requests. Fix the retry configuration (max attempts, jitter), not the table capacity.

Q12. How should a client behave when throttled, and why is jitter essential? Retry with exponential backoff and jitter. Jitter de-synchronizes clients so they don’t all retry at the same instant and re-throttle the table (a retry storm). The AWS SDKs do full jitter by default.

Quick check

  1. A table shows WriteThrottleEvents > 0 while ConsumedWriteCapacityUnits is 45% of provisioned. What is the most likely cause, and what is the fix?
  2. True or false: switching a throttling table from provisioned to on-demand will fix a hot-key problem.
  3. Your base-table writes are throttling but base capacity looks fine. Which metric dimension do you check, and what’s the cause?
  4. An 8 KB item read transactionally costs how many RCU?
  5. What single CloudWatch feature turns “some key is hot” into the exact key value, and which rule do you read?

Answers

  1. A hot key / hot partition — one partition-key value pinned at the ~1,000 WCU per-partition ceiling while the table averages low. Fix: write-shard the key (KEY#0..#N); raising table capacity won’t help.
  2. False. On-demand removes total-capacity planning but is still capped at the per-partition ceiling for a single key; the hot key still throttles.
  3. Check WriteThrottleEvents on the GSI / index dimension — the cause is GSI backpressure from an under-provisioned or hot index throttling the base writes that feed it.
  4. 4 RCU — 8 KB = 2 units of 4 KB, strongly consistent = 2 RCU, transactional doubles it to 4 RCU.
  5. CloudWatch Contributor Insights, reading the MostThrottledKeys (and MostAccessedKeys) rule.

Glossary

Next steps

AWSDynamoDBThrottlingHot PartitionAdaptive CapacityGSIDAXContributor Insights
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading