System Design Interview

Object Storage (Amazon S3) System Design Interview Guide

Somewhere in a fleet of a hundred thousand disks, one dies every few hours. The store has to lose nothing, answer every read, and do it while paying for well under three copies of each byte.

L4, Buckets, keys, and a place to put bytes L5/L6, Erasure coding, placement, and an ordered index L7/L8, Correlated failure, heat, and cost per byte
A stick figure librarian tearing a precious page into strips and filing the strips in three separate buildings, calmly unbothered as one building burns
01

What the interviewer is testing

The interface is almost insultingly simple. PUT some bytes under a name, GET them back, LIST names by prefix, DELETE. There are no joins, no transactions across keys, no in-place updates. Candidates who have used an object store every day of their career tend to sketch a load balancer, a metadata table and "a bunch of storage servers" in the first five minutes and then run out of things to say. The question is hard everywhere the interface hides it.

With a hundred thousand disks, a disk dies every few hours, so durability is not a property of where you wrote the bytes; it is a race between failure and repair, and you have to win it for a trillion objects while paying for well under three copies of each byte. Three copies is the answer everyone reaches for, and on paper it is enough. The interview begins when the interviewer points at the bill: at exabyte scale the difference between 3× and 1.8× storage overhead is 75,000 hard drives. Getting under 3× means erasure coding, erasure coding means repairs that read ten fragments to rebuild one, and spreading those fragments so that a whole availability zone can burn down without losing anything puts a hard floor under how cheap the code can get.

The second thing being tested is whether you notice that the metadata is its own system. Every object has a record: its key, its size, its checksum, where its bytes live. At a trillion objects that index is more than a petabyte, it must return keys in lexicographic order for LIST, and it must be strongly consistent so that a GET issued the instant after a PUT sees the new object. That is a distributed database problem sitting beside a storage problem, and the two want opposite hardware, opposite replication, and opposite partitioning. An answer that stores metadata and bytes in the same place has merged the two hardest parts of the design into one that does neither well.

This post is about the blob store itself. It is not about keeping a user's laptop in sync with the cloud: chunking a file, uploading only the changed blocks, deduplicating across users and resolving edit conflicts are covered in the Dropbox and Google Drive guide, which assumes the store this post designs. The metadata index here is a key-value store in its own right, and this post deals with how it is partitioned and kept consistent rather than how it stores bytes on one machine; the storage engine underneath, LSM trees, compaction and quorum replication, is the subject of the key-value store guide.

Level Core question Differentiator
L4 Can you build a working PUT/GET/LIST service? Separates a metadata store from the byte store; replicates bytes across machines; handles large uploads in parts; knows overwrite replaces the whole object.
L5 What does durability cost, and how do you pay less? Does the overhead arithmetic; reaches for erasure coding and can explain "any k of n"; names the repair and degraded-read cost that comes with it; makes the metadata write the commit point.
L6 Where do the fragments go, and how is the index partitioned? Derives the per-zone fragment limit from the zone-loss requirement; spreads fragments over failure domains; range-partitions the index for ordered LIST and handles hot prefixes; keeps object records out of the repair path.
L7/L8 What actually loses data, and what does a byte cost? Treats correlated failure and the store's own software as the real threats to durability; sizes the read amplification of a zone outage; manages disk heat as drives grow and IOPS do not; knows which line of the bill each decision moves.
ℹ

Scope note. This guide designs a single region: three availability zones, one namespace of buckets, and the durability, consistency and cost targets in §2. Replicating to a second region is a real feature and is discussed as an add-on in §9, but it is asynchronous, driven by a different requirement, and changes nothing inside the region. Filesystem semantics (rename, append, locking) are out of scope; an object store that offers them is a different product.

02

Requirements: eleven nines, strong reads, and a cost ceiling

The functional list is short because the interface is small. What matters is the precise semantics of each operation, since every one of them is a promise that the design later has to keep under failure.

  • PUT, GET, HEAD, DELETE by bucket and key. An object is an immutable blob of 0 bytes to 5 TB plus a small amount of metadata. Overwriting a key replaces the whole object atomically; readers see the old object or the new one, never a mix. GET supports byte ranges.
  • LIST by prefix. Keys come back in lexicographic order, a page at a time, with an optional delimiter so that photos/2026/ can be browsed like a folder even though no folder exists.
  • Multipart upload. Large objects are uploaded as independently retried parts, in parallel and in any order, and become visible as a single object only when the upload is completed.
  • Versioning, per bucket and optional. When enabled, an overwrite or delete keeps the previous version retrievable instead of destroying it.
  • Storage classes and lifecycle rules. Objects can move to cheaper, slower classes as they age, and can expire, on rules the bucket owner sets.
  • Access control. Every request is signed; buckets carry policies; a time-limited presigned URL can grant one operation on one key to someone without credentials.

The non-functional targets are where the architecture comes from. Each one is used by at least one later decision, and the accordions below say which.

Requirement Target Why this number
Durability 99.999999999% per object per year The store is the system of record; nobody keeps a second copy of what they put in it
Zone-loss tolerance Lose one AZ permanently: no data lost, reads and writes continue An AZ is the largest failure that happens often enough to plan for
Availability 99.99% of GET and PUT requests succeed Pipelines and websites call the store inline; an outage is theirs too
Consistency Strong read-after-write for PUT, DELETE and LIST Clients chain jobs through the store: write a file, then tell the next stage to read it
Latency, in-region GET first byte p99 < 100 ms; 1 MB PUT p99 < 200 ms Fast enough to sit on a web request path; nobody expects database latency
Storage cost ≤ 2× raw bytes per logical byte Disks are most of the bill; three copies is the baseline to beat
Request rate ≥ 3,500 writes/s and 5,500 reads/s per key prefix, unbounded across prefixes The per-prefix figures S3 publishes; a design must scale by adding prefixes, not by tuning

What each target forces

Durability: eleven nines, and what it means at a trillion objects ›

Eleven nines is an annual loss probability of 10-11 per object. The way AWS phrases it for S3 is the useful one for a customer: store ten million objects and expect to lose one every ten thousand years. The same number reads differently from the operator's side. §3 puts this region at a trillion objects, and a trillion times 10-11 is ten objects a year. The target is not "never lose anything"; it is a loss rate low enough that a customer will almost certainly never see one, and that framing matters because it tells you which failures are worth engineering against.

Plain probability over independent disk failures turns out to be the easy part: §5 shows that even three copies clear eleven nines on paper. The difficult failures are the correlated ones, a rack losing power, a batch of disks with the same firmware bug, a deploy that corrupts what it writes, and those are where the budget goes.

✓

What this drives: erasure coding with a large margin (§5), fragments spread across racks and zones (§5, §9), continuous scrubbing and prioritised repair (§9), and deletion that is deliberately slow to become permanent (§10).

Zone-loss tolerance: the requirement that sets the code ›

An availability zone is one or more data centres with independent power, cooling and network, kilometres from its siblings but close enough for round trips of about a millisecond. Zones go dark for hours at a time (a power event, a cooling failure, a fibre cut) and, very rarely, are lost outright. The requirement covers both: during an outage every object must stay readable, and if the zone never comes back nothing may be lost.

This is the single most consequential line in the table. It is what forces data to be written synchronously to three zones before a PUT is acknowledged, and it is what decides how many parity fragments the erasure code carries: not the failure rate of disks, but the number of fragments one zone is allowed to hold.

✓

What this drives: RS(10,8) with at most six fragments per zone (§5), three-zone replication of open extents on the PUT path (§6), metadata partitions replicated across all three zones (§7), and the degraded-read load that §10 sizes for a zone outage.

Availability: 99.99% of requests ›

Four nines is about 52 minutes a year, or 4.3 minutes a month, of total unavailability, or the equivalent spread as a 0.01% error rate. Durability and availability are different promises: an object in a zone that is offline for an hour is unavailable but not lost. Keeping the two apart matters because they are bought differently. Durability is bought with redundancy on disk; availability is bought with redundancy on the request path, meaning stateless front ends in every zone, metadata that can elect a new leader, and reads that can route around a slow or missing fragment.

✓

What this drives: stateless front ends in every zone (§4), consensus leader failover in the index (§10), degraded reads (§5), and throttling with a retryable error rather than failing slowly (§5b).

Consistency: strong read-after-write, including LIST ›

After a PUT returns success, every subsequent GET must return that object and every subsequent LIST must include its key; after a DELETE returns, neither may. S3 itself offered weaker guarantees for its first fourteen years, with overwrites and LIST results allowed to lag, and whole tools grew up to paper over it: data-lake frameworks kept their own consistent listing elsewhere because a job could start before the files it depended on appeared in a LIST. S3 has been strongly consistent since December 2020, and a new design should start there.

The concrete failure this prevents: a pipeline stage writes part-0042, signals the next stage, and the next stage lists the directory and silently processes 41 files instead of 42.

✓

What this drives: the metadata commit as the single visibility point (§6), consensus-replicated index partitions (§7), and the rule that object records are never served from a cache while bytes and locations are (§8).

Latency: 100 ms to first byte, 200 ms for a 1 MB PUT ›

These are p99 targets for a client in the same region, measured to the first byte of a GET response and to the acknowledgement of a PUT. They are loose by database standards on purpose: the data lives on hard drives, where a single random read costs 4–10 ms before any queueing, and a design that promised single-digit milliseconds would have to put the bytes on flash and blow through the cost ceiling below. The targets are tight enough to rule out anything that waits on many disks for one small request, which is what rules out erasure coding at PUT time.

✓

What this drives: replicate-then-code for new data (§5), SSD write journals on storage nodes (§6), and systematic codes so a healthy read touches one disk (§5).

Storage cost: at most twice the logical bytes ›

Raw disk is the dominant cost of an object store: every other component is a rounding error next to the drives, their servers, the power and the floor space. The ceiling of 2× is a business target set against the obvious baseline, three full copies, and at the scale in §3 the numbers are not subtle: each 0.1× of overhead on 1 EB of logical data is 100 PB of raw disk, about 6,250 twenty-terabyte drives at 80% full.

✓

What this drives: erasure coding for sealed data (§5), dense hard drives rather than flash for bytes (§4), compaction to reclaim deleted space (§9), and colder storage classes for data nobody reads (§9).

Request rate: per prefix, not per bucket ›

S3 documents its scaling as at least 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD requests per second per partitioned prefix, with no limit on the number of prefixes in a bucket. That phrasing gives away the implementation: throughput scales by splitting the key space into ranges and serving each range independently. A single bucket can take millions of requests a second as long as they are spread across keys; a single hot prefix can take only what one partition can serve until it is split.

✓

What this drives: range partitioning of the index with load-based splits (§7, §9), and a retryable 503 SlowDown while a split catches up (§5b).

03

Capacity estimation: bytes set the disk count, objects set the index

Most capacity estimates start from requests per second. For an object store that is the wrong first question, because the aggregate request rate turns out not to bind anything. The quantities that decide this design are stored bytes, which set how many disks exist and therefore how often one fails, and the number of objects, which sets the size of the index independently of how many bytes those objects hold.

Take one large region as the design point: 1 EB of logical data, an average object of 1 MB, so about a trillion objects. For scale, AWS has said S3 holds more than 400 trillion objects across all its regions, so a trillion in one region is a big region rather than an imaginary one. The average hides a heavily skewed distribution: most objects are small, and most bytes sit in a minority of large ones. That skew matters twice later on. It is why small objects are packed into large extents before being erasure coded (§5), and it is why the index has to be sized by object count, which small objects inflate, rather than by bytes.

So the dimensions that bind are: logical bytes and the erasure code, which together give raw capacity and the disk count; the disk failure rate, which turns the disk count into a steady stream of repairs and a repair bandwidth; and the average object size, which turns bytes into index rows. Request rate is left out of the sliders on purpose. At a peak of 1 M requests a second, 90% of them reads, the index sees about 100 requests a second per partition across the roughly 10,000 partitions sized below, and the disks see 0.9 M reads a second against about 11 M random IOPS of capacity. In aggregate there is headroom everywhere. Request rate binds only locally, on one hot prefix or one hot disk, and §9 is where that gets handled.

Interactive capacity estimator

1,000 PB
1.0 MB
10
8
2.0%
20 TB
Raw capacity
1.80
EB on disk
1,000 PB × 18 ÷ 10
Hard drives
112,500
drives
1.80 EB ÷ (20 TB × 80% full)
Disk failures
6.2
per day, every day
112,500 × 2.0% ÷ 365
Steady-state repair reads
11.4
GB/s, around the clock
6.16/day × 16 TB × 10 reads ÷ 86,400 s
Metadata index
1.50
PB on SSD, for 1.00 T objects
1,000 PB ÷ 1.0 MB × 500 B × 3 replicas
Durability, independent failures only
35.9
nines per object per year
−log₁₀(1,460 windows × C(18, 9) × p⁹)
✓

The architectural implication: read the teal card and the coral card together. Set k = 1 and m = 2, which is three-way replication, and the drive count jumps from 112,500 to 187,500: 75,000 drives is the price of not erasure coding, and it is why §5 exists. Now look at the coral card. Erasure coding pays for that saving in repair traffic, because rebuilding one lost fragment reads k others: at k = 10 the fleet reads 11.4 GB/s around the clock just to stand still, six times the 1.9 GB/s replication would need: ten times the reads per failed drive, spread over fewer drives. That is affordable spread over a hundred thousand drives, but it is why wide codes stop paying off and why §5's accordion on local reconstruction codes exists. Last, the durability card. With replication it reads 11.4 nines; with RS(10,8) it reads about 36. Both clear the target, so the parity count has to come from somewhere else: §5 derives it from the zone rule, and §10 spends the margin on correlated failures.

ℹ

Constants behind the sliders. Drives are filled to 80%, leaving room to absorb the data of failed drives and a zone's worth of rebalancing. A failed drive is assumed to be 80% full too, so repair rebuilds 16 TB per 20 TB drive, and rebuilding each lost fragment reads k surviving fragments of the same size. The index stores 500 bytes per object record on disk, covering a key averaging around 100 bytes, size, checksum, version id, timestamps, storage class, the extent pointer from §7 and LSM overhead, kept as 3 replicas, one per zone; at roughly 50 GB per partition that is about 10,000 partitions. The durability card uses the standard back-of-envelope model: a stripe of n = k+m fragments is lost if more than m of its drives fail inside one 6-hour repair window, so with p = AFR × 6 h ÷ 8,760 h the annual loss probability is about 1,460 windows × C(n, m+1) × pm+1. Six hours covers detection and a declustered rebuild (§9); it is a design target rather than physics. The model assumes failures are independent, which is precisely the assumption that is false in practice. Disk failure rates of 1–2% a year are typical of large published fleet statistics.

⚠

What this estimate deliberately excludes. The bytes of new data that have not yet been erasure coded. §5 writes them three-way replicated first; at 100 GB/s of ingest and an hour's coding lag that is about 360 TB logical held at 3×, around a petabyte of raw disk against 1.8 EB, so it is left out. Also excluded: the cost of the index's flash relative to the drives, which is real but small, and the IOPS budget, which the aggregate numbers above show is not the constraint until a single drive or prefix runs hot.

04

High-level architecture: a metadata plane and a data plane

§1 argued that there are two systems here, and §3 put numbers on both: 1.5 PB of small, mutable, strongly consistent records, and 1.8 EB of large, immutable, cost-dominated bytes. The architecture keeps them apart and puts one thin layer between them. Everything on the request path is either stateless or replicated across all three zones; everything that moves bytes around after they are written runs in the background, off any request path.

Object store architecture: clients reach stateless front ends through per-zone load balancers; front ends read and commit object records in a range-partitioned metadata index, look up extent locations from an extent manager, and stream bytes to storage nodes; background workers for coding, repair, compaction and lifecycle run off the request path Client / SDK signed HTTP requests Load balancers DNS across zones, one set per zone Front-end fleet auth, policy, streams bytes; stateless, every zone Metadata index key ranges, consensus across 3 zones 1.5 PB on SSD Extent manager extent map, placement, repair plans control plane Storage nodes HDDs + SSD journal; know no keys 112,500 drives Background workers, never on a request path Erasure coder sealed → RS(10,8) Repair & scrub rebuild, verify checksums GC & compaction reclaim deleted bytes Lifecycle tiering and expiry read or commit record extent locations (cached) bytes on the request path background or control plane
Figure 1 — The index holds records, the storage nodes hold bytes, and the extent manager holds the map between them. Nothing that moves bytes after a PUT runs on a request path.

Client / SDK. Signs every request with a key derived from the caller's secret, and splits large uploads into parts. The non-obvious constraint is that the client is also the retry policy: a throttled or failed request is retried from the SDK with backoff, so the error codes in §5b are part of the design, not an afterthought.

Load balancers. DNS spreads clients across all three zones, and a load balancer in each zone spreads connections across front ends. They know nothing about keys; any front end can serve any request, which is what lets a whole zone's front ends disappear without clients noticing more than a reconnect.

Front-end fleet. Verifies the signature, evaluates the bucket's access policy, then coordinates the operation: find or commit the object record, find or write the bytes. It streams bodies rather than buffering them, because an object can be 5 GB in a single PUT. Stateless apart from caches, which §8 is careful about.

Metadata index. One record per object version, keyed by bucket and key, stored in key order and split into roughly 10,000 ranges. Each range is a small consensus group with one replica per zone, which is what gives strong consistency and zone-loss tolerance for records. The non-obvious constraint is ordering: LIST needs keys in lexicographic order, so the index cannot hash keys to partitions, and inherits the hot-range problem that §9 deals with.

Extent manager. The control plane for bytes. Objects are packed into large append-only extents, and the extent manager records, for every extent, whether it is open or sealed, how it is encoded, and which drive holds each replica or fragment. It chooses placements and schedules sealing, coding and repair. It is on the request path only on a cache miss, since front ends cache extent locations.

Storage nodes. Servers holding a few dozen hard drives and a small SSD used as a write journal. They store extent replicas and fragments, checksum every block they write, verify on every read, and know nothing about buckets or keys. That ignorance is deliberate: a storage node can be drained, rebuilt or replaced without anyone touching the index.

Background workers. The erasure coder converts sealed extents from three replicas into RS(10,8) fragments. Repair rebuilds fragments lost with a drive, and the scrubber re-reads every fragment on a cycle to find corruption before a reader does. Garbage collection rewrites extents that are mostly deleted data. Lifecycle moves or expires objects by rule. Every one of them changes where bytes live; none of them is allowed to slow a request.

Architectural rationale

Why metadata and bytes live in different systems ›

Put them side by side and they disagree on everything. Records are about 500 bytes, change whenever a key is overwritten, must be ordered for LIST, and need consensus for strong consistency; they belong on flash, replicated three ways, because §3 puts them at 1.5 PB and every GET reads one. Bytes average a megabyte, never change once written, need no ordering, and are dominated by cost per terabyte; they belong on dense hard drives under an erasure code that would be absurd for a 500-byte record.

A single system forced to serve both ends up paying flash prices for bytes or disk-seek latency for records. Separating them also separates their failure and scaling behaviour: an index partition splitting under a hot prefix has no effect on the data plane, and a drive failing has no effect on the index.

Tradeoff Every PUT is now a two-system write, so there is a window in which bytes exist without a record. §6 orders the two writes so that window only ever produces unreferenced bytes, which garbage collection reclaims, and never a record pointing at nothing.
Alternatives Store small objects inline in the record Hash-placed objects with a per-bucket index (Ceph RGW)
Why objects are packed into extents instead of stored one per file ›

Two reasons, both from §3. First, the object size distribution is skewed toward small objects, and erasure coding a 4 KB object on its own means eighteen slivers of a few hundred bytes on eighteen drives: eighteen seeks to write it, and per-fragment bookkeeping larger than the data. Packing objects into 1 GB append-only extents means the code always operates on large, uniform units regardless of object size. Second, one file per object would put about nine million files on every drive (a trillion objects over 112,500 drives, before coding multiplies it), while 100 MB fragments of 1 GB extents come to about 160,000 a drive, a number any local filesystem handles comfortably.

Packing also creates the indirection §7 relies on. An object record points at an extent and an offset; only the extent map knows which drives hold that extent. Repair, coding and rebalancing change the extent map and never touch object records.

Tradeoff Deleting an object no longer frees space, because its bytes sit inside an extent shared with live objects. Space comes back only when compaction rewrites the extent (§9), so the store always carries some dead bytes, and compaction is a permanent background cost.
Alternatives One file per object (small scale only) Code large objects directly, pack only small ones
Why placement is a lookup table rather than a hash function ›

Algorithmic placement, where the drives for a piece of data are computed from its name and a cluster map (consistent hashing, or Ceph's CRUSH), needs no table and no control plane on the read path. It is a good fit for a cluster of hundreds of machines. At this scale it has two problems. When the cluster map changes, the function moves data whether or not moving it is wise at that moment, and repair wants to choose which drive gets a rebuilt fragment based on current heat and free space (§9). And the placement rule this design needs, at most six fragments per zone and at most one per rack, plus "not the drives that are already busiest", is a constraint problem, easier to enforce when a component makes each decision explicitly.

The cost is a table of about a billion extents, each with up to eighteen locations. That is a few hundred gigabytes, partitioned across the extent manager's own replicated shards and cached by front ends, and it changes only when bytes actually move.

Tradeoff A control plane that must stay available. It is off the hot path for cached extents, but a cold read of an old extent needs it, and so does every PUT that needs a fresh open extent.
Alternatives CRUSH-style computed placement Consistent hashing with virtual nodes
Why hard drives for bytes, and flash only where latency is bought ›

Hard drives remain several times cheaper per terabyte than flash, and the bytes in an object store are overwhelmingly cold: most objects are read rarely after the first days. The storage cost ceiling in §2 is unreachable on flash at 1.8 EB. Flash appears in exactly two places, each paid for by a latency target: the index, because every GET reads a record, and a small write journal on each storage node, because a PUT's acknowledgement waits for data to be durable and an fsync on flash with power-loss protection costs well under a millisecond where a hard drive costs a seek.

Tradeoff A drive delivers roughly 100–150 random reads a second whether it holds 4 TB or 20 TB, so larger drives mean fewer IOPS per stored terabyte every generation. That turns "which drive holds this" into a load-balancing problem, which §9 treats as heat management.
Alternatives All-flash (a separate low-latency class) SMR drives for cold classes

How real systems made these decisions

Decision This design Azure Storage (2011–12 papers) Facebook Haystack + f4 Ceph (RADOS + RGW)
New data 3 replicas in an open extent, coded when sealed Same shape: extents replicated three ways, erasure coded after sealing Hot photos in Haystack at 3.6×; migrated to f4 once warm Can write straight into an erasure-coded pool
Code and spread RS(10,8) across 3 zones, ≤6 per zone LRC(12,2,2) inside one storage cluster, 1.33×; other regions via async replication RS(10,4) in each of two data centres, plus an XOR of the pair stored in a third: 2.1× effective Any k+m, spread by CRUSH failure-domain rules
Placement Extent map in a control plane Extent locations in a Paxos-replicated stream manager A directory maps logical volumes to machines Computed from the name by CRUSH; no table
Listing Range-partitioned index, ordered LIST Range-partitioned tables in the partition layer No general LIST; the photo URL carries its volume Bucket index sharded by hash; LIST merges shards
ℹ

None of these is the "right" design; each follows from what its builders had to promise. Facebook never needed LIST, so f4 has no ordered index. Ceph targets clusters an operator runs, so algorithmic placement that needs no central table is worth its rebalancing cost. Azure's 2012 code sits inside a single cluster because zone loss was handled elsewhere. This design has to tolerate a zone loss inside the store, list keys in order, and stay under 2×, and those three requirements are what pick each column's answer.

05

Durability per byte: from three copies to erasure coding across zones

Every other decision in this design is about a few petabytes of index or a few thousand servers. This one is about 1.8 EB, the drives that hold them, and how many of those drives can fail before something is gone. There is no fork between competing algorithms here. There is one idea, redundancy, and a sequence of refinements, each forced by a requirement the previous version fails.

Step 1: three copies, one per zone

The obvious design writes each object to three drives, one in each zone. It works, and it deserves credit before it gets criticised. A zone loss leaves two copies, so reads continue and nothing is lost. A healthy read touches one drive. Repairing a lost copy reads exactly one surviving copy. Under the independent failure model from §3, it gives about 11.4 nines, just past the target.

Its problem is the bill: 3× raw per logical byte, against a ceiling of 2× in §2. At 1 EB that is 187,500 drives, and the margin it buys, a single spare copy once a zone is gone, is thinner than it looks.

Step 2: any k of n

An erasure code splits data into k data fragments and computes m parity fragments from them, such that any k of the n = k+m fragments are enough to rebuild the rest. Reed-Solomon codes have exactly this property. A RS(10,4) code stores fourteen fragments for every ten fragments' worth of data, 1.4× overhead, and survives the loss of any four. Three-way replication, by comparison, is the degenerate code with k = 1 and m = 2: 3× overhead, survives any two.

The codes used in storage are systematic: the ten data fragments are simply the original bytes cut into ten pieces, and only the parity fragments are computed. A healthy read of an object inside data fragment 3 goes to one drive and reads the bytes directly, with no decoding at all. Decoding happens only when a fragment is missing, and then it is expensive: a degraded read fetches the same byte range from ten surviving fragments and reconstructs the missing one, and a repair does the same for the whole fragment. Ten reads to recover one is the price of a ten-wide code, and §3's coral card showed it, for the RS(10,8) this section settles on, as 11.4 GB/s of repair traffic that never stops.

Step 3: the zone rule sets the parity count

Now apply the requirement from §2 that a zone can be lost outright. A stripe survives a zone loss only if the zone held no more fragments than the code can lose. Spread n fragments evenly across three zones, a per zone, and the rule is m ≥ a. That gives a floor: overhead n / k is at least 3a / 2a = 1.5× for any code spread over three zones, no matter how clever. RS(10,4) at 1.4× cannot be made zone-safe; a zone would hold five fragments of a code that can lose four.

At exactly 1.5× the code survives a zone loss with no margin left: the two surviving zones hold exactly k fragments. Re-protecting a destroyed zone means recreating a third of the raw bytes, 500 PB at 1.5×, by reading about 1 EB from the survivors, and placing the results without breaking the zone rule. That is days of work even with the whole fleet on it, and with several drives failing every day, some of the billion zero-margin stripes will certainly lose a fragment during those days. So the code needs margin after a zone is gone, and every fragment of margin is paid for in overhead.

Code Overhead Fragments per zone Spare fragments after a zone loss Reads per repaired fragment
3 replicas 3.0× 1 1 1
RS(6,3) 1.5× 3 0 6
RS(9,6) 1.67× 5 1 9
RS(10,8) 1.8× 6 2 10
RS(14,10) 1.71× 8 2 14

Our choice for this system is RS(10,8), six fragments per zone, one per rack. It keeps two spare fragments after a zone is lost, so a stripe in a surviving zone can lose a drive, be flagged, and be repaired first while it still has one spare left. RS(14,10) buys the same margin for 0.09× less overhead, about 5,400 fewer drives at 1 EB, but costs 40% more reads per repaired fragment, about a third more repair traffic fleet-wide, puts fourteen drives behind every degraded read, and needs eight separate racks per zone for every stripe. At this scale the cheaper repair wins. Notice what did not set the parity count: the disk failure rate. §3's durability card shows that three parity fragments would already clear eleven nines against independent failures. Eight exist because a zone holds six.

A sealed 1 GB extent is Reed-Solomon encoded into ten data and eight parity fragments of 100 MB each, placed six per availability zone and one per rack. Click fragments or use the buttons to fail them. Sealed extent: 1 GB of packed objects, cut into ten 100 MB pieces D1D2D3 D4D5D6 D7D8D9 D10 Reed-Solomon encode: keep D1–D10, add P1–P8 Zone A Zone B Zone C 6 fragments on 6 racks 6 fragments on 6 racks 6 fragments on 6 racks data fragment (the original bytes) parity fragment lost with its drive

18 of 18 fragments healthy. Any 10 rebuild the extent, so 8 more can be lost. A read of any object touches one drive.

Figure 2 — RS(10,8) placed six fragments per zone. Click a fragment to fail its drive, or lose a whole zone: the extent stays readable while ten fragments survive, and the zone rule guarantees a zone loss alone never gets it there.

Step 4: do not erasure code on the PUT path

The last refinement is about when to code. Coding every object as it arrives would make each PUT wait for eighteen drives in three zones to acknowledge, and a request that waits on eighteen drives waits on the slowest of them, which at p99 is far slower than the slowest of three. It would also shred small objects into slivers, which §4 already ruled out. So new data takes the Step 1 path for a short while: PUTs append to an open extent replicated on three drives in three zones, and when the extent reaches 1 GB (or a time limit, so slow-filling extents do not stay replicated forever) it is sealed. The erasure coder then reads the sealed extent, writes eighteen fragments under the placement rule, verifies them, flips the extent map entry, and only then frees the three replicas. The replicated window is short enough that §3 could ignore its cost.

Why the replicated window also suits reads ›

The split lines up with access patterns. Data is read most in the hours after it is written, and during those hours it has three full copies, so a slow drive can be dodged by reading another replica. By the time data is coded, reads of it are rarer and a degraded read is an affordable exception.

Code sketch: placing a stripe and reading from it ›
FRAG = 100 * MB           # 1 GB extent / 10 data fragments
K, M, PER_ZONE = 10, 8, 6

def place_stripe(extent_id, zones=("A", "B", "C")):
    """Choose 18 drives: 6 per zone, never two on one rack, coolest first."""
    chosen = []
    for zone in zones:
        racks = eligible_racks(zone)                  # healthy, below fill target
        racks.sort(key=lambda r: r.recent_iops)       # heat, not just free space (see §9)
        picks = [coolest_drive(r) for r in racks[:PER_ZONE]]
        if len(picks) < PER_ZONE:
            raise PlacementError(f"{zone}: fewer than {PER_ZONE} eligible racks")
        chosen += picks
    return chosen                                     # fragment i lives on chosen[i]

def read(extent, offset, length):
    """Assumes the range sits inside one data fragment; real code splits it."""
    i, off = divmod(offset, FRAG)                     # systematic: bytes live in D(i+1) as-is
    try:
        return fetch(extent.loc[i], off, length, timeout=READ_TIMEOUT)
    except (DriveDown, Timeout):
        # degraded read: same byte range from any 10 other fragments, then decode
        others = [j for j in range(K + M) if j != i and healthy(extent.loc[j])]
        if len(others) < K:
            raise Unavailable(extent.id)
        parts = fetch_all([(extent.loc[j], off, length) for j in others[:K]])
        return rs_reconstruct(parts, missing=i)

Two details carry most of the weight. The sort key in place_stripe is recent load, not free space: a new stripe placed on the emptiest drives lands on the newest drives, which then receive all the fresh, hot data at once. And the degraded path in read is ten times the I/O of the healthy one, so it is the fallback for a drive that is down or has timed out, not a race against one that is merely slow (§6).

If repair traffic were the binding constraint: local reconstruction codes ›

A Reed-Solomon repair reads k fragments no matter which fragment was lost. Local reconstruction codes (LRC), used by Azure Storage, split the data fragments into groups and add one cheap parity per group alongside a few global parities. Azure's LRC(12,2,2) has twelve data fragments in two groups of six, a local parity for each group and two global parities: 16 fragments, 1.33× overhead. A single lost data fragment, by far the most common repair, is rebuilt from the other five in its group plus the local parity, six reads instead of twelve.

This design does not need it at the stated scale. §3 puts steady repair at 11.4 GB/s spread over 112,500 drives, around 0.1 MB/s per drive, which is noise. LRC earns its complexity when stripes get wide enough that repair and degraded reads start competing with customer reads, or when overhead has to fall further than RS allows. It is an L7/L8 answer to "what would you change at ten times the scale", not part of the baseline.

Tradeoff LRC is not MDS the way RS is: LRC(12,2,2) survives any three failures but only about 86% of four-failure patterns, where RS with four parities survives them all. Its durability has to be computed per failure pattern, and it interacts with zone placement in ways that need care.
?

The one open question interviewers ask next: "Doesn't erasure coding make reads slower?" Healthy reads, no: the code is systematic, so an object is read from the one drive holding its data fragment, just as with replication. Degraded reads, yes, by ten times the I/O. The cost that matters is not one slow read but a correlated wave of them: when a zone is down, every object whose data fragment lived there needs a degraded read at once, and §10 shows that roughly quadruples the read load on the surviving drives.

5b

The client contract: operations, guarantees, and retries

An object store is an infrastructure primitive, so its API matters less as a set of routes than as a set of promises. Other systems are built on the exact meaning of "PUT returned 200", and on what a client is supposed to do with each error. The operations are shaped like S3's because most readers already know them; the interest is in the guarantees attached to each.

Operation What success promises What is validated, and where
PUT /{bucket}/{key} Bytes are on three drives across the zones, and the new version is visible to every later GET and LIST Signature and policy at the front end; key ≤ 1,024 bytes of UTF-8; body ≤ 5 GB; checksum compared before the record is committed
GET /{bucket}/{key} The latest committed version, or the byte range asked for; never a mix of two versions Every block's checksum is verified by the storage node as it is read
HEAD /{bucket}/{key} The record alone: size, ETag, checksum, storage class, version Index read only; no storage node involved
DELETE /{bucket}/{key} Later GETs return 404 and LISTs omit the key; with versioning, a delete marker hides the object instead Space is reclaimed later, by compaction (§9)
GET /{bucket}?list-type=2 Keys after the continuation token, in byte order, up to 1,000, reflecting every write that completed before the request Prefix and delimiter applied at the index partition, not the front end
Multipart: create, upload part, complete, abort Parts are durable as each returns; the object appears atomically on complete Parts 5 MB to 5 GB (last may be smaller), at most 10,000; part list checked on complete

A PUT carries its own checksum, and a conditional header turns it into a create-if-absent:

PUT /media-prod/photos/2026/09/17/cat.jpg HTTP/1.1
x-amz-checksum-crc32c: yZRlqg==           // verified end to end before commit
If-None-Match: *                           // create only; 412 if the key exists
Content-Length: 1048576

<1 MB of bytes>
200 OK
ETag: "9b2cf535f27731c974343645a3985328"
x-amz-version-id: 3sL4kqtJlcpXroDTDmJ.rmSpXd3dIbrHY     // versioned buckets only

LIST is where ordering becomes visible. With a delimiter, keys that share a prefix up to the next / are rolled up into a single CommonPrefixes entry, which is how a flat key space is browsed as folders:

GET /media-prod?list-type=2&prefix=photos/2026/09/&delimiter=/ HTTP/1.1

200 OK   (abridged)
  IsTruncated: true,  NextContinuationToken: 1ueGcxLPRx1Tr...   // opaque: encodes the last key returned
  CommonPrefixes: photos/2026/09/16/, photos/2026/09/17/
  Contents:       photos/2026/09/index.json (2,048 bytes)

The guarantees, stated precisely

  • Durable on 200. A PUT or a completed multipart upload does not return success until its bytes are on three drives spread across the zones (one per zone when all three are healthy) and its record is committed by a majority of the index partition's three zone replicas, so either survives the loss of any one zone. There is no "accepted, will be durable soon" state.
  • Atomic visibility. The new version becomes visible in one step, at the index commit (§6). A reader sees the old object or the new one, never a partial upload.
  • Strong read-after-write. Any GET, HEAD or LIST that starts after a write returns success reflects that write, whichever front end and zone serves it.
  • Concurrent writers. Two PUTs to the same key are serialised by the key's index partition, and whichever commits last is the current version. A client that needs compare-and-swap sends If-Match: "<etag>" and gets 412 if someone else got there first. S3 added exactly these conditional writes in 2024.

Errors, and what the client does with each

Response Meaning Client action
503 SlowDown A partition or tenant is over its request rate, often while a split is in progress (§9) Retry with exponential backoff and jitter; the SDK does this by default
500 InternalError A dependency failed mid-request; the write may or may not have committed Retry; see the note on retried PUTs below
400 BadDigest The bytes received do not match the checksum sent Resend the body; nothing was committed
412 PreconditionFailed An If-Match or If-None-Match condition was false Do not retry blindly; re-read and decide
403 AccessDenied / 404 NoSuchKey Policy refused the request / no current version exists Not retryable
⚠

A retried PUT is not always harmless. If the first attempt committed and only the response was lost, the retry commits again. If nobody else wrote the key in between, that is invisible on an unversioned bucket: same key, same bytes. If another client did, the retry lands after it, and a value that had already been replaced reappears. On a versioned bucket the retry creates a second, identical version, and on a lifecycle rule that counts versions it can push an older one out early. A create that must happen once should use If-None-Match: * and carry a client-generated token in user metadata. A 412 on retry means only that the key exists; a HEAD comparing the token shows whether it was this client's write or someone else's.

Multipart upload semantics ›

Create returns an upload id. Each part is uploaded with its part number and stored exactly like a small PUT, into open extents, with its own record in a separate upload table rather than the object index, so it is invisible to GET and LIST. Parts can arrive in any order, in parallel, and a failed part is retried alone, which is the whole point for a 5 TB object over an unreliable link.

Complete sends the ordered list of part numbers and their ETags. The front end checks every part exists with that ETag, then commits one object record whose chunk list is the parts' extent pointers, in order. No bytes are copied; completing a 5 TB upload is a metadata write.

Complete has the same lost-response problem as a PUT, and a worse failure: the client retries, the upload id no longer exists, and a client that then calls Abort to clean up would be aborting a finished upload. So the commit leaves a small tombstone mapping the upload id to the version it produced, kept for a few days. A retried Complete finds the tombstone and returns the same version, and Abort succeeds only on an upload that has not completed.

Tradeoff An upload that is never completed or aborted leaves its parts stored and billed indefinitely, invisible to LIST. The standard fix is a lifecycle rule that aborts incomplete uploads after a few days, and ListMultipartUploads shows an owner what they are paying for.

Optional capabilities, by level

Capability What it is Level
Presigned URLs A URL carrying a signature for one operation on one key, with an expiry; lets a browser upload straight to the store without credentials or a proxy L5
Versioning and ListObjectVersions Every overwrite and delete keeps history; the index key gains a version component (§7) L5
Conditional writes Create-if-absent and compare-and-swap on ETag; enough to build a lock or a manifest commit on top of the store L6
Object Lock (WORM retention) A version that cannot be deleted or overwritten until a date, even by the account owner in its strict mode; the answer to ransomware and regulatory retention (§10b) L6
06

A PUT and a GET, end to end: the commit point and the latency budget

A PUT touches two independent systems, the storage nodes and the index, and both writes can fail. The order they happen in decides what a failure leaves behind, which is why the PUT is worth tracing step by step. The rule is simple and absolute in intent: bytes first, record second. A crash between the two leaves bytes that nothing references, which is waste. The opposite order would leave a record pointing at bytes that do not exist, which is data loss as far as the customer can tell.

PUT sequence: the client sends bytes to a front end, which appends them to an open extent replicated in three zones, then commits a version record to the key's index partition; the commit is the moment the object becomes visible; the extent is erasure coded later in the background Client Front end Open extent 3 replicas, 3 zones Index partition consensus, 3 zones Erasure coder ① PUT, body, CRC32C ② verify signature, check policy ③ append bytes copies to the other two zones; each fsyncs its SSD journal ④ all 3 acked: extent, offset ⑤ checksum matches ① ⑥ conditional commit of the version record leader replicates; a majority has fsynced COMMIT POINT: visible and durable ⑦ committed, version id ⑧ 200 OK, ETag later: extent sealed at 1 GB encode RS(10,8), write 18 fragments, update the extent map, free the replicas request path background
Figure 3 — A single-part PUT. Everything before the commit point is invisible and disposable; everything after it is durable in three zones. Erasure coding happens minutes or hours later, and changes only the extent map.

Steps ① and ② are the front end doing its job as a gatekeeper. Step ③ is the only place bytes move: the front end streams the body to the primary replica of an open extent it holds a lease on, and the primary forwards it down a chain to replicas in the other two zones. Each replica writes to its SSD journal and fsyncs before acknowledging; the hard drive is written later from the journal. An append succeeds only if all three replicas acknowledge. If one is unreachable, the front end does not wait for it or vote around it: the extent is sealed at its current length, and the append is retried on a fresh open extent with three healthy replicas. Refusing to write to a degraded extent keeps the data plane free of consensus entirely.

Step ⑤ compares the checksum computed over the received bytes with the one the client sent. A mismatch returns 400 BadDigest without committing anything, which is what makes the checksum end to end rather than decorative. Step ⑥ is the commit: a conditional write of the new version record, carrying the extent pointer from ④, to the partition that owns the key. It is the only step that changes what any reader can see.

A GET runs the same machinery in reverse, with no commit. The front end reads the current version record from the key's partition, fresh on every request (§8 explains why it is never cached), resolves the extent to drive locations from its cache, reads the byte range from the drive holding the data, verifies block checksums, and streams. A large object's chunks are fetched in parallel from different extents, so a 5 GB GET is limited by the client's bandwidth rather than one drive's.

ℹ

Correctness budget: what is reversible, and when. Before ⑥ commits, nothing is visible: a crash anywhere in ①–⑤ leaves at most unreferenced bytes in an extent, which compaction reclaims (§9). After ⑥ commits, the object is durable in three zones and visible to every reader. A crash between ⑥ and ⑧ leaves a committed object and a client that saw a timeout; that is the retried-PUT case in §5b, and it is why conditional headers exist.

PUT latency, 1 MB, p99 target 200 ms. Receiving the body from an in-region client: ~1 ms at 10 Gb/s, ~8 ms at 1 Gb/s. Signature and cached policy: under 1 ms. Append ③–④: a cross-zone round trip of 0.5–1 ms plus an SSD fsync of 0.1–1 ms, a few milliseconds typically and ~20 ms at p99, because it waits for the slowest of three. Commit ⑥–⑦: one consensus round across zones plus an fsync, again a few milliseconds typically and ~20 ms at p99. Typical total: 10–15 ms. The large headroom to 200 ms is deliberate. It absorbs a seal-and-retry on a fresh extent when a replica stalls, and it is what coding on the PUT path would have spent waiting for the slowest of eighteen drives.

GET first byte, p99 target 100 ms. Front end and auth: ~1 ms. Index read: 1–5 ms, ~10 ms at p99. Extent location: from cache, effectively zero; a miss costs one more ~2 ms lookup. Drive read: a 4–10 ms seek before any queueing, and queueing on a busy drive is where the tail lives, so allow ~50 ms at p99. That closes at about 60 ms, inside the target with room to spare, and adding stage p99s overstates the real tail.

Hedging a drive that is slow but not dead (L7) ›

The budget above holds at p99. If the target were p99.9, or the fleet had drives that degrade slowly instead of failing outright, the drive read is where it would break, and the tool is a hedged read: if the read has not returned by about 30 ms, roughly its p95, a second read is issued elsewhere. For a recently written object still in a replicated extent, the hedge is one read of another replica. For a coded extent, it is a degraded read of ten fragments, so hedges on coded data are capped at a small fraction of reads; unlimited hedging would multiply load exactly when drives are already slow.

07

Data model: an ordered index of keys and a map of extents

There are four kinds of thing to store: buckets, object versions, in-progress multipart uploads, and extents. They are used in very different ways, by very different parts of the system, at rates that differ by six orders of magnitude, and those differences decide the schema before any field is chosen.

Operation Frequency Query shape
GET / HEAD the current version ~900,000/s at peak Point read on (bucket, key), newest version first
PUT / DELETE ~100,000/s at peak Conditional insert of a new version at (bucket, key)
LIST by prefix A small share of reads, but each returns up to 1,000 rows Range scan from (bucket, prefix) in key order
Resolve an extent's locations Every GET that misses the front-end cache Point read by extent id
Seal and code an extent Millions a day Rewrite one extent's entry: replicas out, fragments in
Repair a failed drive ~6 a day, ~160,000 fragments each "Every extent with a fragment on drive X"
Lifecycle, inventory, compaction Continuous, in the background Full scans of a bucket's keys; liveness checks of an extent's contents

Two observations force the schema. First, LIST is a range scan in key order, so object records must be stored sorted by bucket and key, and partitioned by key range rather than by a hash of the key. Hashing would spread photos/2026/09/ across every partition and turn each LIST page into a scatter-gather over thousands of them. The cost of ordering is that adjacent keys share a partition, and adjacent keys are exactly what a date-prefixed workload hammers; §9 handles that.

Second, bytes move far more often than objects change. Even at half the peak PUT rate, about four million extents a day seal and get coded, each holding around a thousand 1 MB objects. If object records stored drive locations, coding alone would add some 50,000 index writes a second, and every drive failure would rewrite 160,000 fragments' worth of records. So records point at extents, and only the extent map knows where extents are. That one level of indirection keeps every background process in §9 out of the index.

Two-level indirection: an object record in the index points to an extent id, offset and length; the extent map resolves the extent id to the eighteen drives holding its fragments; repair and coding change only the extent map Object index (1 trillion rows) media-prod / photos/2026/09/17/cat.jpg v 3sL4kqtJ... (newest) size 1,048,576 crc32c yZRlqg== chunk: extent 88121 off 41 MB, len 1 MB Extent map (~1 billion rows) extent 88121 state CODED, RS(10,8) epoch 7 D1 → A / rack A1 / drv 0413 D2 → A / rack A2 / drv 7702 ... P8 → C / rack C6 / drv 5190 Storage nodes fragment files of 100 MB, checksummed per block; no keys, no buckets drv 0413 (holds D1): read 1 MB at offset 41 MB changes on PUT, DELETE, compaction changes on seal, code, repair, rebalance changes when bytes are written Four million extents coded a day, and not one object record rewritten.
Figure 4 — Each layer changes for different reasons, at different rates. The 1 MB object at offset 41 MB of a 1 GB extent falls inside D1, the first 100 MB data fragment, so a healthy read is one drive.
-- Object index: range-partitioned by (bucket_id, key); ~10,000 partitions, 3 replicas each
object_version (
  bucket_id       BIGINT,
  key             BYTES,          -- up to 1,024 bytes, compared bytewise for LIST order
  version_ts      BIGINT DESC,    -- commit timestamp; newest sorts first
  version_id      STRING,         -- opaque id returned to clients
  is_delete_marker BOOL,
  size            BIGINT,
  etag            STRING,
  checksum        BYTES,          -- client-visible, e.g. CRC32C of the whole object
  storage_class   ENUM,           -- STANDARD, INFREQUENT, COLD (offline archive tiers are out of scope)
  chunks          LIST<(extent_id BIGINT, offset BIGINT, length BIGINT)>,
  encryption      (kms_key_id, wrapped_data_key),
  user_metadata   MAP<STRING, STRING>,   -- bounded, ~2 KB
  PRIMARY KEY (bucket_id, key, version_ts DESC)
)

-- Extent map: owned by the extent manager, hash-partitioned by extent_id
extent (
  extent_id   BIGINT PRIMARY KEY,
  state       ENUM,              -- OPEN, SEALED, CODED
  length      BIGINT,
  scheme      ENUM,              -- REPLICA_3, RS_10_8
  epoch       INT,               -- bumped on every move; caches compare it
  locations   LIST<(fragment_idx, drive_id, checksum)>
)
drive_extents (drive_id, extent_id)  -- secondary index: what to rebuild when a drive dies

-- Multipart: separate so parts are invisible to GET and LIST
upload (bucket_id, key, upload_id, created_at, parts MAP<int, (etag, size, extent_id, offset)>)

-- Buckets: small, globally unique names, read on every request and cached
bucket (name PRIMARY KEY, owner, versioning, policy, lifecycle_rules, created_at)
Why the version sorts newest-first inside the key ›

Versioned or not, every object is stored as a list of versions under its key, and the current version is simply the first row. A GET is then a range read of one row starting at (bucket, key), the same shape as with no versioning at all, and an unversioned bucket is just one where each PUT also deletes the previous row. A delete marker is a version with no chunks; GET sees it first and returns 404, while ListObjectVersions sees everything under it. Using the commit timestamp from the partition leader as the sort key gives a single order per key, which is the "last commit wins" rule in §5b.

The cost shows up when versions pile up. A key overwritten daily for a year has 365 rows, and a prefix whose objects were all deleted is a run of delete markers with nothing live under them. A GET still reads only the first row, but LIST has to step over every noncurrent row and marker to find the next live key, so a heavily versioned bucket lists slowly and a mass delete can make LIST slower rather than faster. Two things keep it bounded. The partition can keep a skip pointer per key to the next key's current version, so LIST jumps over the history. And lifecycle rules (NoncurrentVersionExpiration, and ExpiredObjectDeleteMarker for markers with nothing behind them) remove the rows themselves, which is the fix that also stops the storage bill growing.

Why a chunk list rather than a single pointer ›

An object larger than what one append should carry, and every multipart object, spans several extents. The chunk list is ordered, so a range GET maps directly to the chunks that cover it, and completing a multipart upload is writing a chunk list made of the parts' pointers without copying a byte. Most objects are small and have exactly one chunk, so the common record stays small.

Why checksums exist at three layers ›

Each one catches something the others cannot. The object checksum in the record is computed by the client and checked at the front end, so it catches corruption in transit and in the front end's own memory, and a client can re-verify it after download. Per-block checksums inside fragment files are checked by the storage node on every read, so a drive returning wrong bytes is caught before they leave the machine. Per-fragment checksums in the extent map let the scrubber and the erasure coder verify a whole fragment against what was written, which catches a fragment that is internally consistent but wrong, such as a write that landed at the wrong place.

Why the extent map carries an epoch ›

Front ends cache extent locations, and extents move. The epoch is bumped every time an extent's locations change, and a storage node refuses a read for a fragment it no longer holds or holds under a newer epoch. The front end treats that refusal as a cache miss, refetches, and retries. A stale location therefore costs one extra round trip and never returns wrong data, which is the property §8 relies on to cache the map freely.

08

Caching the read path: bytes and locations, never the object record

A GET does three lookups in a row: the object record, the extent's location, then the bytes. They look alike, but they change at completely different rates, and how safe each one is to cache depends entirely on how often it changes. The record changes on every PUT to its key. An extent's location changes when background work moves fragments, and it carries an epoch. The bytes at a given extent, offset and length never change at all.

Lookup Changes when Cached? Why that is safe
Object record Every PUT or DELETE on the key No A stale record is a read-after-write violation. Reading it fresh costs about 100 requests a second per partition at peak (§3), which the index absorbs easily
LIST page Every PUT or DELETE in the range No Same reason. The continuation token is the last key returned, so each page is a fresh range scan from there
Bucket config and policy Rarely, by the owner Yes, for seconds Bucket configuration is eventually consistent by contract, and S3 documents it that way; only object operations promise read-after-write
Extent location Seal, code, repair, rebalance Yes, no expiry A storage node rejects a read under an old epoch, so a stale entry costs one refetch and never returns wrong bytes (§7)
Chunk bytes Never Yes, hot chunks Keyed by (extent, offset, length), not by name. An overwrite writes new bytes at a new location, and the old entry just ages out

Reading the record fresh is cheap because the partition leader holds a lease, so a linearizable read is a lookup on the leader, not a consensus round. The cost is one extra hop, about a millisecond when the leader is in another zone, plus the lookup itself: 1–5 ms in the GET budget in §6. That budget has room for it. What the design avoids is the harder problem that comes with a cached record, which is invalidating it on a thousand front ends within the time it takes a client to send its next request.

Aggregate load has headroom (§3); the byte cache exists for the single hot object. A 1 MB object that has been coded lives in one fragment on one hard drive, and a hard drive serves somewhere around 100 random reads a second. The request-rate NFR promises 5,500 reads a second per prefix, and nothing stops a customer from sending all of them to a single key: a release artifact, a popular image, a config file every container fetches at startup. Without a cache, that key is capped at about 1/50th of the documented rate by a single drive.

So each front end keeps hot chunks in memory, with a local SSD behind it. Peak egress is 0.9 M reads of 1 MB a second, 900 GB/s, and at around 1 GB/s of useful throughput per front end that is on the order of a thousand front ends. Each one caches independently. A key that suddenly gets popular costs at most one drive read per front end to warm, a thousand reads spread over a few seconds, and after that the drive sees nothing. Because entries are immutable, the cache needs no invalidation, no coherence protocol, and no coordination between front ends. Chunks larger than a few megabytes bypass it; those reads are sequential, spread across many extents, and bound by bandwidth rather than seeks.

Why not a separate, shared cache tier? ›

A shared tier, with chunks assigned to cache nodes by consistent hashing, would have a better hit rate, because each chunk is held once instead of up to once per front end. It would also add a network hop to every hit, a fleet to operate, and its own hot-key problem: the one popular chunk would land on one cache node. Per-front-end caches spread a hot key across the whole front-end fleet by construction, because the load balancer already spreads requests there. The memory duplication matters only if the hot set is large relative to a front end's memory, and hot sets in object storage are usually small. If a workload proved otherwise, the tier would be the thing to add, and the immutability that makes the current cache simple would make that one simple too.

Can the record be cached with a staleness check? (L7) ›

Yes, and this is how S3 went from eventual to strong consistency in 2020 without removing its metadata cache. AWS has described adding new replication logic to its persistence tier, plus a component it calls a witness, which the cache consults to find out whether an entry it holds is stale. The check still reads something fresh, but that something is far smaller and cheaper than the full index.

That trade pays off when index reads are the expensive part: for a very large installed base, a metadata tier built before strong consistency was a requirement, or read rates per partition far higher than this design's ~100 a second. For a design built from scratch at this scale, a leader-lease read from the partition already provides the guarantee with one system instead of two. Being able to explain why the witness exists, and when it is worth building, is a strong L7 signal.

Where a CDN fits ›

Outside the store. For public content served to end users, the object store is the origin and a CDN sits in front of it, configured by the customer. The store's job is to be a good origin: ETags and Last-Modified for conditional revalidation, range reads for large media, and presigned URLs (§5b) for private content. Building edge caching into the store would mean serving global read latency, which §2 never asked for.

09

Scaling: hot prefixes, hot drives, repair, and reclaiming space

§3 showed that aggregate capacity is not what breaks. Four things break instead, either in one place or slowly over time: a key range that runs hot, a drive that runs hot, the constant stream of repairs, and space that deletes leave behind. Each has its own mechanism below. The region-wide layout they operate on comes first.

One region across three availability zones: load balancers spread clients across zones; each zone runs front ends, one replica of every index partition, a replica of the extent manager, six racks each holding one fragment of a stripe, and background workers; the index replicas form consensus groups across the zones Clients in the region Load balancers: DNS spreads connections across all three zones Zone A Zone B Zone C Front ends + hot-chunk cache Front ends + hot-chunk cache Front ends + hot-chunk cache Index: 1 replica of every partition partition P17: leader Index: 1 replica of every partition partition P17: follower Index: 1 replica of every partition partition P17: follower Extent manager replica Extent manager replica Extent manager replica 6 racks of dense HDDs 6 racks of dense HDDs 6 racks of dense HDDs D1 D2 D3 D4 P1 P2 D5 D6 D7 P3 P4 P5 D8 D9 D10 P6 P7 P8 one fragment of a stripe per rack one fragment of a stripe per rack one fragment of a stripe per rack Background workers code · repair · scrub · compact Background workers code · repair · scrub · compact Background workers code · repair · scrub · compact Lose any one zone: every partition keeps a majority, and every stripe keeps 12 of its 18 fragments. request path consensus replication across zones
Figure 5 — The whole region. Every zone is a complete copy of the machinery but holds only a third of each stripe. A front end in any zone reads the index leader and fragments wherever they are, so a GET crosses zones routinely; a cross-zone round trip is cheap enough (§12) that the design does not try to avoid it.

Hot key ranges: splitting the index

A partition splits for two reasons: size, at around 50 GB, and load. A load split picks the key where the partition's recent traffic divides in half and hands the upper range to a new consensus group. Splits are how a single bucket reaches millions of requests a second: each new range serves its own 3,500 writes and 5,500 reads a second. Traffic is not redirected at once, which is why §5b's 503 SlowDown exists. Clients that back off and retry land on the new partition once the split completes.

Splitting has a blind spot that key ordering creates. If keys are written in increasing order, such as logs/2026-09-17T10:42:01Z-..., every new write lands at the end of the key space, and however the range is split, the last partition takes all of them. A monotonic key sequence therefore caps at one partition's write rate. The fix is on the key, not in the store: put a high-cardinality component, such as a shard number or a hash, before the timestamp so writes spread across ranges. The store's part is to make that limit explicit, per prefix, rather than let it show up as mysterious latency.

Hot drives: placing by heat and free space

The naive placement rule, which sends new extents to the emptiest drives, fails in a way that looks harmless: a freshly installed rack is the emptiest thing in the region, so it receives nearly every new extent, and new data is the data being read. The rack runs hot while older racks idle. Placement therefore weighs recent IOPS as well as free space, and caps the share of new writes any drive can take. New drives fill mostly through the rebalancer instead, which moves cold fragments onto them. Every move is a copy plus one extent-map update (§7); no object record changes.

Repair: declustered and prioritised

At 6.2 drive failures a day (§3), repair never stops. Rebuilding a dead drive onto one spare is the wrong model. Writing 16 TB at around 200 MB/s takes about 22 hours, and reading ten times that from its stripes' survivors would pile onto a handful of drives. Instead, each of the dead drive's ~160,000 fragments is rebuilt independently: a worker reads ten surviving fragments of that stripe, decodes the missing one, and writes it to a drive that respects the zone and rack rule. Thousands of drives each do a sliver. Meeting the 6-hour window from §3 takes 16 TB ÷ 6 h, about 0.74 GB/s written and 7.4 GB/s read per failure, well under a percent of the fleet's bandwidth.

Not every missing fragment is equally urgent. A stripe missing one fragment still has seven spare; a stripe missing four is halfway to its margin. The repair queue is ordered by fragments missing per stripe, and a repair budget, a capped share of each drive's IOPS, keeps rebuilds from starving customer reads. When stripes get close to their margin, the budget is lifted. Alongside repair, a scrubber reads and verifies every fragment's checksums on a fixed cycle, so latent sector errors are found while the stripe can still rebuild them. A two-week cycle over 1.8 EB is about 13 MB/s per drive, 5–7% of a drive's 200–250 MB/s of sequential bandwidth.

Reclaiming space: compaction

A DELETE or an overwrite changes only the index, and the old bytes stay where they are inside a sealed, coded extent. The extent manager keeps an approximate garbage count per extent, fed by deletes. Once an extent crosses a threshold, a compactor rewrites it. It reads the list of (key, version, offset) that every extent keeps in its footer, checks each entry against the object index and the multipart upload table, since a part of an open upload is live but has no object record yet, and copies the live bytes into a new extent. It then moves each record with a conditional update, "only if this record still points at extent E, offset O", because the key may have been overwritten while the copy ran. Compaction is the one background process that writes to the object index, so it is throttled.

The threshold trades space for rewrite work. Compacting an extent that is g garbage reclaims g and copies 1 − g, so at 50% garbage every byte reclaimed costs one byte copied, and at 20% it costs four. The old extent is not deleted when the copy finishes. It is scheduled for deletion days later, because a bug in the liveness check would otherwise become permanent data loss within the hour (§10).

Spreading one customer across the fleet ›

The placement idea also works at the workload level. Spreading each bucket's extents across as many drives as possible means any one customer's burst is a small share of every drive it touches, rather than all of a few drives. AWS has described customers whose data is spread across more than a million drives. Spreading is what lets the fleet absorb a single customer's peak, because at that point the aggregate headroom from §3 actually applies.

What a split actually does, and why clients see 503s ›

The cheap part is logical: the partition's LSM files already hold the upper range, so the new partition can start by referencing those files and compact its own copy later, rather than copying 25 GB up front. The part that takes time is everything around it. A new consensus group has to be formed with a replica in each zone, the partition map has to be updated, and every front end's cached copy of that map has to find out. Front ends learn lazily: a request sent to the old owner for a key it no longer owns is rejected with a redirect. During the handoff, the old partition sheds load it cannot serve by returning 503 SlowDown instead of queueing it, because queueing would turn one hot prefix into latency for every key sharing that partition.

Colder data: a wider code ›

Lifecycle rules (§2) move objects nobody reads into cheaper storage classes. For data that is rarely read, the costs of a wide code stop mattering: repair reads more fragments, and a degraded read decodes from more of them. RS(20,13) puts 33 fragments across three zones, 11 per zone, and still has two spare after losing a zone, at 1.65× instead of 1.8×, which is about 8% fewer drives for the same bytes. Repair and degraded reads now touch 20 fragments rather than 10, which is fine for data read a few times a year and bad for data on a web request path. Moving an object between classes is a copy into an extent coded the other way plus a conditional record update, the same mechanics as compaction. Lifecycle rules are evaluated by a per-partition scan that emits transition tasks into a throttled queue, so a rule change on a billion-object bucket becomes a backlog, not a spike.

Choosing the code is close to a permanent decision. Re-encoding 1 EB means reading and rewriting all of it; at the ~50 GB/s of spare bandwidth a fleet can give background work without hurting customers, that is more than 200 days. A new code therefore usually applies to new data and to data moving class anyway, and the old code ages out.

If your NFRs included surviving a region: cross-region replication ›

§1 scoped this design to a single region, and nothing in §2 needs more. When a customer does, the standard answer is asynchronous, per-bucket replication rather than a multi-region commit. Each index partition emits an ordered change log of committed versions. A replication service consumes it, copies the bytes to a bucket in another region, and writes the version there with the same version id. Synchronous replication would put a 70–100 ms cross-region round trip (§12) inside every PUT and blow the 200 ms budget in §6.

The cost of going asynchronous is a recovery point objective, not zero loss: writes committed in the last few minutes before a region is lost may not have been copied yet. S3 sells a replication time guarantee of 15 minutes for 99.99% of objects as a paid option, which puts a number on that trade. The destination bucket is also eventually consistent with respect to the source, so a client that fails over must tolerate reading a slightly older version.

10

Failure modes: dead drives, a lost zone, and bugs that delete data

Hardware failure is the part of this system that is already designed for: a drive dies every few hours, and §5 and §9 turn that into routine work. The failures worth discussing are the ones where the routine machinery is the wrong response, such as rebuilding a zone that will be back in an hour, and the ones that redundancy cannot help with at all, because the store's own software did the damage and then faithfully replicated it.

Scenario Problem Solution Level
A drive dies under a GET The object's data fragment is unreadable Degraded read: fetch the same range from ten surviving fragments and decode. The client sees a slower read, not an error. The drive's fragments join the repair queue (§9) L4
Connection drops halfway through a 2 GB upload Nothing was committed; the client has no way to resume a single PUT Nothing to clean up on the read side, because the commit point was never reached (§6). Large objects use multipart, so a retry resends one part rather than the whole object (§5b) L4
Index partition leader crashes Its key range cannot commit or serve linearizable reads until a new leader holds the lease The group elects a new leader in the surviving replicas within seconds. Every acknowledged write was already on a majority, so none is lost. Requests in the gap get a retryable 503 L5
Front end commits a PUT, then dies before responding The client times out and retries a write that already succeeded The retry commits the same bytes again as a newer version. That is harmless only if no other client wrote the key in between; otherwise an older value reappears. Clients that care send If-None-Match: * or If-Match with a client token in user metadata, and read a 412 by checking the token (§5b) L5
One replica of an open extent stops acknowledging Appends to that extent cannot complete with three copies Seal the extent at its last fully acknowledged length and continue on a fresh extent (§6). The sealed extent has one short replica, so it goes to the front of the coding queue L5/L6
Silent corruption: bit rot, or a write that landed in the wrong place The drive returns bytes without an error, and they are wrong Block checksums are verified on every read and by the two-week scrub (§9). A bad block is treated as a lost fragment and rebuilt from the stripe; the client gets the reconstructed bytes, never the bad ones L5/L6
An availability zone goes dark A third of every stripe is gone, and so is one replica of every partition and every open extent Reads decode, writes go to two zones, and repair mostly waits. The three decisions are covered below the table L6
A batch of drives with the same firmware fails together Failures are correlated, so the independence assumption behind §3's durability card no longer holds Mix drive models and batches across a stripe's racks, so a bad batch holds few fragments of any one stripe. Track failure rates per batch and drain a suspect batch before it fails L6
A bug in compaction or GC marks live data as garbage Redundancy does not help: the system deletes every fragment, correctly and durably Deletion is deliberately slow. Extents are deleted days after being unreferenced, the index keeps its change log for the same window, and a separate auditor re-checks liveness before anything is freed. Deploys roll out one zone at a time, so a bad build affects one zone first L7
Index and data drift apart A crashed PUT leaves bytes with no record; a bug could leave a record with no bytes The first is expected: bytes referenced by neither the object index nor an open upload are reclaimed once older than a grace period G. The second must not occur structurally. Bytes are written before the record (§6), and each append carries its time, so a commit or a multipart Complete that points at appends older than G is rejected and re-uploaded rather than pointing at reclaimed bytes. A continuous audit compares extent footers with the index to catch a bug that gets past both L7
A zone is declared permanently lost Two zones cannot hold RS(10,8) under the zone rule, and 600 PB of fragments are gone A capacity and risk decision, not an automatic one: rebuild into a replacement zone, reading about 1 EB and writing 600 PB. At ~100 MB/s per surviving drive that takes days. In the meantime the stripes have two spare fragments, and repair is spent on stripes that lose more L7/L8

A zone outage, in numbers

Reads. On average a third of all data fragments sit in the lost zone, so a third of reads become degraded reads of ten fragments each. At peak, 0.9 M reads a second become 0.3 M × 10 + 0.6 M = 3.6 M, which is 4× the load. If the lost zone happens to hold four of a stripe's ten data fragments, as in Figure 2's layout, the figure is 4.1 M, or 4.6×. The surviving 75,000 drives manage about 7.5 M random reads a second between them, so the outage takes them to roughly half their random-read capacity. That is survivable because in normal operation the fleet runs at under a tenth of its random-read capacity (§3); even at 95% full, with fewer drives, the outage would reach only about 60%. The hot-chunk cache (§8) matters more during an outage than at any other time.

Writes. An open extent needs three replicas, and only two zones are left. New extents are placed two plus one across the surviving zones, which still survives the loss of either zone. Coding pauses, because 18 fragments over two zones means 9 per zone, one more than the code can lose. New data stays replicated at 3× until the zone is back. At an average ingest of 50 GB/s that costs about 5 PB of extra raw storage per day of outage, small against the free space the 80% fill leaves.

Repair. The routine repair loop would treat the outage as 37,500 dead drives and try to rebuild 600 PB into two zones that cannot legally hold it. So repair distinguishes unavailable from lost. A zone that is unreachable is assumed to come back, and only stripes that also lose fragments in the surviving zones get repaired, temporarily breaking the zone rule if needed, since a stripe with one spare is in more danger than a stripe that is lopsided. Declaring a zone permanently lost is left to a human, because its cost is measured in days of degraded capacity.

⚠

The failure redundancy cannot fix. Every mechanism in §5 protects against losing bytes the system still intends to keep. None of them helps if the system decides to throw bytes away, because a deletion is replicated as faithfully as a write. At this scale, software is a larger durability risk than disks, which is why the table answers the compaction-bug row with slow deletion and an independent audit, not with more parity.

10b

Abuse and compliance: public buckets, ransomware, and deletion that must be real

Object storage is where an organisation's data ends up by default. Backups, logs, datasets and user uploads all land there, often without anyone deciding who should be able to read them. Most incidents involving object stores are therefore access failures: data that was readable when it should not have been, or deleted by someone who should not have been able to. Exposure is handled by defaults: account- and bucket-level public-access blocks override any policy that tries to grant public access, and policy evaluation denies unless something explicitly allows it.

Ransomware and malicious deletion

An attacker holding stolen credentials with delete permission can do in minutes what no hardware failure can, and the store will replicate the deletions to all three zones. Versioning helps only partly: a DELETE becomes a delete marker, and older versions survive, until the attacker deletes those as well. Object Lock (§5b) closes the gap. In compliance mode, a version under retention cannot be deleted or overwritten by anyone, including the account root, until its retention date passes. Enforcement lives in the index partition: the delete path checks the retention field in the version record before committing a removal, so no credential can bypass it. That makes it the answer to ransomware and to regulations that require write-once storage.

Encryption, and deletion that has to be real

Since January 2023, S3 encrypts every new object by default. The usual design is envelope encryption: each object gets its own data key, the bytes are encrypted with it, and the data key is stored in the object record wrapped by a key in a key management service. A customer who revokes access to that outer key makes their data unreadable immediately, without the store moving a byte.

This matters because §10 makes deletion deliberately slow, while privacy law requires erasure to be real. The two fit together. When a version is deleted, its record, and the wrapped data key with it, leaves the index at once. What lingers for the grace period is ciphertext in unreferenced extents, plus the index change log that also expires within that window. Erasure is therefore complete within a bounded, documented time, and anything recovered from the grace window is recovered deliberately, not by accident.

?

The probe that separates levels here: "An attacker has your admin credentials. What can they destroy?" An L5 answer is versioning. An L6 answer notices that versioning is undone by deleting versions, and brings in Object Lock and MFA delete. An L7 answer asks where the retention check is enforced, and puts it in the index's commit path rather than in the front end's authorisation, so that a compromised front end or a new API cannot skip it. It then points out the store's own grace period as a last line of defence that no customer credential can reach.

11

How to answer the object storage question at your level

Nearly every candidate draws the same boxes: a metadata service, a fleet of storage nodes, and a front end between them. The levels separate on what the candidate can say about those boxes. The biggest jump is from L5 to L6, where the candidate derives eleven nines instead of stating it, and derives the parity count from the zone rule.

L4 Separates metadata from bytes, and keeps copies ›
What good looks like
  • Splits the design into a metadata store mapping keys to locations and a fleet of nodes holding bytes, and explains why they are different workloads
  • Defines PUT, GET, DELETE and LIST with buckets and keys, and treats objects as immutable: an overwrite is a new object
  • Keeps several copies on different machines and checksums the bytes
  • Uses multipart upload for large objects, and can say why a single 5 GB stream is fragile
What separates L5 from here
  • "Three replicas" with no idea what that costs at an exabyte
  • Metadata as "a database", with no plan for a trillion rows or for LIST
  • No order between writing bytes and writing the record, so a crash can leave a pointer to nothing
L5 Sizes it, and makes the commit point explicit ›
What good looks like
  • Sizes the data plane by bytes and the index by object count, and sees that small objects inflate the index, not the disks
  • Brings in erasure coding for cost, with the overhead and repair trade stated
  • Writes the bytes first and commits the record second, and names the orphan that a crash leaves behind
  • Range-partitions the index so LIST is a scan, and reads from the partition leader for read-after-write
  • Knows a degraded read exists, and what it costs
What separates L6 from here
  • Picks RS(10,4) or similar without checking it against losing a zone
  • Erasure codes each PUT inline, so a 1 MB object becomes eighteen 100 KB writes and waits on the slowest
  • Object records point straight at drives, so every repair rewrites the index
  • No answer for a hot prefix beyond "add partitions"
L6 Derives the code from the zone rule, and decouples the layers ›
What good looks like
  • Derives fragments-per-zone ≤ m, the 1.5× floor it implies, and why the floor has no margin, then picks a code with spare fragments after a zone loss
  • Shows that three copies already clear eleven nines against independent failures, so the parity count is set by zones, not disks
  • Replicates open extents and codes sealed ones, and explains both the latency and the small-object reason
  • Adds the extent map as an indirection so background work never touches object records
  • Declustered, prioritised repair with the 6-hour window and the bandwidth it costs
  • Explains why monotonic keys defeat splitting, and puts a number on zone-outage read amplification
What separates L7 from here
  • Treats durability as a parity problem and stops there; nothing protects against the store deleting data itself
  • Repairs a zone outage like 37,500 dead drives
  • Correlated failures mentioned but not designed for
L7/L8 Treats durability as an operational property, and cost as a design input ›
What good looks like
  • Names software as the largest durability risk and designs for it: delayed deletion, an independent liveness auditor, deploys one zone at a time
  • Separates an unavailable zone from a lost one, and makes declaring a zone lost a human decision priced in days
  • Designs for correlated failure with batch diversity across a stripe and per-batch failure tracking
  • Treats the code as nearly irreversible: re-encoding an exabyte takes most of a year of spare bandwidth, so the choice is made for new data and left to age in
  • Knows the witness approach to cached metadata and when it beats a fresh read
  • Says what to watch: stripes by spare-fragment count, repair queue age, scrub coverage, degraded-read fraction, and 503 rate per partition
What an L8 adds
  • Treats storage classes and wider codes as the main cost lever, and can plan migrating an exabyte from one code to another without a durability dip
  • Knows when local reconstruction codes pay for their complexity, and when they do not
  • Treats verification as part of the design: AWS has published its use of lightweight formal methods to check a storage node against a reference model, because testing alone does not find the bugs that lose data
  • Frames cross-region replication as a product with a recovery point, not a default

Classic probes, and how the answers differ by level

Probe L4 L5 L6 L7/L8
"How do you get eleven nines?" Three copies on different machines, with a failed copy re-created quickly from the others Works it through with an AFR and a repair window, and uses erasure coding to reach it cheaply Shows independent failures are the easy part, and that losing a zone is what sets the parity count Correlated hardware and the store's own software are the real risks; durability is an operational process of scrubbing, auditing, and slow deletion
"Why not just replicate three times?" Replication triples the disks; knows erasure coding exists to cut that 3× against 1.8×: 75,000 extra drives at 1 EB Replicates on write and codes after sealing, because coding a PUT waits on the slowest of eighteen drives and shreds small objects Treats the code as a trade between overhead and repair bandwidth, with LRC and wider codes per storage class as the next levers
"A customer writes 10,000 objects a second with timestamp keys and gets 503s. Why?" Every write goes to the same place, because the keys are sequential One partition is hot; split it Monotonic keys all land at the end of the range, so splitting cannot help; the key needs a high-cardinality prefix Makes the per-prefix limit an explicit contract, sheds with 503 instead of queueing, and alerts on 503 rate per partition before customers file tickets
"After a PUT returns, can another client's GET see the old object?" Not if every GET reads the metadata from the same place the PUT wrote it No: GETs read the record from the partition leader The index write is the commit point; bytes and extent locations are cached because they are immutable, and records are not Explains leader leases and the clock assumptions behind them, and the witness design S3 used to keep its metadata cache while becoming strongly consistent
"One availability zone goes dark. What happens?" The other two zones still have the data, so reads keep working Reads of coded data become degraded reads, since a third of each stripe is gone Prices it: read load rises about 4×, which the fleet absorbs because it normally runs under a tenth of its random-read capacity; repair does not start yet Separates unavailable from lost, makes declaring a zone lost a human decision, and knows re-protecting it is 500 PB of rebuild measured in days
12

Numbers to know

Latency: the numbers that set the budget

Number Value What it settles
GET first byte, p99 100 ms in-region That the drive read, not the index read, is where the tail lives (§6), so the record can be read fresh on every GET (§8)
1 MB PUT, p99 200 ms in-region That there is room to seal and retry on a fresh extent when a replica stalls, and no room to wait on eighteen drives, which is why coding happens after the PUT (§5, §6)
Round trip across AZs, same region ~0.5–1 ms That committing synchronously across zones, all three replicas for the extent append and a two-of-three majority for the index, costs milliseconds, so surviving a zone needs no asynchronous path (§6, §9)
fsync on NVMe with power-loss protection ~0.1–1 ms Why appends are acknowledged from an SSD journal rather than the hard drive (§6)
Spinning-disk seek 4–10 ms, ~100 random reads/s That one hot object is capped by one drive unless the front ends cache its chunk (§8), and that a zone outage's 4× degraded reads fit only because drives start well below their IOPS (§10)
Hard-drive sequential bandwidth ~200–250 MB/s That rebuilding a 20 TB drive onto one spare takes about a day, so repair is declustered across thousands of drives (§9)
Indexed point read, warm 1–5 ms typical, 10 ms+ at p99 That a fresh index read on every GET fits the budget, so the design needs no metadata cache and no witness (§8)
Cross-region round trip (US↔EU) ~70–100 ms That cross-region replication has to be asynchronous, with a recovery point, because one round trip is half the PUT budget (§9)
How the pieces connect
Every decision in this design traces back to a requirement or a capacity number.
01
A storage cost ceiling of 2× raw per logical byte (§2) against 1 EB stored (§3) → three full copies would need 187,500 disks, and each 0.1× of overhead is another 6,250 → sealed data is erasure coded, not replicated (§5), landing at 1.8× and 112,500 disks.
02
Survive the permanent loss of one availability zone (§2) → no zone may hold more fragments of a stripe than the code can lose, which puts a floor of 1.5× under any three-zone code and leaves zero margin at the floor → RS(10,8), six fragments per zone, one per rack (§5, §9).
03
PUT p99 under 200 ms, and an average object of 1 MB (§2, §3) → coding each PUT would wait on the slowest of eighteen disks and shred small objects into slivers → writes append to a three-way replicated open extent, and the extent is coded in the background once sealed (§5, §6).
04
LIST in key order, strongly consistent, at thousands of requests a second per prefix (§2) → hash partitioning scatters a prefix across every shard → the index is range-partitioned, each partition a consensus group across the three zones, splitting when a prefix runs hot (§7, §9).
05
A trillion object records (§3), and bytes that move constantly under repair, coding and compaction (§9) → rewriting object records every time a fragment moves would put the busiest background work on the biggest table → two-level indirection: key to extent, extent to fragments (§7). Placement changes touch only the extent map.
06
Strong read-after-write (§2) → a cached object record can be stale the instant after a PUT, but the bytes and extent it points to never change once written → every GET reads the record fresh from its partition, while bytes and extent locations are cached freely (§8).

System Design Mock Interviews

AI-powered system design practice with real-time feedback on your architecture and tradeoff reasoning.

Coming Soon

Practice Coding Interviews Now

Get instant feedback on your approach, communication, and code — powered by AI.

Start a Coding Mock Interview →
Also in this series