· 8 min read

How many writes per second before a new DynamoDB table throttles?

AWS documents that a brand-new table serves "up to 4,000 write request units per second" out of the box. The study everyone still cites for what actually happens — the one Capital One and ScyllaDB both link — measured it in 2019, before warm throughput, before configurable maximums, before the current scaling rules existed. As far as we can tell, nobody has published a measurement since.

So we ran one. On 2026-08-27, against a table created minutes earlier in us-east-1, offered write load ramped from 1,000 to 8,000 requests per second:

OfferedAchievedThrottled requests
1,000/s1,000/s0
2,000/s2,000/s0
3,000/s3,000/s0
4,000/s4,000/s0
5,000/s4,132/s25,992
6,000/s4,131/s55,966
8,000/s4,134/s115,922

The documented baseline holds, and it is slightly conservative: the service accepted everything up to 4,000/s without a single rejection, then pinned at 4,130 ±2 writes per second no matter how hard we pushed. Three windows, three offered rates, the same ceiling to within 0.05%. Reads never throttled at all — we drove a seeded table past 12,700 eventually-consistent reads per second and the shortfall above that was our own client, not DynamoDB.

One table, one day, one region, ~1 KB items with uniformly random keys — no in sight. That scope is the honest print on every number here. The rest of this post is how we measured it, including the part where the benchmark failed three times before it worked, and none of the failures were DynamoDB's fault.

The throttle is a 400, and it names the wrong suspect first

When the ceiling hits, the error you get is worth reading closely:

ThrottlingException: Throughput exceeds the current capacity of your table or index.
DynamoDB is automatically scaling your table or index so please try again shortly.
If exceptions persist, check if you have a hot key:
https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-design.html

Three observations, all measured:

  • It is an HTTP 400, not a 5xx. Your retry policy and your dashboards need to know that. A client that only retries 5xx will drop these on the floor; a monitor that only alarms on 5xx will show a green service while a third of your writes bounce.
  • The first throttle arrived 0.9–3.8 seconds into each over-baseline window — the service gives you a short grace burst before the ceiling engages, and the higher the offered rate, the sooner it bites.
  • The hot-key hint is a default, not a diagnosis. Our keys were uniformly random UUIDs; there was no hot key. At small scale, the first suspect for this message is simply the table-level ceiling.

Latency stayed indifferent to all of it: p50 write latency was 4–5 ms in-region in every window, throttled or not. Rejection is cheap for the service — it does not slow down, it just says no.

You cannot measure this from a laptop

Our first instrument was the obvious one: a Node script on a laptop in Madrid. It paced 1,000 writes per second cleanly and collapsed at 2,000 — not because DynamoDB pushed back, but because ~100 ms of Atlantic round-trip means 2,000 in-flight requests per second needs hundreds of concurrent sockets, and the event loop drowned. The service never throttled once. We were benchmarking our own Wi-Fi.

Attempt two moved the runner into the same region as a single Lambda function. In-region round-trip is ~5 ms, and one 3 GB function paced 2,000 requests per second cleanly at 10 ms p50. Past that it flatlined around 1,100/s with the CPU pinned: request signing and response handling are single-threaded JavaScript, and one runner simply cannot sign 4,000 requests a second. Memory allocated: 3 GB. Memory used: 253 MB. The bottleneck was never RAM — a Lambda's CPU share scales with its memory setting, and we were buying compute, not storage.

So the final instrument is a fleet of eight Lambdas, each pacing one-eighth of the total offered rate, all started against a shared wall-clock T0 so their windows line up. Eight runners at a comfortable 1,000/s each gave us 8,000/s of offered load with headroom, and the aggregate is a sum of counted requests — no extrapolation anywhere.

Three runs died before one worked, and DynamoDB was innocent every time

The fleet's first run ended with a runner reporting it had started 882 seconds after T0 — fifteen minutes late to a twenty-second appointment. The second run died with a read timeout. The third, with retries disabled, failed loudly on all eight runners at once. Meanwhile CloudWatch showed every single Lambda finishing its four-minute measurement cleanly, on time, no errors.

The culprit was the connection between the laptop and Lambda. A synchronous invocation holds one HTTPS connection open, fully silent, for the entire run — and a residential router quietly kills silent connections after a few minutes. The CLI, seeing a dead socket, did the worst possible thing: it silently retried, re-running a measurement Lambda that then found its T0 long gone. A benchmark harness that can invisibly run twice is not a harness; it is a random number generator with an AWS bill.

The shape that finally worked has three rules we would now use for any long-running remote measurement:

  • Fire-and-forget, results out-of-band. Runners are invoked asynchronously (the connection closes in milliseconds) and write their results as items into a small DynamoDB table; the driver polls for eight result rows. No connection lives longer than a request.
  • Retries are off everywhere. The measurement client runs at one attempt per request — a retry would silently absorb the throttles we exist to count — and the invocation path has retries disabled too, so no runner can ever execute twice.
  • A watchdog instead of a hang. Each runner races its schedule against a deadline; if anything wedges, it returns partial counts plus a snapshot of exactly where it was stuck, rather than timing out in silence. A failed run that explains itself costs one read; a hung one costs an evening.

Every request also carries an 8-second timeout. The run that hung did so because a single in-flight request with no timeout wedged the final drain step forever. One unbounded wait, per ~4 million requests, was enough.

What the reads did

The read phase ran against a second fresh table seeded with 1,000 items, using GetItems (~1 KB each, 0.5 read units):

OfferedAchievedThrottled
4,000/s4,000/s0
8,000/s7,941/s0
12,000/s11,119/s0
16,000/s12,762/s0

Zero throttles, ever. The documented 12,000 read/s baseline holds and we could not find its edge: at 16,000/s offered, five of our eight runners hit their own client-side saturation, so the 12,762/s figure is where our fleet topped out, not where DynamoDB did. We are saying that plainly rather than dressing it up as a service limit. Reads in-region ran at 2–4 ms p50.

Two smaller numbers worth keeping: a fresh on-demand table went from CreateTable to ACTIVE in 22 seconds on the benchmark run and 7.4 seconds on an earlier probe — budget for the variance, not the best case. And the entire benchmark, 672,116 billed writes and 1.08 million reads, cost $0.97. The instrument is reusable; the experiment is a coffee.

Half an hour of pressure does not double the ceiling

AWS's growth rule says on-demand capacity accommodates up to double your previous peak. We wanted to see that happen, so after the ceiling run we held a table at 8,000 writes/s of offered load for 34 contiguous minutes and bucketed the achieved rate per 10 seconds. The shape:

Minutes under loadCeiling
0–8~4,000/s (baseline, unmoved)
9–25~5,000/s
26~6,000/s
27–34~7,000/s
Achieved writes per second, minute by minute, over 34 sustained minutes at 8,000/s offered

Growth arrives in abrupt ~1,000/s steps, not a ramp — a minute is flat at one rate, the next minute is flat at the next. The first ceiling is sticky for a full 8 minutes of continuous over-demand. And after 34 minutes the table served 7,000/s: 1.75× where it started, still short of the offered 8,000 and of a clean doubling. If your launch needs more than ~4,000 writes/s on a new table, warm it up ahead of time or set its maximum on-demand throughput explicitly — the growth mechanism is real but it is neither instant nor generous on your schedule. The behavior is stable, too: under 9,000/s of offered load, the 2019 study’s table had grown to about 7,000/s when the test ended — the same plateau ours reached seven years later. That run cost $12.67, the most expensive thing we did all day.

What transfers if you measure a cloud service yourself

  • Put the load generator in the same region as the target. Otherwise you are measuring your route, not the service.
  • One Node process tops out around 2,000 signed requests per second regardless of memory; shard the load across runners and sum counted results.
  • Disable retries on the measurement path, at every layer. Retries exist to hide exactly what a benchmark exists to see.
  • Never hold a silent connection across a long run. Invoke async, deliver results out-of-band, poll.
  • Give every request a timeout and every runner a watchdog that returns partial data with a stuck-state snapshot.
  • Set a hard operation ceiling per runner so a pacing bug aborts instead of running up the bill, and tear down everything the run created — tables, roles, functions, logs — in a finally.

The reference pages this feeds

The full dataset — every window, every runner, per-latency percentiles, the verbatim error strings — now backs the measured tables in our DynamoDB limits reference, alongside the item-size and page-limit probes we published earlier. If you work against DynamoDB daily, DynoTable is our desktop client for it — the same team, the same habit of checking claims against the live service before repeating them.

Work with DynamoDB without the Console

A fast DynamoDB desktop client that runs the real SQL DynamoDB can’t — JOINs, GROUP BY, aggregates — with visual editing and an AI agent on your own Bedrock keys.

Free 30-day trial, no credit card — then the Free plan with no time limit.