Engineering Atlas › System Design Interviews

Design an S3-style object store

21 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

Back-of-envelope

Round planning numbers for one large region, not any provider's published figures.

API

PUT /{bucket}/{key}
  Content-Length: 5242880
  x-checksum-crc32c: "yZRlqg=="
  If-None-Match: *                         # optional: create only
  -> 200 ETag: "a1f3..."   412 Precondition Failed   400 BadDigest

GET /{bucket}/{key}
  Range: bytes=0-1048575                   # optional
  -> 206 ETag: "a1f3..."  Content-Range: bytes 0-1048575/5242880

GET /{bucket}?list-type=2&prefix=logs/2026/10/&delimiter=/&max-keys=1000
    &continuation-token=...
  -> 200 { "keys": [{"key": "logs/2026/10/16.gz", "size": 812345, "etag": "..."}],
           "common_prefixes": ["logs/2026/10/archive/"],
           "next_token": "bG9ncy8yMDI2LzEwLzE2Lmd6" }

DELETE /{bucket}/{key}
  If-Match: "a1f3..."                      # optional
  -> 204   412

POST /{bucket}/{key}?uploads               -> { "upload_id": "u-77" }
PUT  /{bucket}/{key}?uploadId=u-77&partNumber=3   -> ETag
POST /{bucket}/{key}?uploadId=u-77  { "parts": [{"n": 1, "etag": "..."}] }
  -> 200 ETag: "9c0e...-12"

The front end verifies the client's checksum before committing, so corruption in transit is caught at the door. Multipart rules are covered in the S3 lesson; a completed upload is simply a commit whose layout lists every part.

Data model

High-level design

flowchart LR
  C["Client"] --> LB["Load balancer"]
  LB --> FE["Front end (auth, checksum)"]
  FE --> IX["Index ranges (Raft)"]
  FE --> PM["Placement map"]
  FE --> SN["Storage nodes (3 AZs)"]
  IX --> GQ["GC queue"]
  GQ --> GC["Collector"]
  GC --> SN
  SC["Scrubber and repair"] --> SN
  SC --> PM
  PMG["Partition manager"] --> IX

Write path. The front end authenticates, rate-limits and streams the body while computing its checksum. A small object (under about 1 MB) is appended to an open volume that is replicated three ways across AZs. A large object is cut into stripes, and each stripe is encoded 8+4 into 12 fragments written in parallel to the 12 drives of a placement group, 4 per AZ. When every fragment is acknowledged and the checksum matches the client's, the front end sends a commit to the index range that owns the key. The range leader checks any condition (If-None-Match, If-Match), appends the new entry to its Raft log, and replies once a majority has persisted it. Only then does the client get its 200. Any overwritten layout goes to the GC queue.

Read path. The front end asks the owning index range for the key; the leader answers under its lease, so the result reflects every acknowledged commit. With the layout in hand, it reads only the extents that cover the requested range, verifies each one's checksum and streams the bytes. If a data fragment is slow or missing, it reads parity fragments instead and decodes. LIST is a range scan from the prefix or continuation token, with delimiter roll-up done on the index server.

Deep dives

1. The commit protocol and the index

Why data first. If the index entry were written first, a reader could find a key whose bytes don't exist yet, or never will if the writer crashes. Writing fragments first and committing the index entry last means a reader sees the old version or the new one, never a mixture, and the commit is one small write to one consensus group however large the object is. The price is orphans: fragments from crashed uploads, abandoned multipart uploads and overwritten versions. A collector deletes an unreferenced blob only after a grace period of hours, which also protects readers that looked up the old layout just before an overwrite and are still streaming it. Deleting live data is the worst bug an object store can have, so the collector re-checks the index before every delete.

Index options. A hash-partitioned key-value store (in the style of DynamoDB) spreads load perfectly but cannot list keys in order without asking every partition. One database per bucket is simple but caps the size of a bucket. Range partitioning with a Raft group per range, as in Spanner, CockroachDB or TiKV, keeps keys sorted, gives linearizable conditional writes per key, and scales by splitting. I'd choose range partitioning. The known weak spot is sequential keys such as timestamps: every new key lands in the rightmost range, so splitting does not help write throughput. S3's documented guidance of at least 3,500 writes and 5,500 reads per second per prefix, scaling gradually beyond that with 503 Slow Down errors while it does, reflects the same pressure (S3 performance).

Strong consistency with caches. Reads go to the range leader, which uses a lease so it can answer without a round of consensus. Front ends may cache index entries for hot keys, but a cache that cannot tell whether it is stale breaks read-after-write. Each cached entry carries the range's commit index, which the front end checks against the leader's with a tiny request before serving it. AWS described the same idea when it made S3 strongly consistent in 2020: a new "witness" component is told of every object change and acts as a read barrier, so a cached entry is served only if it is not stale (All Things Distributed).

2. Durability: encoding, placement, scrubbing and repair

Encoding. Three-way replication costs 3x and survives 2 lost copies. An 8+4 Reed-Solomon code costs 1.5x and survives any 4 lost fragments. With 4 fragments per AZ, losing a whole AZ still leaves 8, enough to read every object. That leaves no margin during an AZ outage, which is acceptable because an AZ outage usually makes fragments unreachable rather than destroying them. A 9+6 code would add margin at 1.67x. Repair cost is the weakness of plain Reed-Solomon, which is why Azure Storage uses Local Reconstruction Codes: extra local parities let a single lost fragment be rebuilt from a small group instead of from 8 others (Huang et al., USENIX ATC 2012). The Erasure Coding lesson covers the maths. New small objects start in 3x-replicated open volumes, and a volume is erasure-coded once it is full and sealed, the same hot-to-warm path Facebook's f4 described (f4, OSDI 2014).

The nines. With a 1% annual drive failure rate (Backblaze measured 1.36% across about 344,000 drives in 2025, Backblaze) and 6 hours to repair, a fragment's drive fails within a given repair window with probability 0.01 × 6 / 8,760 ≈ 6.8 × 10^-6. Losing 5 of 12 independently in one window is about 792 × (6.8 × 10^-6)^5 ≈ 10^-23. That number is meaningless, because real risks are correlated: a firmware bug in one drive batch, a bad software release, or an operator deleting the wrong thing. The defences are mixing drive batches within placement groups, staged rollouts of storage software, and keeping deletes reversible for a while.

Checksums and scrubbing. Every record carries a CRC32C that is checked on write, on read and by a scrubber that reads every byte on every drive every few weeks. Disks return wrong data silently, and finding it early means repairing while the other 11 fragments are healthy. Checksums should start at the client: AWS-owned S3 clients now compute a checksum on every upload (CRC64NVME by default), and S3 recomputes and compares it before storing the object (S3 object integrity).

Repair speed. A dead 20 TB drive means rebuilding 20 TB of fragments, and each rebuilt fragment needs reads from 8 others: 160 TB of reads. One replacement drive writing at 250 MB/s would take 22 hours. Placement is therefore declustered: the dead drive's fragments belong to thousands of groups, so a thousand drives each rebuild a slice in parallel and repair takes under an hour.

3. Small objects, heat and the storage node

Small objects. Erasure-coding a 4 KB object into 12 fragments of a few hundred bytes would multiply IOPS by 12 and waste space on headers. Packing small objects into large volumes, the way Facebook's Haystack stored photos (Haystack, OSDI 2010), turns many small writes into sequential appends and makes each read one seek. Deletes leave holes, so a compactor rewrites volumes whose live fraction falls below a threshold, and the index entries move to the new location in the same commit protocol.

Heat. Drive capacity keeps growing while IOPS per drive stays flat, so the IOPS available per TB shrinks every generation. A freshly uploaded dataset is often read heavily for a day and then rarely touched. If its fragments sat on a handful of drives those drives would saturate, so placement chooses groups at random from the whole fleet, which spreads any one customer's hot data across thousands of spindles. Andy Warfield's FAST '23 talk describes heat management as a central problem at S3's scale. An SSD read cache absorbs most of the remaining hot reads.

Storage node software. It is deliberately simple (append, read by offset, verify, report health), because simplicity is a durability feature. AWS used lightweight formal methods to check that ShardStore, its storage node software, behaves like a simple reference model, including after crashes (SOSP 2021).

Down the stack

Layer 1The contract

A PUT that returns 200 is durable in fragments on drives in at least two AZs and visible to every later GET, HEAD and LIST in the region. An overwrite is atomic: concurrent readers get the old bytes or the new bytes, never a mix. If-None-Match and If-Match are evaluated against the latest committed version, so two writers racing to create the same key get one 200 and one 412. The check runs at commit time, so an in-progress multipart upload does not hold the key: S3 documents that the first write to finish wins and a later CompleteMultipartUpload with the same condition fails (S3 conditional writes). A GET never returns bytes whose checksum fails; it returns another copy, a reconstruction or an error. Deleted data may remain on disk for the grace period but is never served.

Layer 2The mechanism

Each index range is a Raft group whose log entries are {op, key, expected_etag, new_entry}. The leader evaluates the condition against its applied state, appends the entry and commits it when a majority has made it durable, then applies it to a local ordered store (an LSM tree in practice). A storage record on disk is a header (magic number, record ID, length, flags), the payload and a CRC32C trailer. Volumes are append-only, so a crash can only leave a torn tail, which the node detects by checksum and truncates. Stripe reads start parity reads when a data fragment misses a p95 deadline.

Layer 3The machine

A 7,200 rpm drive spends about 4 ms on rotation and 4 to 8 ms seeking, so a random read costs 8 to 10 ms (about 120 IOPS), while sequential transfer runs near 250 MB/s. That is why volumes are appended sequentially and why one small read should be one seek. Index writes go to NVMe SSDs where an fsync of a Raft log entry takes tens of microseconds on drives with power-loss protection, so a majority commit across AZs costs about one inter-AZ round trip (1 to 2 ms). CRC32C runs at several GB/s per core with the SSE4.2 instruction, so verifying every byte is cheap compared to the disk read. A 1 MB fragment read in parallel from 8 drives is dominated by the slowest of the 8 seeks, which is why hedged parity reads pay off.

Why it's built this way

  1. Disks are cheap per TB but slow per random operation, so data is laid out in large sequential volumes and small objects are packed.
  2. Full copies are too expensive at exabyte scale, so data is erasure-coded, which makes repairs read many fragments, so placement is declustered to spread repairs across many disks.
  3. Large data cannot be updated atomically in place, so atomicity comes from one small metadata commit after the data is safely written.
  4. That commit must be consistent and ordered for conditional writes and LIST, so the index is a range-partitioned set of consensus groups.
  5. Writing first and committing last leaves orphans, and real data loss comes from correlated faults, so collection is cautious and every byte is checksummed.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
3x replicationFast writes and reads; simple repair3x storage costOpen volumes, index, hot small data
Erasure coding 8+41.5x cost; survives 4 lossesRepair reads 8 fragments; decode costSealed volumes and large objects
Range-partitioned indexOrdered LIST; conditional writesSequential keys hit one rangeObject stores with LIST semantics
Hash-partitioned indexEven loadLIST must query every partitionPure key-value blob stores
Packed volumesFew IOPS per small objectCompaction after deletesWorkloads with many small objects
Long GC grace periodSafe for slow readers and bugsDeleted bytes cost money for longerDefault; hours to days

Leadership lens

Check yourself

  1. Why does a PUT write data fragments first and commit the index entry last, and what cost does that ordering create?

    Show answer

    Committing the index first could let a reader find a key whose bytes do not exist yet, or never will if the writer crashes. Data first and one small index commit last makes overwrites atomic, but it leaves orphaned blobs that a cautious collector deletes only after a grace period and an index re-check.

  1. Why is the index range-partitioned rather than hash-partitioned, and what is the known weak spot of that choice?

    Show answer

    LIST needs keys in sorted order, and a hash-partitioned index would have to ask every partition. Range partitioning with a Raft group per range keeps keys ordered and gives conditional writes per key, but sequential keys such as timestamps all land in the rightmost range, so splitting does not help their write throughput.

  1. Using the lesson's numbers, why does a dead 20 TB drive force declustered placement?

    Show answer

    Rebuilding 20 TB of fragments needs reads from 8 others, about 160 TB, and one replacement drive writing at 250 MB/s would take about 22 hours. Declustering spreads the dead drive's fragments across thousands of placement groups, so a thousand drives each rebuild a slice and repair takes under an hour.

  1. The independent-failure math gives data loss odds around 10^-23. Why does the lesson call that meaningless, and what defends against the real risk?

    Show answer

    Real losses come from correlated faults such as a firmware bug in one drive batch, a bad software release or an operator error. The defences are mixing drive models within placement groups, staged rollouts of storage software, and keeping deletes reversible for a while through grace periods and quarantine.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →