Your Azure Cosmos DB container is provisioned at 10,000 RU/s. Metrics say you're using about 3,000. Yet requests keep failing with:
Response status code does not indicate success: TooManyRequests (429);
Substatus: 3200; Request rate is too large.
How can you be rate-limited at 30% usage? The answer is in how Cosmos DB spreads throughput across partitions.
Request units and 429s
Every operation costs request units (RUs): a point read of a 1 KB item costs about 1 RU, a write about 5+ RUs, and a query as much as the work it does. When a partition runs out of RUs for the current second, Cosmos DB returns 429 with a x-ms-retry-after-ms header instead of queueing the request.
The SDKs retry 429s automatically (the .NET SDK defaults to 9 retries or 30 seconds of waiting). You only see the error when the retries run out, and by then there's sustained throttling, not a blip.
A small number of 429s (around 1–5% of requests) with good end-to-end latency is actually healthy: it means you're using what you pay for.
The usual cause: a hot partition
Throughput is divided evenly across physical partitions. A container with 10,000 RU/s and 5 physical partitions gives each one about 2,000 RU/s. If most traffic goes to one partition key value, that partition hits its 2,000 RU/s limit and throttles, while the account-level graph shows 30%.
Confirm it in Azure Monitor:
- Normalized RU Consumption, split by PartitionKeyRangeID: one range at 100% while the others idle means a hot partition.
- Total Requests filtered to status code 429.
- Diagnostic logs (
CDBPartitionKeyRUConsumption) show the hottest logical partition key values.
Typical bad partition keys:
/status,/typeor/country: few distinct values, and heavily skewed;/date: every write today goes to one partition;/tenantIdwhen one tenant is 50% of the traffic.
Fixes
1. Choose a partition key with high cardinality and even load. Good keys spread both storage and requests, and match your most frequent query filter so queries stay single-partition: /userId, /deviceId, /orderId. For skewed tenants, use hierarchical partition keys (/tenantId, /userId) so a big tenant spans several partitions. The partition key can't be changed in place; changing it means migrating to a new container (container copy jobs or the change feed help).
2. Make each operation cheaper.
-
Use point reads (
ReadItemAsync(id, partitionKey)) instead ofSELECT * FROM c WHERE c.id = @id. A point read is about 1 RU; the query costs more. -
Avoid cross-partition queries in hot paths. Always include the partition key in the filter.
-
Trim the indexing policy. By default every property is indexed, and every write pays RUs to update each index. Exclude paths you never filter or sort on:
{ "indexingMode": "consistent", "includedPaths": [{ "path": "/customerId/?" }, { "path": "/createdAt/?" }], "excludedPaths": [{ "path": "/*" }] } -
Read the
RequestChargereturned by every SDK call, and log it for your top queries.
3. Match throughput to the shape of your traffic.
- Autoscale scales between 10% and 100% of a maximum you set, which suits spiky workloads.
- Burst capacity lets idle partitions bank unused RUs for short spikes.
- Serverless suits low, intermittent traffic.
Raising RU/s on a container with a hot partition is the expensive non-fix: you pay for more throughput across all partitions to relieve one.
4. Smooth out bulk work. Imports and backfills should use the SDK's bulk mode and a throughput budget, or run on a separate container, so they don't starve production traffic.
Checklist
- 429s at low overall usage mean a hot partition. Check Normalized RU Consumption by partition range.
- Pick high-cardinality, evenly used partition keys; use hierarchical keys for skewed tenants.
- Cut RU per operation: point reads, single-partition queries, a lean indexing policy.
- Use autoscale or burst capacity for spikes, and don't just raise RU/s.
Get the weekly commit
New database deep dives every week.
