Cloud Infrastructure · Cloud Storage
Request Rate Limits and Why Key Prefixes Are Partitioned
A bucket scales by splitting its keyspace across index partitions. Sequential keys hammer one partition, so the naming scheme decides the achievable request rate.
The index has to be split somewhere
A bucket can hold billions of keys, and no single machine can serve lookups over that many keys at a useful rate. The ordered index that makes prefix listing and range scans possible is therefore partitioned, and the partition boundaries are chosen by key range. Each partition owns a contiguous span of the sorted keyspace and serves the requests for the keys inside it, with its own compute and its own replication.
This is the same structure as a range-partitioned table, and it inherits the same property: throughput is a function of how evenly requests spread across partitions, and the partition map is invisible to clients. A client addresses keys, not partitions, so a workload whose keys all fall in one span puts all its requests on one partition regardless of how many clients it uses or how many connections it opens. The service may split a hot partition by choosing a new boundary inside its range, but a split requires moving index entries and cannot happen faster than the data can be redistributed.
The published limits reflect this. S3 documents a request-rate target per partitioned prefix of the keyspace, at least 3,500 write requests per second and 5,500 read requests per second, and states that the service splits prefixes as they grow hot. Google Cloud Storage publishes an initial per-bucket operation rate that scales with the bucket’s size and an explicit recommendation to spread requests across key ranges rather than concentrate them. Both are saying the same thing: the achievable aggregate rate is a property of the key distribution, and the service’s automatic splitting is a remedy for growth, not for a pathological naming scheme.
Why a timestamp prefix concentrates everything
The classic mistake is naming objects by a monotonically increasing value: a timestamp, a sequence number, or an identifier from a monotonic generator. Keys named 2026-10-25T12:00:00Z/event.json, 2026-10-25T12:00:01Z/event.json, and so on, sort into a narrow contiguous range, and every new write lands at the top of that range. For as long as the workload writes, the requests concentrate on the last partition of the keyspace, and the partitions that hold older data receive nothing.
The consequence is not graceful degradation. Writes to the hot partition are throttled with an error that the client must retry, and retries increase the load on the same partition. The workload’s throughput is capped at roughly the per-prefix write rate no matter how much parallelism it attempts, and the cap does not rise as the bucket grows, because growth adds partitions that the naming scheme never addresses.
Prefix listing makes the mistake worse in a different way. A listing of 2026-10-25/ is a range scan over the partition that holds that day, which is fine. A listing of the bucket root without a delimiter is a scan across every partition, which is why the service returns it in pages of a thousand keys and why a naive “list everything and filter in the client” job scales its cost with the bucket’s total size rather than with the subset it wants.
Hash prefixes spread keys but destroy ordering
The remedy is to put entropy at the front of the key. A hash of the object’s logical identifier, rendered as a few hexadecimal characters, becomes a prefix: a3f9/2026-10-25T12:00:00Z/event.json. Consecutive logical identifiers now map to unrelated prefixes, so writes spread across the whole keyspace, and the aggregate write rate rises roughly in proportion to the number of distinct prefixes in use.
The cost is that keys no longer sort by time, so a time-range query cannot be answered by a prefix listing. The listing that used to be one range scan becomes a scan of every hash prefix followed by a filter, which multiplies the request count by the number of prefixes and forces the client to merge results. Ordering has been traded for throughput, and the trade is only worth it when the write rate matters more than the ability to list by time.
Both properties can be had by separating the two functions into two keyspaces: write the data under hashed keys, and maintain a separate index, itself an object or a small set of objects, that maps time ranges to the data keys. That index becomes the thing readers consult, and its update is the coordination problem treated in conditional writes and prefix-based coordination. The design is more work than either extreme, and it is what most systems that need both properties end up doing.
A key-mapping function makes the distribution visible:
import hashlib
def spread_key(logical_id, entropy_bytes=2):
"""Prefix a logical id with a hash, keeping the original for lookup."""
digest = hashlib.sha256(logical_id.encode()).digest()
prefix = digest[:entropy_bytes].hex()
return f"{prefix}/{logical_id}"
ids = [f"2026-10-25T12:00:{s:02d}Z" for s in range(12)]
for k in map(spread_key, ids):
print(k)
Two bytes of entropy give 65,536 distinct prefixes, which is far more than any single bucket needs; the purpose is not uniformity for its own sake but ensuring that adjacent logical identifiers land in different partitions. Four bytes would be equally effective and produce longer keys, and the choice is bounded only by the key-length limit of 1024 bytes.
What the service splits, and what it cannot
Automatic splitting is triggered by observed load, and it operates on the prefix space, not on the client’s intent. A service that sees a hot range will divide it, and after the division the two halves are served independently. This is why a bucket that starts with a poor naming scheme can improve over time as the service adapts, and why the improvement is not something a workload should rely on: the split takes time proportional to the data being redistributed, and a burst that arrives faster than the split completes is throttled throughout.
Splitting cannot fix a workload that concentrates on a single key. A key lives in exactly one partition, and the per-key request rate is bounded by what that partition can serve for that key. A counter updated by every worker in a fleet, a manifest rewritten on every commit, or a lock polled at a high frequency all target one key and are limited accordingly. The fix is architectural, either shard the counter across n keys and sum on read or reduce the polling frequency, rather than a matter of naming.
There is a related asymmetry between reads and writes. Reads can be served from replicas of a partition, so the read rate a prefix supports is higher than its write rate, and the documented targets reflect that. A workload that is read-heavy over a narrow prefix is therefore less constrained than one that is write-heavy over the same prefix, and a migration that converts reads into writes, such as a compaction job or a rewrite-in-place, can hit a limit that the steady-state workload never approached.
Listing cost is a separate budget
Every listing is a range scan with a page size, and the page size is a service constant, commonly a thousand keys. The number of requests a full enumeration requires is the key count divided by that page size, and each request is billed and rate-limited like any other. Enumerating a bucket of ten million keys therefore costs ten thousand list requests, which at a few thousand requests per second is several seconds of wall time at best and a throttling problem at worst if the enumeration is one of many concurrent jobs.
The pattern that avoids this is to never enumerate the whole bucket. A partition-per-prefix layout with a known, bounded set of prefixes lets a job enumerate each prefix independently and in parallel, and a layout with an explicit index object lets it read the list of keys instead of scanning for them. Both reduce the enumeration to a bounded number of requests whose count is known in advance rather than a function of the data’s size.
Finally, the interaction with consistency is worth noting. A listing that is strongly consistent, as described in durability and read-after-write consistency, still returns a snapshot per page rather than across pages; the continuation token carries the scan position, and a key written behind that position will not appear in the current enumeration. A job that must see every key needs either a versioned, immutable keyspace per run or an acceptance that the enumeration reflects a moving target.
References
- Amazon S3 User Guide — Best practices design patterns: optimizing Amazon S3 performance — Amazon Web Services
- Windows Azure Storage: A Highly Available Cloud Storage Service with Strong Consistency — Calder et al., SOSP 2011
- Google Cloud Storage documentation — Request rate and access distribution guidelines — Google Cloud