Design Object Storage
Build the object store itself: metadata apart from data nodes, replication versus erasure coding with the durability maths, multipart, listing and GC.
On this page
Most designs in this track end with "the bytes go in an object store". Here you build that box. A user sends PUT photos/2026/cat.jpg with 4 MB of bytes, and years later, after thousands of disks have died and been replaced, a GET must return exactly those 4 MB. Amazon S3 is the kind of product this question means.
The API is tiny and every hard part is underneath: keeping billions of names apart from petabytes of bytes, turning "never lose data" into arithmetic, storing a 1 TB object, and reclaiming space without deleting something still in use. For the user's side, read Blob Storage; this lesson does not repeat it. The steps follow A Framework for Any Design.
Clarify the problem
Design an object storage service.
I'll assume buckets holding objects addressed by key, with put, get, delete and list by prefix, and objects replaced whole, never edited. How big can one object be? Do we need versioning?
From zero bytes up to 1 TB. Versioning is a stretch goal.
How much data, and how busy is it?
100 PB today in about 100 billion objects, growing 10 PB a month. About 4,000 writes and 40,000 reads a second on average.
Then durability comes first, since a lost object can never be fixed; then read availability; then cost per byte, because at 100 PB the storage overhead is the bill. I'll give read-after-write consistency, list included. I'll leave out access policies, billing, storage tiers and cross-region copies.
- Functional: create buckets; put, get (whole or a byte range) and delete objects; list keys by prefix, page by page; upload large objects in parts.
- Non-functional: eleven nines of durability per object per year (checked with arithmetic below); reads available 99.99% of the time; a successful put is visible to every later get and list; first byte within about 100 ms; storage overhead well under 3x.
- Out of scope: access policies, billing, storage classes, cross-region replication; versioning is a follow-up.
Estimate the scale
The average object is 100 PB ÷ 100 billion = 1 MB.
- Ingest: 4,000 puts a second × 1 MB = 4 GB/s. A month is about 2.6 million seconds, so 4,000 × 2.6 million ≈ 10 billion objects, 10 PB, a month, which matches the growth given.
- Egress: 40,000 gets a second × 1 MB = 40 GB/s on average, 120 GB/s at a 3x peak. If one front-end server moves about 1 GB/s, that is about 120 servers for peak reads alone. This store is limited by bandwidth, not request count.
Each object has a metadata row: bucket, key, size, checksum, where its bytes live. How much metadata is there, before replication?
- 100 billion objects
- About 500 bytes per metadata row
Show the working
- 100 × 109 × 500 B = 5 × 1013 B = 50 TB
- With 3 replicas: 150 TB
Estimate 50 TB (anything from 15 TB to 150 TB is a good answer)
Far too much for one machine: the metadata store is itself a sharded database, and every request touches it.
Instead of 3 copies, data is split into 10 data fragments plus 4 parity fragments, and any 10 rebuild it. How much raw disk do 100 PB of objects need?
- 100 PB of objects
- 10 data + 4 parity fragments
Show the working
- Overhead: 14 ÷ 10 = 1.4x
- 100 PB × 1.4 = 140 PB
- At 20 TB a disk: 7,000 disks. Three copies would be 300 PB, 15,000 disks.
Estimate 140 PB (anything from 50 PB to 400 PB is a good answer)
160 PB of disk saved is why most data ends up erasure coded.
Write put, get, delete, list, and the calls of a multipart upload. How can the client be sure the bytes arrived intact?
Compare with a model answer
Put, get and delete on /{bucket}/{key}, list on /{bucket} with a prefix and a page token, and start, part and complete for multipart. The put carries a checksum, and the store refuses to commit bytes that do not match it.
Define the API
PUT /{bucket}/{key} x-checksum: sha256=9f2c...
200 { "etag": "9f2c...", "version": "v2" }
400 Bad Digest bytes do not match the checksum; nothing stored
412 Precondition Failed If-Match / If-None-Match did not hold
GET /{bucket}/{key} Range: bytes=0-1048575 (optional)
200 or 206 + bytes 404 no such key
DELETE /{bucket}/{key}
204 No Content also when the key did not exist
GET /{bucket}?prefix=photos/2026/&delimiter=/&limit=1000&after=...
200 { "keys": [...], "prefixes": ["photos/2026/01/"], "next": "token" }
POST /{bucket}/{key}?uploads start -> { "upload_id": "u7" }
PUT /{bucket}/{key}?upload_id=u7&part=3 one part -> { "etag": "..." }
POST /{bucket}/{key}?upload_id=u7 complete, body: [{part, etag}, ...]
DELETE /{bucket}/{key}?upload_id=u7 abortThe decision worth arguing is two clients putting the same key at once. Default to last writer wins, ordered by the metadata commit, and offer conditional puts: If-None-Match: * ("only if the key does not exist") and If-Match: <etag> ("only if nobody changed it since I read it"). Users get compare-and-swap with no locks in the store. Delete returns 204 even for a missing key, so retries are safe (see idempotency and retries).
What records describe an object, and where do its bytes live? Think about what must change when a disk dies and its data is rebuilt elsewhere.
Compare with a model answer
An object row maps a key to chunk ids. Chunks live in large volumes, and a volume map says which disks hold each volume. Rebuilding a disk changes the volume map, never billions of object rows.
Model the data
objects -- sorted by (bucket_id, key)
bucket_id BIGINT
key VARCHAR(1024) -- "photos/2026/cat.jpg"
version BIGINT
size_bytes BIGINT
checksum CHAR(64) -- sha256 of the bytes (multipart: of the parts' sums)
chunks LIST -- [(volume_id, chunk_id, length, crc32c), ...]
PRIMARY KEY (bucket_id, key)
volumes -- the volume map, about 10 million rows
volume_id BIGINT PRIMARY KEY
state VARCHAR(8) -- open | sealed | coded
layout LIST -- 3 disks while open; 14 fragment disks once coded
generation INT -- bumped when compaction rewrites it
multipart_parts
upload_id, part_number, chunks, checksumThe indirection is key → chunks → volume → disks. A volume is a 10 GB append-only file packing many objects' chunks; 100 PB ÷ 10 GB = 10 million volumes, and at about 200 bytes each the volume map is 2 GB, small enough for every front end to cache. Each storage node keeps its own index from chunk id to offset.
Gets and puts are single-key lookups that must be strongly consistent. List scans keys in sorted order from a prefix, which rules out hash partitioning: it scatters neighbouring keys. Use a range-partitioned, sorted key-value store with consensus per range, such as TiKV. Each contiguous key range is replicated three ways with a leader chosen by consensus, and busy ranges split.
Sketch the boxes. What picks the disks, where is the commit point that makes an object visible, and what runs in the background?
Compare with a model answer
Stateless front ends; a metadata service over the sorted store; a placement service that hands out volumes; storage nodes; background workers for scrubbing, repair, coding and garbage collection. The metadata commit is the moment the object exists.
High-level design
Metadata is small, strongly consistent and touched by every request. Data is huge, written once and mostly cold. They scale for different reasons, so they are separate services. The front end writes bytes first and metadata second, so an object never points at bytes that are not there.
request and replyasynchronous message
- object
- 4 MB
- visible
- no
1A client sends
PUT photos/2026/cat.jpg: 4 MB plus a SHA-256 of the bytes. Any front end can take it.- object
- 4 MB
- visible
- no
2Placement hands out open volume 5,120,331, replicated on three nodes in three racks, so one rack losing power cannot take every copy.
- object
- 4 MB
- copies on disk
- 3
- visible
- no
3The 4 MB streams to all three nodes. Each appends the chunk, verifies the checksum and flushes to disk before saying yes.
- object
- 4 MB
- copies on disk
- 3
- visible
- yes
4The commit point: the metadata service writes the key's row, pointing at the new chunk, through the range's consensus group. From now on every get and list sees it.
- copies on disk
- 3
- visible
- yes
5The client gets 200 and the etag. Bytes first, then metadata: neither is visible without the other.
- copies on disk
- 3
6Later, a get. One key lookup gives the chunk and its volume; the cached volume map gives the disks.
- good copies
- 2
7The first copy fails its CRC: bits on that disk flipped since the write.
- good copies
- 3
8The second copy checks out and streams to the client. The bad copy is reported, and a repair worker rewrites it from a good one.
- orphaned bytes
- 4 MB
- visible
- old version
9Another put: the bytes reach all three nodes, then the front end crashes before writing metadata. The client sees an error and retries; the old object is untouched.
- orphaned bytes
- 0
10No row points at those 4 MB. The garbage collector finds them, waits out a grace period in case a commit is still in flight, and reclaims them.
Deep dives
Durability: copies or erasure coding
Eleven nines means a 1 in 1011 chance of losing a given object in a year: across 100 billion objects, about 1 object a year. Assume, as a planning figure, that 2% of disks fail a year and a lost piece is rebuilt within 24 hours.
- The chance a given disk fails in a 24-hour window: p = 0.02 × 24 ÷ 8,760 ≈ 5.5 × 10−5.
- 3 copies. One of the 3 disks fails 3 × 0.02 = 0.06 times a year, and the object is lost only if both others fail before the rebuild: p² ≈ 3.0 × 10−9. Yearly loss ≈ 0.06 × 3.0 × 10−9 = 1.8 × 10−10, just under ten nines: about 18 objects a year. Fresh data stays in copies only until its volume fills and is coded, so the coded figure is the one that counts.
- 10 + 4 coding. One of 14 disks fails 14 × 0.02 = 0.28 times a year, and the object is lost only if 4 of the other 13 fail before the rebuild: 715 ways to pick them, so 715 × p⁴ ≈ 6.4 × 10−15. Yearly loss ≈ 0.28 × 6.4 × 10−15 ≈ 1.8 × 10−15, almost fifteen nines.
- Faster repair pays twice. Repair in 1 hour and p falls 24 times, so the 3-copy loss falls 24² = 576 times, to about 3 × 10−13.
The model assumes disks fail independently. They do not: a rack loses power, a disk batch shares a firmware bug, a release deletes the wrong files. Correlated failures, not code maths, are the real threat. So put each volume's pieces in different racks, roll out software slowly, and wait days before erasing deleted data.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| 3 full copies | Simple. Any copy serves a read; a rebuild reads only what was lost. | 3x disk: 300 PB for 100 PB. About ten nines with day-long repair. | Open volumes, until they fill and are sealed. |
| Erasure coding 10 + 4 | 1.4x disk: 140 PB. Survives any 4 lost fragments. | Rebuilding one fragment reads 10. A read during an outage fetches and decodes 10 fragments. | Large, sealed, mostly cold data: most of the store. |
| Copies first, then code Usual default | Puts are fast appends to 3 replicas; a full, sealed volume is coded in the background. | Coding traffic is about as large as ingest, and there are two layouts to operate. | Fast writes and cheap storage. Our design. |
Repair is the price of coding, so compute it. 7,000 disks at 2% a year is 140 failures, one every 2.6 days. Rebuilding a 20 TB disk under 10 + 4 reads 10 fragments per lost one: 200 TB read, 20 TB written. Over 24 hours that is 200 TB ÷ 86,400 s ≈ 2.3 GB/s of background traffic, against 0.23 GB/s for copies. Normal reads do not pay it: the code is systematic, so the 10 data fragments hold the original bytes and a 1 MB read usually touches one fragment and decodes nothing.
Durability also needs detection: a silently rotted copy is a copy you do not have. Every chunk carries a CRC checked on each read, and a scrubber reads every disk end to end. Scrubbing a 20 TB disk every 14 days is 20 TB ÷ 1.2 million s ≈ 17 MB/s, a small slice of a disk's sequential speed.
Huge objects and multipart upload
A 1 TB object arrives as parts, say 10,000 of 100 MB, uploaded in any order and in parallel. Inside the store a part is just a small put: chunks appended to volumes plus a multipart_parts row. Re-uploading part 3 replaces its row.
Complete moves no bytes. It checks every listed part exists with the given etag, then writes one object row whose chunk list is the parts' chunks in order, in one metadata transaction. A 1 TB object appears atomically, in milliseconds, with a checksum computed from the parts' checksums. Abort, or a rule that expires uploads idle for a week, deletes the part rows and leaves the chunks to garbage collection. Range reads map a byte range to the chunks that cover it.
Listing by prefix
There are no folders. photos/2026/ is just the start of some keys, so listing it scans the sorted store from the first key at or after the prefix until keys stop matching. Range partitioning means that scan touches one or two ranges, not all of them.
- Pages: at most 1,000 keys plus a token holding the last key returned; the next page starts after it.
- Delimiter: with
delimiter=/, keys underphotos/2026/01/collapse into one entry, and the scan seeks straight past them instead of reading a million rows. - Hot ranges: keys starting with a timestamp all land at the end of one range, and splitting cannot help because every new key goes to the newest half. Suggest a short hash prefix for hot writers.
List reads the same consistent store as get, so a key is listed the moment its put returns. A separate listing index updated asynchronously would show new objects late and break every job that writes files and then lists them.
Deleting and reclaiming space
Delete removes the object row in one metadata write, so the object vanishes at once. Its bytes still sit in an append-only volume beside live chunks. Reclaiming them is a separate, slower job.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Reference counts | Each delete updates a dead-bytes counter per volume, so you know at once which volumes are mostly garbage. | A lost update leaks space; an extra one deletes live data. Crashed puts were never counted. | As the fast path for choosing volumes to compact. |
| Mark and sweep Usual default | Scans all object and part rows for live chunk ids; anything else is garbage, crashed puts included. | Scanning 50 TB of metadata takes hours, so garbage waits days. | As the authority on what may be erased. |
Use both, with two safety rules. A grace period: never erase a chunk younger than a few days, because a put may have written bytes and not yet committed its row. The commit refuses chunks older than a day, so a put stalled past the window fails instead of pointing at erased bytes. And compaction keeps chunk ids: when a sealed volume is over 40% dead, a worker copies its live chunks into a new generation of the same volume and updates the volume map. Object rows still say "volume 5,120,331, chunk 88", so the metadata never changes. The old generation stays readable for a day for gets that looked up the old layout.
Bottlenecks and failures
At 10x, 40,000 puts and 400,000 gets a second, add storage nodes (placement sends new volumes to the emptiest disks, so nothing old moves) and front ends. The metadata store strains first: every request is a lookup and every put a consensus write. Split busy ranges and cache hot rows in the front ends.
| When this dies | What happens | What you do |
|---|---|---|
| A front end | In-flight requests fail; an interrupted put leaves orphan bytes, never half an object. | Clients retry; GC reclaims the orphans. |
| A metadata range leader | Requests for that range pause for seconds. | A new leader is elected; committed rows are on a majority, so none are lost. |
| The placement service | No new volumes are handed out. | Front ends use the open volumes and cached map they hold. Run it as a consensus group. |
| A disk or storage node | Its copies or fragments are gone. | Read another copy or decode from 10 fragments; repair rebuilds elsewhere within hours. |
| A whole rack | Many disks at once. | No volume has two pieces in one rack, so each loses at most one. |
| Scrubber or GC | Rot is found later; disks fill faster. | Nothing breaks today. Alert on time since last scrub and on free space. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Stores files on servers with a database of names, keeps 3 copies, and with prompting splits big files into parts. |
| Mid | Separates metadata from data, compares replication with erasure coding by overhead, and covers multipart upload and checksums. |
| Senior | Derives a durability figure and names its assumptions, prices repair traffic, makes the metadata commit the single point of visibility, picks a sorted store because of list, and makes deletion delayed and safe. |
How durable is your design?
Eleven nines, because we keep three copies.
How durable is your design?
With 2% of disks failing a year and day-long repair, 3 copies lose about 2 objects in 10 billion a year, just short of ten nines. 10 + 4 coding is around fifteen nines at 1.4x space, so sealed data is coded. The real risks are correlated, a rack or our own bugs, so pieces go on different racks and erasure waits days after a delete.
What does coding cost you?
Repair: rebuilding a 20 TB disk reads 200 TB. With a failure every couple of days that's a few GB/s of background traffic, which I'd budget and throttle.
Follow-up questions
How would you add versioning?
Put the version in the key so each put adds a row, and make delete add a "delete marker" row. A plain get reads the newest row that is not a marker. GC must treat every version's chunks as live.
How do you survive losing a whole data center?
No zone may hold more fragments than the code can lose. With 3 zones, 12 data + 6 parity, 6 per zone, survives a zone at 18 ÷ 12 = 1.5x. While a zone is down there is no margin left, so repair must be fast.
What about billions of tiny objects?
Coding is per 10 GB volume, so a 1 KB object costs 1.4 KB of disk. Its 500-byte metadata row now rivals the data, so the metadata store is the real limit.
One object gets 50,000 gets a second. What breaks?
The disk holding its data fragment. Cache hot objects in front-end memory or behind a CDN. Objects never change in place, so cached copies cannot go stale.
How do you delete a bucket with a billion objects?
Mark it deleted so it refuses requests, then remove its key range in background batches: one contiguous scan in a range-partitioned store.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".