SysDesignPrep.com
System design interview question

Design an Object Storage Service

An S3-like store for objects from bytes to terabytes, with eleven nines of durability and strong read-after-write consistency at exabyte scale.

Last updated 2026-10-26. Difficulty: hard. Patterns: erasure-coding, range-partitioning, durability, strong-consistency, object-storage. Reported at Amazon, Google, Microsoft, Meta, Dropbox, Databricks.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.

Or sit it in the style of Amazon · Google · Microsoft · Meta, run as that company’s round is reported to go.

The short answer

How to design Object Storage Service in a system design interview, in five steps:

  1. Scope it. Buckets: create, delete, configure, PUT, GET, HEAD and DELETE an object by key, LIST keys by prefix, objects from 0 bytes to 5 TB, versioning, strong read-after-write consistency, lifecycle rules and storage classes, end-to-end integrity.
  2. Set the targets. Durability: 11 nines per object per year; Availability: 99.99 %, survives a data centre; Consistency: linearisable per key; Latency: first byte p99 < 100 ms.
  3. Do the numbers. Requests per second: ~10 M/s avg · ~20 M/s peak; Ingest bandwidth: ~230 GB/s in · ~350 GB/s to disk; Metadata index size: ~30 PB · ~90 PB replicated.
  4. Draw the design. IAM & KMS, Bucket registry, Partition map, Index change log, Lifecycle engine, Front end, Metadata index, GC & compaction, Archive tier.
  5. Go deep where it is hard.
    • Metadata plane and data plane → Separate planes: a partitioned index for names, immutable extents for bytes.
    • Partitioning the index, and hot prefixes → Range partitions split by size and by load, each a Raft group across three data centres.
    • Erasure coding, copies and eleven nines → RS 12+6, six fragments in each of three data centres.
    • Commit-last writes and strong consistency → The metadata commit as the linearisation point, leader-lease reads, caches only with invalidation.
    • Placement, repair and rebalancing → An extent map with weighted, failure-domain-aware allocation.
    • Deletes, versions and storage tiers → Delete markers and row removal in the index, GC from the change log with a grace period.

Functional requirements

  • Buckets: create, delete, configure. A bucket is a namespace with an owner and settings: versioning, lifecycle rules, access policy, default encryption. Written a few times a year, read on every request.
  • PUT, GET, HEAD and DELETE an object by key. Keys are up to 1,024 bytes of UTF-8 in a flat namespace; the slashes in photos/2026/10/cat.jpg mean nothing to the store. Objects are immutable: an overwrite writes a new object and never edits bytes in place. GET supports byte ranges.
  • LIST keys by prefix. In key order, with a delimiter to imitate folders and a continuation token for pages of 1,000. This one requirement decides how the index is partitioned.
  • Objects from 0 bytes to 5 TB. A single PUT up to 5 GB; above that, multipart upload: up to 10,000 parts of 5 MB to 5 GB, sent in parallel, retried one by one, and stitched together by a final manifest.
  • Versioning. When enabled, every overwrite and delete keeps the previous version, addressable by version id. A delete without a version id writes a delete marker instead of removing anything.
  • Strong read-after-write consistency. Once a PUT or DELETE returns success, every later GET, HEAD and LIST in the region reflects it. Conditional writes (If-Match, If-None-Match) let clients build compare-and-swap on top.
  • Lifecycle rules and storage classes. Per prefix and age: move objects to infrequent-access or archive classes, expire old versions, abort multipart uploads that were never finished.
  • End-to-end integrity. The client sends a checksum with every upload; it is verified on arrival, stored with the bytes, and checked again on every read and every background scrub.
  • Out of scope. Cross-region replication, event notifications, static website hosting, query in place, the CDN in front, and billing. Bucket names are globally unique, but the global name registry is treated as a lookup we call.

Non-functional requirements

  • Durability (11 nines per object per year). The design target AWS publishes for S3: store 10 M objects and expect to lose one every 10,000 years. Across our 100 trillion objects that still allows ~1,000 losses a year, so we design far beyond it on paper and spend the margin on correlated failures. Durability against cost is the hardest trade-off in the design.
  • Availability (99.99 %, survives a data centre). Reads and writes keep working with one of the region’s three data centres completely down. That single requirement forces every consensus group and every erasure-code stripe to span all three.
  • Consistency (linearisable per key). A successful write is visible to the next read anywhere in the region, LIST included. S3 has offered this since December 2020; before that, overwrites and deletes were eventually consistent and clients built workarounds.
  • Latency (first byte p99 < 100 ms). An uncached GET is one metadata lookup (1–5 ms) plus one disk seek (~10 ms). Large-object throughput is bounded by the client’s parallelism, not by us.
  • Request rate per prefix (3,500 writes/s · 5,500 reads/s, then split). The per-prefix rates S3 documents. Beyond them the index splits the hot range automatically; a single sequential hot point is the one case splitting cannot spread.
  • Cost (≤ 1.6 raw bytes per stored byte). At 10 EB, every 0.1× of overhead is another 1 EB of disk, about 60,000 drives at 80 % fill. Archive classes must cost a fraction of standard.
  • Scale (100 T objects · 10 EB · 20 M req/s peak). In one region. S3 has said publicly that it holds hundreds of trillions of objects. Object count stresses the index; bytes stress disks and repair.
  • Security (every request authorised, encrypted at rest). Signed requests are checked against identity and bucket policy on every call. Each object is encrypted with its own data key, wrapped by a key-management service, so deleting the key makes leftover fragments unreadable.

Back-of-envelope estimates

  • Requests per second: ~10 M/s avg · ~20 M/s peak. ~860 B requests a day ÷ 86,400 ≈ 10 M/s, with a 2× daily peak. Roughly 70 % GET and HEAD, 15 % PUT (multipart parts included), 3 % LIST. Every request touches the metadata index; only GET and PUT touch disks.
  • Ingest bandwidth: ~230 GB/s in · ~350 GB/s to disk. 20 PB/day of new bytes ÷ 86,400 ≈ 230 GB/s, about 1.9 Tbit/s. Coding at 1.5× makes it ~350 GB/s of fragment writes, and two-thirds of those cross data-centre links, because every stripe is spread evenly over three data centres.
  • Metadata index size: ~30 PB · ~90 PB replicated. 10^14 object versions × ~300 B per row (a ~100 B key, size, ETag, version id, storage class, user metadata, a wrapped data key, a pointer to the bytes) = 30 PB. Three replicas, one per data centre, = 90 PB on NVMe. The index of names is larger than most companies’ entire data, so it is a partitioned database in its own right, not a master’s in-memory table.
  • Index partitions: ~3 M · ~7 req/s each on average. 30 PB ÷ 10 GB per partition = 3 M partitions, each a three-replica consensus group: ~9 M replicas on ~3,000 metadata servers of 30 TB, ~3,000 replicas each. 20 M req/s ÷ 3 M ≈ 7 req/s per partition at peak, against a capacity of thousands. The average is irrelevant: the problem is skew, one customer’s sequential keys putting 20 k writes/s on one partition.
  • Drives: ~970 k drives · ~20 k servers. 10 EB × 1.55 blended overhead (1.5× for coded data, plus recent small objects still at 3× and dead bytes awaiting compaction) = 15.5 EB. Filled to 80 %, that is ~19.4 EB of disk ÷ 20 TB = ~970 k drives, ~20 k servers of 48 drives.
  • Drive failures: ~53 a day. 970 k drives × 2 % annual failure rate ≈ 19,400 a year ≈ 53/day, one every 27 minutes, plus a server or two a day lost to boards and power supplies. Failure is not an incident here; it is the background workload.
  • Repair bandwidth: ~120 GB/s, all day. A dead 20 TB drive at 80 % holds ~16 TB of fragments, and rebuilding each one reads 12 others: 192 TB of reads per drive. 53 a day × 192 TB ≈ 10 PB/day ≈ 118 GB/s, ~0.9 Tbit/s, more than half of it across data centres. Spread over 20 k servers it is ~6 MB/s each, affordable only because the rebuild is spread out.
  • Copies versus coding: ~900 k more drives for 3×. Three copies of 10 EB is 30 EB against 15.5 EB coded: 14.5 EB more, ÷ 0.8 fill ÷ 20 TB ≈ 900 k extra drives, nearly doubling the fleet. The choice of code is not a detail; it decides whether the service makes money.

Components

  • IAM & KMS (policy · data keys): Identity and policy evaluation, and the key-management service that wraps data keys. Operated by other teams and called on every request, so the front end caches derived signing keys and policy decisions for seconds. Object data keys are generated locally and wrapped by a cached bucket key, so KMS sees a call per bucket every few minutes, not one per PUT.
  • Bucket registry (small · consensus-replicated): Bucket name → id, owner, versioning state, lifecycle rules, policy and default encryption. Hundreds of millions of rows, read on every request and written rarely, so front ends cache it for seconds. That cache makes bucket configuration the one eventually consistent thing in the system, which S3 also documents for its own bucket settings.
  • Partition map (ranges → replica groups): Knows which contiguous key range belongs to which index partition, and where its three replicas and current leader are. Front ends cache it and are corrected by a replica that answers "not my range", so a stale map costs a retry, never a wrong answer. It also watches load and size per partition and orders splits, merges and moves, which is how hot prefixes are absorbed.
  • Index change log (per partition, commit order): Every committed mutation, streamed from each partition’s replicated log in commit order: put, delete, transition, multipart complete. GC uses it to keep exact live-byte counts per extent; lifecycle, billing and notifications consume it too. An event exists if and only if the commit did, because it is the commit log itself rather than a second write.
  • Lifecycle engine (daily scan per partition): Scans the index partitions of buckets that have rules, in key order, once a day, and applies them: transition to infrequent access or archive by age, expire noncurrent versions, remove expired delete markers, abort stale multipart uploads. Every action is a conditional metadata commit, so a client overwrite during a transition simply wins.
  • Client / SDK (signed HTTPS): Applications, data pipelines and backup tools speaking the S3 REST API through an SDK. The SDK signs every request, computes a CRC of the payload before sending, switches to multipart above ~100 MB, and parallelises big downloads with byte-range GETs. Most "storage is slow" complaints are clients making one request at a time.
  • Front end (auth · route · encode): A stateless fleet in every data centre behind DNS and load balancers. It authenticates and authorises each request, finds the key’s index partition, streams bytes to and from storage (erasure coding and decoding on the fly), and commits metadata last. It stores nothing, so losing a front end loses only the requests in flight.
  • Metadata index (range-partitioned · Raft ×3 DCs): One row per object version, keyed (bucket, key, version descending) and kept sorted, so LIST by prefix is a range scan and the latest version is the first row at its key. About 3 M partitions of ~10 GB, each a three-replica consensus group with one replica per data centre. The leader’s commit of a row is the moment an object exists, and leader-lease reads make every GET and LIST see the latest commit.
  • GC & compaction: Keeps a running count of live bytes per extent from the change log, frees extents with no live bytes for a 48-hour grace period, and compacts mostly dead small-object extents by copying live objects out and repointing their rows with compare-and-set. A weekly reconciliation against a full index scan catches drift. It is deliberately slow and conservative, because a GC bug is a data-loss bug.
  • Archive tier (dense, mostly spun down): The cold storage class: high-density drives kept mostly powered down, written in large sequential batches and read back only through an explicit restore that takes minutes to hours. The same failure-domain rules as standard apply; the saving comes from cheaper media and almost no provisioned IOPS, roughly a tenth of the cost per byte.
  • Fragment cache (SSD · immutable data): An SSD cache of byte ranges keyed by (extent id, offset), in front of the disks. Extents never change after they are written, so a cached range can never be stale and there is nothing to invalidate. It absorbs release-day spikes on popular objects and protects an HDD fleet that offers only ~7 IOPS per TB.
  • Replicated extents (3× · one copy per DC): Append-only extents of up to 1 GB that hold small objects (under 1 MB) packed Haystack style: header, bytes, checksum. Each has three replicas, one per data centre, and a single writer at a time. An extent is sealed when full or when a replica falters, and sealed extents that stay cold for 30 days are re-encoded to 12+6 in the background, as Facebook’s f4 and Microsoft’s Azure Storage describe.
  • Extent manager (extent → disks · placement): Maps each extent id to its scheme and the disks holding its 3 replicas or 18 fragments: billions of rows, a few TB, partitioned and consensus-replicated. It allocates new extents by free space under failure-domain rules, takes heartbeats from every storage server, and drives repair and rebalancing. Object rows name extents, never disks, so moving data never touches the 30 PB index.
  • Coded extents (RS 12+6 · 6 fragments per DC): Large objects stream through an encoder in 1 MB cells: 12 data and 6 parity fragments per stripe, on 18 servers, six in each data centre, up to 768 MB of data per extent. That is 1.5× storage with any six fragments losable, so a whole data centre can vanish. The code is systematic, so a healthy read returns the original bytes with no decoding.
  • Scrub & repair (priority by spares left): Rebuilds lost fragments from 12 survivors, ordered by redundancy remaining so a stripe at its last spare goes first, and spread across thousands of disks so a 20 TB drive is re-protected in about an hour instead of the 28 it takes to copy onto one spare. The scrubber reads every disk end to end every two weeks and checks every cell’s checksum, because disks do not report all their errors.

User flows

  1. PUT a small object: bytes first, commit last. The core write. The bytes are durable in three data centres before any metadata row exists, and the object becomes visible at exactly one moment: the consensus commit of its index row. A crash anywhere before that leaves garbage, never a half-written object.
    1. Client sends a signed PUT carrying the bytes and a CRC32C of them. One request, one key, no session. The signature covers the method, path, headers and a hash of the payload, so nothing on the way can change them. The client-computed checksum starts an end-to-end chain: checked at the front end, stored with the bytes, checked again on every read and every scrub.
    2. Front end verifies the signature and the caller’s permission, and loads the bucket’s configuration. Bucket configuration (versioning state, default encryption, lifecycle, policy) is read on every request but changes a few times a year, so front ends cache it for seconds. That makes it the one eventually consistent part of the system; S3 documents the same for bucket settings and asks you to wait after enabling versioning before writing.
    3. Front end appends the bytes to an open replicated extent, one replica in each data centre. Objects under 1 MB are packed Haystack style into append-only extents of up to 1 GB, so a 120 KB object costs one sequential append rather than a file and an inode. The front end holds leases on a few open extents from the extent manager, which makes it the only writer of each and costs no lookup per PUT. Every replica checks the CRC, writes header, bytes and checksum, and fsyncs before acknowledging; if one is slow, the front end seals that extent and retries the append on another rather than waiting.
    4. Front end finds the key’s partition and commits the new version row through its leader. The partition map is cached; a replica that no longer owns the key rejects the call and the front end refreshes, so a stale map never produces a wrong answer. The leader replicates the row to a majority (two of three data centres) and applies it. This commit is the linearisation point: before it no reader can see the object, after it every reader does.
    5. Front end returns success with the ETag and version id. Only now does the client hear success. Had the front end died after the appends but before the commit, the client would see a timeout and retry with fresh bytes and a new version; the first copy would be bytes that no row points at, invisible and later collected.
    6. The committed mutation is streamed to the partition’s change log. Each partition exports its replicated log in commit order. Consumers are extent accounting, lifecycle hints, event notifications and billing. Because the log is the commit log itself, an event exists exactly when the commit does, with no dual write to get wrong.
    7. GC adds the new bytes to the extent’s live count; bytes of any version this PUT replaced become dead. Live-byte counts per extent decide compaction later. Bytes of an overwritten object in an unversioned bucket are not freed now; they simply stop being counted, and an extent that is mostly dead gets compacted. Nothing in the data plane is ever edited in place.
  2. GET a large object, strongly consistent. The read path for a 2 GB video, fetched with parallel ranged GETs. The metadata read is linearisable, so it always returns the latest committed version; the data read can be cached freely, because extents never change.
    1. Client issues parallel ranged GETs for 64 MB slices of the object. Large downloads parallelise by byte range, typically 8 to 64 requests at once, which is how one client reaches tens of Gbit/s. Each range is an independent request, retried on its own.
    2. Front end authorises the request and reads the latest version’s row from the partition leader. The leader holds a lease of a few seconds during which no other replica can be elected, so it answers from its own state, with no consensus round, and is still linearisable. A follower could be behind, so followers serve a hot key only after checking the leader’s commit index (Raft’s ReadIndex).
    3. Front end maps the byte range to coded extents and looks up where their fragments live. Prefix sums over part sizes find the part, then the extent and offset inside it. An extent’s locations change only when a fragment is repaired or moved, so front ends cache them and treat "fragment not here" from a storage server as the cue to refresh.
    4. Front end checks the fragment cache for the range. Cache keys are (extent id, offset, length), and an extent never changes after it is written, so a cached range cannot be stale and there is no invalidation problem on the data plane. A popular object, a game patch on release day, is served almost entirely from here.
    5. On a miss, front end reads the 12 data fragments in parallel and hedges the slowest with a parity fragment. The code is systematic: the data fragments are the original bytes, so a healthy read needs no decoding. With 12 disks per read the slowest sets latency, so past roughly p95 the front end also asks for one or two parity fragments and decodes from whichever 12 arrive first. Two-thirds of the fragments are in other data centres, 1–2 ms away, which is noise next to moving 64 MB.
    6. Front end verifies every cell’s checksum and streams the range to the client. Corruption the disk did not report, from a misdirected write, firmware or bit rot, is caught here: the bad cell is decoded from other fragments, the client still gets correct bytes, and the extent is queued for repair. A read never returns bytes that fail their checksum.
  3. Upload a 2 TB object in parts. A single 2 TB PUT would hold one connection for hours and restart from zero on any error. Multipart turns it into thousands of independent, parallel, retryable part uploads, and the object appears atomically only when one manifest commit lands.
    1. Client starts a multipart upload and gets an upload id. The upload is a metadata record keyed (bucket, key, upload id), which sorts right next to the key itself, so the upload, its parts and the final object all live in one index partition. Nothing is visible to GET or LIST yet.
    2. Client uploads 8,000 parts of 250 MB, 32 at a time, each with its own checksum. Each part is an ordinary write with its own connection and its own retry, so a dropped connection costs one 250 MB part, not 2 TB. At 32 parts in flight a well-connected client sustains ~25 Gbit/s and finishes in about 11 minutes. Parts can come from many machines in any order.
    3. Front end streams each part through an encoder into coded extents spread over three data centres. Bytes are cut into 1 MB cells; every 12 data cells produce 6 parity cells, and the 18 go to 18 disks on 18 servers, six per data centre, chosen by the extent manager when it allocated the extent. The write is acknowledged when all 18 are durable, and a fragment that is slow to land past ~p99 is re-sent to another server in the same data centre, so one slow disk costs milliseconds rather than the tail.
    4. Each finished part is recorded under the upload with its size, ETag and layout. Part records are committed like objects, so a recorded part is durable and appears in ListParts. A client that crashes lists what is done and resumes from there. Parts written but never recorded are just unreferenced extents for GC.
    5. Client completes the upload with the ordered list of part numbers and ETags. The list lets the client choose exactly which uploaded parts form the object, so a part that was re-sent twice is unambiguous. The resulting ETag is a hash of the part hashes plus "-8000", not an MD5 of the object, because the object was never seen whole in one place.
    6. Index validates the list and commits one version row that points at the parts: the object appears atomically. No bytes are copied or moved; completion is a single commit in one partition, because split points never fall inside one key’s rows. Parts left out of the list lose their records and their extents lose their references. Until this commit GET returns the previous version or 404; after it, every reader sees the whole new object.
    7. Uploads that are never completed are aborted by a lifecycle rule and their bytes collected. An abandoned upload is invisible but still occupies, and bills, storage. A rule that aborts uploads older than seven days deletes their records in one commit per upload, and the change log tells GC which extents just lost their references.
  4. A disk dies and repair races the clock. With about a million drives, ~53 fail every day. Durability is decided by how quickly the system notices, and by rebuilding the stripes with the least redundancy left first, in parallel across the fleet.
    1. A storage server reports a failed disk, or stops sending heartbeats altogether. A disk that fails its reads is declared dead at once. A silent server is not: most silences are reboots and deploys that end within minutes, and re-protecting a 768 TB server for a reboot would cost ~9 PB of repair reads. Its fragments count as unavailable for reads immediately, but repair waits ~15 minutes unless a stripe is already short.
    2. Extent manager lists every extent with a fragment on that disk and ranks them by spare fragments left. A 20 TB disk at 80 % holds ~16 TB of 64 MB fragments, about 250 k extents, found through a reverse index from disk to extents. The repair queue is ordered by fragments remaining minus 12: an extent that has lost three (three spares left) goes before one that lost one. Stripes at one or two spares get urgent bandwidth; the rest are rate-limited.
    3. Repair workers read 12 surviving fragments of each extent, decode the missing one, and write it to a new disk. Placement is declustered: those 250 k extents have their other fragments on tens of thousands of disks, and the replacements go to tens of thousands of others, so the rebuild runs in parallel across the fleet. Copying 20 TB onto one spare at 200 MB/s takes ~28 hours; rebuilt this way, at ~50 GB/s of reads, it takes about an hour.
    4. Extent manager records the fragment’s new home; the object index is not touched. Object rows name extents, not disks, so re-protecting 250 k extents updates 250 k small extent-map rows instead of rewriting the index rows of every object stored in them. That indirection is what makes repair cheap enough to run all day.
    5. Meanwhile, the scrubber finds a fragment whose bytes no longer match their checksum. Disks do not report every error: drives specify one unrecoverable read error per 10^15 bits, about one per 125 TB read, and silent corruption also comes from firmware and misdirected writes. Every disk is read end to end every two weeks, ~13 MB/s per disk or ~6 % of its bandwidth, with every 1 MB cell’s CRC checked. A bad cell enters the same priority queue as a dead disk.
    6. Small-object extents that lost a replica are copied from a surviving one. For replicated extents repair is a plain copy, cheaper than a decode. But with only three copies, an extent down to one survivor is the most urgent item in the whole queue, and it jumps ahead of every coded stripe.
  5. Delete, tiering and garbage collection. Nothing is deleted in place. A delete in a versioned bucket writes a marker; lifecycle rules later expire old versions and move cold data to a cheaper tier; space comes back only when GC finds bytes that nothing references.
    1. Client deletes a key in a versioned bucket. With versioning on, DELETE without a version id commits a delete marker: a new latest version that says "gone". GET returns 404 and LIST skips the key, but every older version remains and deleting the marker restores the object. A DELETE with a version id removes that version for good.
    2. Lifecycle engine scans the partitions of buckets with rules and evaluates each row against them. Rules are by prefix and age: infrequent access after 30 days, archive after 180, expire noncurrent versions after 90, remove expired delete markers. A daily scan in key order, partition by partition and from follower replicas, spreads the work across the fleet and needs no secondary index on age.
    3. A cold object is copied into the archive tier, then its row is switched to the new layout in one conditional commit. Copy first, then commit only if the row still points at the old layout. If a client overwrote the key meanwhile, the condition fails and the copy becomes garbage, so no lock is held across a slow copy. Archive uses dense, mostly spun-down drives, about a tenth of the cost per byte, and reading it back needs a restore that takes minutes to hours.
    4. Each commit’s change-log entry tells GC exactly which bytes in which extents just stopped being referenced. GC never guesses liveness: bytes are live only if an index row points at them, and the change log carries every change to that in commit order, so live-byte counts per extent are an exact running sum. A weekly reconciliation recomputes every count from a full index scan to catch bugs.
    5. Extents with no live bytes are freed after a grace period; mostly dead small-object extents are compacted. A coded extent at zero live bytes for 48 hours is freed. A replicated extent that is, say, 70 % dead has its live objects copied to a fresh extent and their rows repointed with compare-and-set, then the old one is freed. The grace period exists because a slow upload may still be about to commit a reference to an extent that looks dead.

Deep dives

Metadata plane and data plane

Why not store each object as a file on a big distributed file system, with one master keeping the namespace?

The obvious design is GFS or HDFS: a master holds the namespace in memory and chunk servers hold the bytes. It breaks on object count, not bytes. 100 trillion objects at ~300 B of metadata is 30 PB; an HDFS NameNode spends roughly 150 bytes of heap per file and block and tops out in the hundreds of millions of files. We need five orders of magnitude more, and 10 M metadata operations a second rather than tens of thousands.

The two halves also want opposite things. Metadata is tiny, hot, mutable and must be strongly consistent: an overwrite changes which bytes a name points to. Data is huge, mostly cold, and can be made immutable: bytes are written once and only ever deleted. Splitting them lets each be built for its own job: a partitioned consensus database on flash for names, and dense, erasure-coded disks for bytes.

  • Separate planes: a partitioned index for names, immutable extents for bytes chosen
  • Single-master namespace (GFS or HDFS style) rejected
  • Metadata stored with the data and located by hashing (Ceph RADOS style) situational: private clusters of up to tens of petabytes, where operational simplicity matters more than exabyte-scale efficiency
  • One big database holding bytes and metadata rejected

The answer: Two planes joined by one pointer. The metadata index stores, per object version, its size, ETag, storage class, wrapped data key and a layout: (extent id, offset, length) for a small object, or the list of parts and extents for a large one. The data plane stores immutable extents and knows nothing about buckets or keys beyond a self-describing header on each object, which is what lets the index be rebuilt from the disks in a disaster. An extent manager in between maps extent ids to disks, so data can move without the index noticing. Writes go data first and metadata last; reads go metadata first and data second. Google made the same move when Colossus, the successor to GFS, put file system metadata in Bigtable.

Every GET now costs a metadata lookup before the first byte. Is that a latency problem?

It is one round trip to a partition leader serving from memory or NVMe, about 1 to 5 ms, against ~10 ms for the disk seek that follows. The leader keeps its hot rows in memory, and that cache needs no invalidation because the leader is where writes land. For a very hot key, followers serve reads after checking the leader’s commit index. The lookup is the cheap part of an uncached GET.

You lose part of the index. Are those objects gone?

Not if the data plane is self-describing. Each small object’s header carries bucket, key, version and checksum, and each coded extent records the upload and part it belongs to, so a recovery job can scan extents and rebuild missing rows. It is slow and a last resort, behind three replicas across data centres and point-in-time snapshots of the index. Keeping enough in the data plane to rebuild the index turns an index bug into an outage rather than a loss.

Why insist on immutable data? Customers overwrite objects all the time.

An overwrite writes new bytes and moves the pointer in one metadata commit; the old bytes become garbage. In-place updates would need locking across 18 fragments, a read-modify-write of parity, and cache invalidation on the data plane. Immutability buys torn-write-free updates, caches that cannot be stale, and repair that copies fragments at any time without coordinating with writers. The price is GC, which failed uploads require anyway.

How small does an object have to be before per-object overhead dominates?

On disk, a file per object costs an inode, a directory entry and extra seeks per read once metadata no longer fits in memory, which is what Facebook measured before building Haystack. Packing objects into 1 GB extents makes a small write one append and a small read one seek. In the index the ~300 B row is fixed, so a 10 KB object pays 3 % overhead in flash and a 1 KB object 30 %. That is why S3 bills infrequent-access objects as at least 128 KB.

How would you know the two planes agree?

Three continuous checks. A weekly reconciliation recomputes every extent’s live-byte count from a full index scan, and any mismatch stops GC for that extent and pages someone. Sampled audits read random index rows and verify the extent, offset and checksum they point at. Change-log sequence numbers prove that no consumer skipped a mutation. Orphans found this way are freed only after the grace period; missing data is an incident.

Partitioning the index, and hot prefixes

How do you split an index of 100 trillion keys across machines so that LIST works and one hot prefix cannot melt a partition?

LIST means "every key in this bucket starting with logs/2026/, in order, 1,000 at a time". With hash partitioning, as in the key-value store, neighbouring keys land on unrelated machines and every LIST page becomes a scatter-gather over all of a bucket’s partitions. So the index is range partitioned on (bucket, key): each partition owns a contiguous slice of the sorted keyspace, and a LIST is a range scan on one or two partitions.

Range partitioning has a well-known failure: key names are not random. Customers write logs/2026-10-26T12:00:01.123, img-000001, img-000002. Every new key sorts at the end of the same range, so one partition takes every write while three million others idle. For years S3’s guidance was to put a random hash at the start of key names; in 2018 it announced at least 3,500 writes and 5,500 reads a second per prefix with automatic scaling, and withdrew that advice. The fix is to split by load, not only by size.

  • Range partitions split by size and by load, each a Raft group across three data centres chosen
  • Hash partitioning with consistent hashing rejected
  • Hash prefixes added to key names by the customer situational: one workload needs more than a single partition’s sustained write rate on what would otherwise be one sequential range
  • Range partitions split only by size rejected

The answer: Rows are keyed (bucket id, key, version id descending), and the keyspace is cut into ranges of up to 10 GB, about 3 M partitions, each a Raft group with one replica per data centre so that a majority survives losing any one. The partition map tracks load per partition from sampled request keys; above ~3,500 writes or 5,500 reads a second sustained, or a CPU threshold, it splits the partition at the load median rather than the size median, and moves the busier half to a lightly loaded server. Split points never fall inside one key’s rows, so a key’s versions and multipart parts always commit in one partition. While a split is in flight the partition sheds excess load with 503 Slow Down, and SDKs back off with jitter. For monotonic keys, splitting at the tail leaves a small, hot range on its own server; past that server’s capacity the honest answer is the one S3 documents, spread the writes across prefixes, because one sequential point in an ordered index is one machine.

A customer writes 20,000 objects a second, all named logs/<timestamp>. What exactly happens?

All of those commits go to the partition owning the tail of logs/. It saturates and returns 503 Slow Down for the excess, and the SDKs back off. The partition map sees sustained overload and splits at the load median, which for timestamps is very close to the end, so the new right-hand range holds almost no data but takes all the writes, on a server of its own. That buys a few times the rate. To sustain 20 k a second the customer spreads keys over, say, eight prefixes, logs/0/ to logs/7/, which is what S3’s documentation recommends for parallelism.

Why not split ahead of time, before the 503s?

When load is predictable we do: a prefix whose load is growing steadily, or a customer who warns us before a launch, gets pre-split into empty ranges spread over servers. Splitting a small, quiet range is a metadata change, while splitting under load means moving a hot replica. The reactive path exists for everything nobody predicted.

LIST on a prefix with 100 million keys: how long does it take, and is it consistent across pages?

Each page of 1,000 keys is one range scan on one partition leader, a few milliseconds, so 100 k pages in sequence take minutes; clients in a hurry list sub-prefixes in parallel. Each page is consistent as of its read and the continuation token is the last key returned, so the listing is not a snapshot: a key added behind the cursor is missed and one added ahead is seen. A point-in-time listing of a whole bucket is an inventory job, not LIST.

Three million Raft groups. Isn’t the heartbeat traffic alone a problem?

It would be at one heartbeat per group every 100 ms. Most partitions see a few requests a second, so idle groups go quiet until there is work, and a server sends one combined heartbeat per peer for all the groups they share. CockroachDB and TiKV do both, for the same reason. What remains is proportional to active partitions and servers, not to the 3 M total.

How do you move a partition without a blip?

Add a replica on the target server as a non-voting learner, stream it a snapshot plus the log tail, then change membership in one consensus step and remove the old replica. Leadership is transferred explicitly to a caught-up replica, so the unavailable window is one leader handover, not an election timeout. Front ends learn the new location from a "not my range" rejection and a map refresh, never from a timeout.

Erasure coding, copies and eleven nines

Why not keep three copies of everything? And where does eleven nines actually come from?

Three copies of 10 EB is 30 EB of disk; RS 12+6 is 15 EB. The difference is about 900,000 drives, which decides whether the service makes money. But erasure coding is not free: every healthy read of a large object touches 12 disks instead of one, rebuilding one lost fragment reads 12 others, and a 10 KB object cut into twelve 850-byte pieces would turn one disk IO into twelve. Small objects need different treatment.

The availability requirement constrains the code. To survive losing one of three data centres, no data centre may hold more than m of a stripe’s k + m fragments, which forces m ≥ k/2 and an overhead of at least 1.5×. The popular RS 10+4 at 1.4× cannot survive a data-centre loss across three sites, because one of them would hold five fragments. Durability then comes from three levers: how many fragments can be lost (m), how quickly a lost one is rebuilt, and how independent the failures really are.

  • RS 12+6, six fragments in each of three data centres chosen
  • 3× replication, one copy per data centre chosen
  • RS 10+4 inside one data centre situational: a cheaper single-site storage class that customers choose knowingly, like S3 One Zone-IA, or data that can be regenerated
  • RS 10+4 per site plus an XOR across sites, as in Facebook’s f4 situational: sites are far apart or cross-site bandwidth is scarce, so keeping repair local is worth 0.6× more disk

The answer: Objects under 1 MB go into 3× replicated extents, one replica per data centre; once an extent is sealed and has been cold for 30 days it is re-encoded to 12+6 in the background, so small objects also end near 1.5×. Larger objects are cut into 1 MB cells and encoded inline into RS 12+6 stripes, six fragments per data centre on 18 different servers: 1.5×, tolerating any six losses. Durability comes from repair speed as much as from the code: fragments are declustered across the fleet, so a dead 20 TB disk is rebuilt by thousands of disks in about an hour rather than by one replacement in 28. With a 2 % annual failure rate and one-hour repair, the independent-failure model gives about 30 nines for 12+6 and about 12 for three copies; three copies with day-long rebuilds give about 10. Those numbers are not the point: real losses come from correlated failures (a rack’s power, a bad batch of drives, a software bug, an operator), so failure domains, staged deployments and delete protection are where the eleven nines are really earned. AWS publishes eleven nines as S3’s design target; this is our design for meeting it, not a description of theirs.

Walk me through the durability arithmetic for three copies.

Take a 2 % annual failure rate and a one-day rebuild. Each disk is down about 0.02 × 1/365 ≈ 5.5 × 10^-5 of the time. Data is lost if, after a first failure, both other copies fail inside the window: roughly 3 × 0.02 × (5.5 × 10^-5)² ≈ 2 × 10^-10 a year, just under ten nines. Cut repair to an hour and that improves by 24², about 600×, to over twelve nines. Each extra tolerated loss multiplies by another factor of about 1/(failure rate × repair time), which is why both parity and repair speed matter so much.

A whole data centre loses power for six hours. What happens?

Partitions whose leaders were there elect new leaders in the other two sites once the old leases expire, a few seconds of 503s for those ranges, and metadata writes carry on with two of three replicas. Reads of coded objects need all 12 surviving fragments and decode the missing data cells, so latency and CPU rise and one slow disk can fail a read until the site returns; nothing is lost. New writes place 9 + 9 fragments in the two live sites, still tolerating six disk losses, and are rebalanced later. Repair does not rebuild the dark site: recreating ~5 EB for a six-hour outage would be absurd, so it fixes only stripes that also lost a fragment elsewhere.

Rebuilding reads 12 fragments per lost one, mostly across sites. Can you cut that?

Yes, with a local reconstruction code. Azure published LRC 12+2+2: the data fragments form two local groups, each with its own parity, plus two global parities, so the common single-fragment repair reads 6 fragments instead of 12, at 1.33×. Adapting it to three sites means local groups that sit inside one data centre and enough global parity to survive losing a site, which costs some overhead beyond 1.5× and a more complex code. With ~120 GB/s of repair reads, halving them may well be worth it.

Why not erasure code small objects inline too?

A 10 KB object split twelve ways is twelve tiny writes and, worse, twelve seeks to read back, on drives that give ~150 IOPS each. Replicating it costs 3× the bytes but one append and one seek. Small objects are ~99 % of objects but only ~10 % of bytes, so paying 3× on them while they are hot is cheap. Once their extent is sealed and cold we encode the whole 1 GB extent, and a read of one object still touches only the one data fragment it lives in, because the code is systematic.

How would you know you are actually meeting eleven nines?

You cannot measure it directly; losses at that rate are too rare to count. Measure what drives it: time from failure to full redundancy, the number of stripes by spare fragments left (alerting on any at its last spare), failure rates by drive model and batch, scrub findings per petabyte, and how correlated failures are across racks. Feed those into the model continuously. Then also count what the model ignores: every incident in which a bug or an operator could have deleted data.

Commit-last writes and strong consistency

How do you guarantee that a GET right after a successful PUT returns the new object, and that a PUT which fails halfway leaves nothing behind?

Before December 2020, S3 gave read-after-write consistency for new keys but only eventual consistency for overwrites, deletes and LIST, and tools such as EMRFS consistent view kept a side index in DynamoDB to compensate. In an object store the hard part is not the database, it is caching: metadata is read 10 M times a second, and any cache that can serve a stale row breaks read-after-write.

A write also spans two planes: bytes go to storage servers and the name goes to the index. Write the name first and readers can see an object whose bytes do not exist yet; write the bytes first and a crash leaves bytes with no name. Only the second failure is harmless, provided something cleans up, so the order is fixed: bytes first, metadata last, and the metadata commit is the single moment the object comes into existence.

  • The metadata commit as the linearisation point, leader-lease reads, caches only with invalidation chosen
  • Eventually consistent replicas behind TTL caches rejected
  • Leaderless quorums (Dynamo-style N, R, W) rejected
  • Metadata first, marked pending, flipped once the bytes land situational: multipart uploads, which live for minutes to days and must be listable and abortable, so they get an upload record up front

The answer: A PUT streams bytes to storage and waits until they are durable (three replicas fsynced, or all 18 fragments), then sends one commit to the partition leader, which replicates the row to a majority across data centres and applies it; only then does the client get 200. A GET reads the latest version from the leader, which holds a lease of a few seconds during which no other replica can be elected, so it answers from its own state and is still linearisable; hot keys can also be read from followers after a ReadIndex check of the leader’s commit position. Any metadata cache outside the leader is lease-based and invalidated by the leader before a write to that key is acknowledged, and if a cache node is unreachable the write waits out its lease. Conditional PUTs, If-None-Match: * for create-only and If-Match: <ETag> for compare-and-swap, are checked at the leader inside the commit; S3 added both in 2024. Uncommitted bytes are collected once their write lease and a 48-hour grace period have passed. S3 moved to strong read-after-write consistency for all GET, PUT and LIST operations in December 2020, at no extra cost; the mechanism described here is our design.

The front end writes all the bytes, then crashes before the commit. What does the client see, and what happens to the bytes?

The client sees a reset or a timeout, never a 200, so it retries; the retry writes new bytes and commits a new version, and the client cannot tell. The first copy sits in an extent with no row pointing at it. GC never counted those bytes as live, so a coded extent written only by that upload is freed once its write lease and the 48-hour grace period expire, and a shared small-object extent just carries a few dead objects until compaction.

Two clients PUT the same key at the same moment. Who wins?

Whichever commit the partition leader orders second, and every reader agrees on that order because it is the leader’s log, not a client timestamp. Both writes succeed and, in a versioned bucket, both versions exist. If the application needs "only one of us may create this", it sends If-None-Match: * and the loser gets 412 Precondition Failed; for read-modify-write it sends If-Match with the ETag it read. Both are cheap because the check runs where the order is decided.

The leader in one data centre is cut off but still thinks it is leader. Can it serve a stale read?

Not if leases are done right. A leader serves reads only while its lease is valid, and no new leader may be elected until that lease has expired, with a margin for clock drift, so two replicas never both believe they may serve reads. The price is a few seconds of unavailability for that partition after the cut, and a dependence on clocks drifting less than the margin, which we monitor as a hard alert.

Why not cache metadata at the front ends with a one-second TTL? It would cut leader load a lot.

Because one second of staleness is exactly the bug strong consistency exists to remove: a pipeline writes a manifest and the next step reads the old one. If leader load becomes a problem for a hot key, the fixes keep consistency: follower reads after a ReadIndex check, or leased cache entries that the leader revokes before acknowledging a write. Data, unlike metadata, is cached freely, because an extent never changes.

Is LIST strongly consistent too? It spans partitions.

Each page is read from the leaders of the partitions it covers, so any PUT that returned 200 before the LIST started appears in it, and any delete that returned before does not. A LIST is not a snapshot across pages, which is fine for the contract "what has committed is visible". Delete markers are the practical problem: a prefix with millions of markers and few live keys makes each page scan many rows to return a few, which is why there is a lifecycle rule to remove expired delete markers.

Placement, repair and rebalancing

Where do an extent’s fragments go, how do you find them again, and what happens when a disk dies?

About 53 drives fail every day, and each held ~16 TB of fragments from ~250,000 extents. Placement decides how fast those come back: if every fragment on a dead disk has its siblings on the same handful of disks, the rebuild is bottlenecked on them; if siblings are spread over the whole fleet, thousands of disks rebuild in parallel. Placement must also respect failure domains: never two fragments of a stripe on one server, at most two per rack, exactly six per data centre.

Finding data again is the other half. Writing disk locations into object rows would mean rewriting index rows on every repair: 250,000 extents per dead disk, each holding many objects. One level of indirection, an extent id stored in the row and a small map that resolves it to disks, keeps repair and rebalancing entirely out of the 30 PB index.

  • An extent map with weighted, failure-domain-aware allocation chosen
  • Computed placement, like Ceph’s CRUSH situational: clusters where avoiding a location service matters more than per-extent control, which is why Ceph uses it
  • Disk locations stored in each object’s index row rejected
  • Copy a dead disk onto a hot spare rejected

The answer: The extent manager owns placement. A new extent gets 3 replicas or 18 fragments chosen by weighted random sampling over disks with free space, constrained to one fragment per server, at most two per rack and six per data centre, which spreads every extent’s siblings across the fleet. Storage servers heartbeat every 5 s; a failed disk is declared at once, a silent server after ~15 minutes, because most silences are reboots and re-protecting 768 TB for a reboot would cost ~9 PB of reads. Repair is a priority queue ordered by spare fragments left, so a stripe at its last spare preempts everything, and it is rate-limited per disk so foreground latency does not move. Rebuilt fragments go to new disks and the extent map row is updated with compare-and-set on a generation number. The same machinery rebalances: new racks are filled gradually by moving sealed, cold extents onto them, rather than by sending them every new write, because new data is the hottest data and a fresh rack would become an IOPS hot spot.

A whole rack of servers loses power. How is that different from a dead disk?

It is worse, because hundreds of disks failed together, which is the correlation the independent-failure model ignores. The placement rule already limits any stripe to two fragments per rack, so a rack loss costs each affected stripe at most two of its six spares. The rack is treated like a silent server: unavailable at once, rebuilt only if it does not return within the window or if a stripe is now short. That is why the per-rack limit is a rule and not left to chance.

Two repair workers both rebuild fragment 7 of the same extent. What stops a mess?

The extent map row has a generation number. Each worker reads it, rebuilds, and commits the new location with compare-and-set on that generation; the second commit fails and that worker deletes its orphaned fragment. Without the check the map could point at one copy while the other is counted as live, or two fragments could claim the same slot and quietly break the stripe.

How do you add 20 PB of new capacity without hurting anything?

Give the new disks a higher, capped weight for new extents, and move existing sealed extents onto them at a controlled rate. If every new write went to the new rack, it would serve the newest, hottest data from too few spindles at ~7 IOPS per TB. Moving cold extents onto new disks and letting new writes spread normally keeps both capacity and IOPS balanced. Retiring old drives is the same process in reverse.

Drives keep getting bigger. Why is that a durability problem?

A 30 TB drive holds 50 % more fragments than a 20 TB one with about the same bandwidth, so copying it takes longer and its loss is a bigger repair job. Declustering hides most of that, because the rebuild is spread over the fleet, but repair bandwidth per failure still grows with capacity. Bigger drives push towards faster repair, more parity or codes that read less per repair, and towards a reserve of repair bandwidth that foreground traffic never uses.

What do you alert on?

Any stripe at its last spare fragment; the age of the oldest item in the repair queue; time from failure to full redundancy at p99; repair throughput against the failure rate, where a backlog growing for an hour pages someone; and drive failures per model and batch per week, the early warning of a correlated failure. Scrub coverage too: every disk scrubbed within its two-week cycle, with findings per petabyte trended.

Deletes, versions and storage tiers

Nothing is modified in place. So how do delete, versioning and lifecycle work, and how do you get the space back safely?

A delete means three things at once: the name must vanish from GET and LIST immediately, the bytes should go eventually for cost and privacy, and in a versioned bucket the bytes must not go at all until a rule says so. With bytes packed into shared, immutable, erasure-coded extents, "delete these bytes now" is not even an operation the data plane has.

The answer separates the name from the space. Deletes are metadata commits: a removed row or a delete marker. Space is reclaimed by GC, which learns from the change log exactly which bytes stopped being referenced, and by compaction, which rewrites mostly dead extents. Tiering has the same shape: copy the bytes to a cheaper tier, then move the pointer in a conditional commit.

  • Delete markers and row removal in the index, GC from the change log with a grace period chosen
  • Delete the bytes synchronously rejected
  • Periodic mark-and-sweep over the whole index only situational: as the weekly reconciliation behind the incremental counts, never as the only mechanism
  • Expire bytes by TTL inside the storage layer rejected

The answer: In an unversioned bucket, DELETE removes the row; in a versioned one it commits a delete marker as the new latest version, and DELETE with a version id removes that version permanently. Each commit’s change-log entry carries the extents and byte counts it released, so GC keeps exact live-byte counts: a coded extent at zero live bytes for 48 hours is freed, and a small-object extent below ~30 % live is compacted by copying its live objects to a new extent and repointing their rows with compare-and-set. Freed extents stay unreferenced for a few more days before their space is reused, so a GC bug can be rolled back, and a weekly reconciliation recomputes every count from the index. The lifecycle engine scans the partitions of buckets with rules daily and acts through the same conditional commits: transition to infrequent access (same media, retrieval fee) or archive (dense, spun-down drives, restore in minutes to hours, about a tenth of the cost), expire noncurrent versions, remove expired delete markers, abort stale multipart uploads. For privacy, each object’s wrapped data key lives in its row, so removing the row makes leftover fragments unreadable long before GC reaches them.

A customer deletes 100 million objects with one lifecycle rule. What load does that create?

One hundred million index commits across whichever partitions hold that prefix, paced per partition by the lifecycle engine so they never compete with foreground traffic, finishing over hours rather than minutes. GC then sees a wave of released bytes in the change log. Fully dead coded extents are cheap to free; partly dead small-object extents queue for compaction, which is the expensive part and is rate-limited too. The customer sees objects disappear from LIST as each commit lands.

Versioning is on and a script overwrites every object with garbage. Can you recover?

Yes, that is what versioning is for: every previous version is still there, and restoring a prefix is a metadata operation per key that copies or re-points it to its last good version. The danger is a rule that expires noncurrent versions after a few days, which turns recovery into a race. For data that must survive deliberate deletion, write-once retention that even the owner cannot shorten is the stronger tool. Durability is not a backup either way.

Why a 48-hour grace period before freeing an extent?

Because "no row points at it" is briefly true of extents that are about to be referenced: a slow PUT between its last fragment write and its commit, or a multipart part written but not yet recorded. Write leases fence the first case, since a commit after the lease expired is rejected, and the grace period covers clock skew, retries and bugs nobody has thought of. Two days of extra garbage is a fraction of a percent of capacity; freeing one live extent is data loss.

How does a transition to archive stay correct if the client overwrites the key mid-copy?

The lifecycle engine copies the bytes into the archive tier first, then commits the new layout only if the row still points at the old one. If the client overwrote the key in the meantime, the condition fails, the copy is discarded as garbage, and the new version starts its own lifecycle clock. Nothing ever holds a lock across the copy.

A user asks for an object to be erased under GDPR. When are the bytes really gone?

The name goes immediately with the delete commit, and the wrapped data key goes with the row, so from that moment the fragments are ciphertext nobody can decrypt. The fragments themselves are freed or compacted within days. Old versions and index snapshots fall under the same policy, so the documented answer is "unreadable immediately, physically removed within N days", and both halves are worth saying in the interview.

The theory behind it

  • Replication vs erasure coding: an interactive playground. Store 10 PB with copies or Reed-Solomon codes, change disk failure and repair rates, and compare raw storage, nines of durability and repair traffic.
  • Multipart uploads: an interactive playground. Upload 100 MB to 5 GB over connections that drop, with a single PUT, a resumable upload and parallel multipart parts, and compare time to finish and bytes wasted.
  • Storage tiering: an interactive playground. Store a petabyte of images for three years under Standard, Infrequent Access, Glacier Instant Retrieval and Deep Archive lifecycles, and see when tiering saves money and when retrieval fees eat it.
  • Distributed file systems and erasure coding: How huge files are stored across thousands of disks: the GFS and HDFS design with a metadata master and chunk servers, chunk sizes and replication, write pipelines, metadata scaling, object storage internals, and erasure coding versus replication for durability and cost.
  • Object storage and large files: Blob storage, pre-signed uploads, chunking and resumable transfer, multipart, erasure coding, storage tiers and lifecycle, plus how media pipelines are structured.
  • Storing billions of small files (Haystack): Why file systems struggle with billions of small photos and how Facebook's Haystack solved it: metadata overhead, packing files into large volumes, in-memory indexes, needle lookups in one disk read, deletes and compaction, warm storage with erasure coding, and lessons for photo and attachment storage.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview