Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Intermediate/I8
15 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDI8 · Part 2 · Intermediate

Storage systems

Block, file, object — and why the bytes of a user upload should never touch your app servers.

19 min read2 diagrams9 sections0 exercises
15 of 36

In this chapter

  • Three storage abstractions, and the one you should default to
  • Why object storage is the default, and what the durability number means
  • Presigned URLs: get your app servers out of the byte path
  • Large media: chunked, resumable, and parallel
  • Transcoding: the pipeline behind every video product

and 4 more

about 19 min
09On this page
  1. Three storage abstractions, and the one you should default to
  2. Why object storage is the default, and what the durability number means
  3. Presigned URLs: get your app servers out of the byte path
  4. Large media: chunked, resumable, and parallel
  5. Transcoding: the pipeline behind every video product
  6. Where not to put blobs: your relational database
  7. Hot, warm, cold: tiering and what it actually saves
  8. Operational details worth one sentence each
  9. Recognizing it in an unseen problem

Three storage abstractions, and the one you should default to

"Where do the files go?" appears in nearly every design that touches photos, video, documents or backups, and it is a question with a boring correct answer that a surprising number of candidates miss. The three abstractions differ in what the storage layer knows about your data: block storage knows nothing but offsets, file storage knows a directory tree, object storage knows an immutable blob plus metadata and nothing else.

BlockFileObject
UnitFixed-size blocks, no structureFiles in a POSIX hierarchyImmutable blob + key + metadata, flat namespace
AccessAttached to one machine as a deviceMounted by many machines (NFS/SMB)HTTP GET/PUT, from anywhere
MutationRandom read/write at any offsetRandom read/write, plus locking semanticsReplace whole object; no partial in-place edit
Scale limitOne volume, typically up to ~64 TBPetabytes, but metadata operations get slowEffectively unbounded; S3 allows objects up to 50 TB (48.8 TiB) since December 2025 (5 TB before)
Latency (rough, varies)Network-attached SSD such as EBS gp3: single-digit ms; local NVMe: far lowerLow single-digit ms~100-200 ms first byte on S3 Standard (AWS guidance); single-digit ms on S3 Express One Zone
Cost / GB-month (approximate US East list prices; check current)~$0.08 (SSD-backed)~$0.30 (managed NFS)~$0.023, dropping to ~$0.001 archived
Reach for this when…A database's data directory, or anything needing real random writesLegacy software that insists on a filesystem, or shared build/render scratch spaceUser uploads, media, backups, logs, static assets — the default

Notice the cost column spans two orders of magnitude for storing the same bytes. That gap is not a discount for being clever; it is what you pay for random-write latency you probably do not need. A 500 KB profile photo does not need sub-millisecond random access at any offset. It needs to be fetched whole, over HTTP, from a CDN.

Why object storage is the default, and what the durability number means

S3-class object stores advertise something like eleven nines of durability (99.999999999%). That is not marketing noise, but it also does not mean what people assume. It is a design target for annual expected object loss, not a contractual guarantee: store ten million objects and you would statistically expect to lose one roughly every ten thousand years. AWS describes how it gets there as redundant storage on multiple devices across at least three Availability Zones, continuous checksum verification, and fast detection and repair of lost redundancy, so that the loss of a whole AZ loses nothing. The generic technique behind object stores of this kind is erasure coding — split the object into k data fragments plus m parity fragments spread across independent failure domains, where any k of the k+m fragments reconstruct it — but treat that as the textbook mechanism, not a statement of S3's internals.

⚠ Durability and availability are different properties, and conflating them is a tell Durability is "the bytes still exist." Availability is "you can read them right now." S3 Standard is designed for eleven nines of durability but only 99.99% availability, and the SLA that credits you if it falls short starts at a lower, 99.9% threshold. Those are not in tension — a regional API outage means you cannot reach a perfectly intact object. And critically, durability protects against hardware, not against you. Eleven nines does nothing about a bad deploy that deletes a prefix, or a compromised credential. That is what versioning, object lock and cross-region replication are for. Say this distinction out loud; it is a cheap, high-signal moment.

The other property to name explicitly is consistency. Modern S3 is strongly read-after-write consistent for PUT, DELETE and LIST, in every Region, since December 2020 — a listing issued after a successful write shows it. That removed a class of bug that used to bite everyone. The caveats that survive are different ones: bucket-level configuration (for example, enabling versioning, or deleting a bucket then listing buckets) is still eventually consistent; there is no atomic update across keys; two concurrent writers to one key are last-writer-wins; and any CDN or client cache in front of the bucket reintroduces staleness that the storage layer's guarantee says nothing about. For the concurrent-writer case S3 now supports conditional writes (If-None-Match: * to create only if absent, If-Match with an ETag to overwrite only if unchanged), which gives you a compare-and-set on a single key. Cache-busting by putting a content hash in the key — writing new objects instead of overwriting them — sidesteps the cache problem and the overwrite race.

Presigned URLs: get your app servers out of the byte path

This is the single most reliably-asked storage detail in an interview, and the naive design fails on it hard. If uploads are POSTed to your API, then every byte of every file traverses your app servers: they buffer it, hold a worker thread or event-loop turn for the whole transfer, consume bandwidth twice (in from the client, out to storage), and now your autoscaling is driven by upload volume rather than request volume. A single user on a slow connection uploading a 2 GB video occupies a server slot for minutes.

client app server issues presigned URL metadata DB row: pending object storage bucket event queue object-created worker scan + transcode 1 init 2 signed URL 3 pending row 4 PUT bytes straight to the bucket 5 event 6 marks it ready
The green path carries every byte and never touches the app tier. The app tier only ever handles a few small JSON requests per upload, whatever the file size.

A presigned URL is a normal storage URL with a signature, an expiry and a set of constraints baked into the query string. Your app server holds the storage credentials; the client never does. Signing is a local cryptographic operation — no network call to the storage provider — so issuing them is essentially free.

The constraints matter, and interviewers probe them. A presigned PUT should pin: expiry (5-15 minutes, not 7 days), the exact object key which your server generates so clients cannot overwrite each other's objects, and content type. Size needs care: you can sign the exact Content-Length you expect, but a presigned PUT cannot express "any size up to N". For a size ceiling use a presigned POST with a content-length-range condition, or re-check the size from the object-created event and delete oversize objects. The key should be server-chosen and unguessable — a UUID or content hash, never a user-supplied filename, which is both a collision problem and a path traversal problem.

⚠ Trusting the client to tell you the upload finished If the client PUTs to storage and then calls your API saying "done," a malicious or merely buggy client can mark a nonexistent or half-written object as ready. Drive the state transition from the storage layer's own object-created event instead, which is what the queue in the diagram is for. That also gives you a natural place to run the things you must never skip: virus scanning, content moderation, size and format validation, and metadata extraction — all before the object is visible to anyone. Objects sitting in the pending state past their expiry get swept by a lifecycle rule.

Downloads get the same treatment in reverse. Serving private media through your API means proxying gigabytes; a presigned GET (or a signed CDN URL with a short TTL) lets the CDN serve it while still enforcing authorization, because the signature is the authorization and it expires.

Large media: chunked, resumable, and parallel

A single PUT is fine up to about 100 MB (AWS suggests multipart from there) and is capped at 5 GB. Beyond that, and on any mobile network, one long-lived request is the wrong shape: a dropped connection at 95% costs you the whole transfer.

Multipart upload solves this. The client initiates an upload and gets an upload ID, then uploads independent parts (5 MiB to 5 GiB each, up to 10,000 parts), each returning an ETag. The part-count cap sets the part size: a 5 TB object over 10,000 parts needs parts of at least ~524 MiB, and the maximum 48.8 TiB object needs parts near 5 GiB. When all parts are in, the client sends a complete request listing the parts and their ETags, and the storage service assembles the object. Three properties fall out of that design and each is worth stating:

  • Resumability. A failed part is retried alone. The client can query which parts already landed and resume after an app restart or a change of network.
  • Parallelism. Parts are independent, so a client can run 4-8 concurrent uploads and saturate the link rather than being limited by a single TCP stream's throughput.
  • Integrity. Per-part checksums catch corruption at part granularity instead of after the whole 5 GB transfer.

Each part can have its own presigned URL, so the bytes still bypass your app entirely. The one thing you must remember operationally: incomplete multipart uploads consume storage you are billed for and are invisible in a normal object listing. A lifecycle rule to abort uploads older than seven days is not optional, and mentioning it reads as operational experience.

Dry run: one 2.3 GB upload through the presigned-URL flow above

StepWhoWhat happensState after
1. initClientPOST /uploads {filename: "vacation.mp4", size: 2.3 GB}App server generates the object key server-side: uploads/8f2e1c.../vacation.mp4
2. pending rowApp serverInsert metadata row {id, key, size: 2.3 GB, status: "pending"}A row exists; no bytes exist anywhere yet
3. signed URL(s)App server2.3 GB is past the single-PUT comfort zone, so sign a multipart upload at 100 MB/part: ⌈2,300 MB / 100 MB⌉ = 23 presigned part URLs, 10-minute expiry eachClient holds 23 URLs; app server has still never seen a byte
4. PUT bytesClientUploads parts 1-23 directly to the bucket, 6 in parallel; part 14 drops mid-transfer and is retried aloneBytes land in the bucket; metadata row is still "pending"
5. eventObject storageThe client reports the 23 part ETags to the app server (one more small JSON call), which sends CompleteMultipartUpload; storage assembles the parts into one object and emits an object-created eventEvent lands on the queue; the object exists but nothing has confirmed it yet
6. readyWorkerConsumes the event, verifies the checksum and scans the file, flips the row to "ready"Only now is the video visible to any reader

Track which actor touches the file's bytes: the app server never does — it only ever handles the few small JSON calls in steps 1, 3 and 5 (a multipart upload adds the part-signing and complete calls), independent of whether the upload is 200 KB or 200 GB. And the pending → ready transition is driven entirely by the storage layer's own event in steps 5-6, never by the client's word that it finished — exactly the trust boundary the warning box above is about.

Transcoding: the pipeline behind every video product

Raw uploads are unservable. A 4K phone recording is the wrong codec, bitrate and container for most viewers, so you generate a ladder of renditions and let the player adapt to the network.

source object 4K, 3 GB job queue per segment worker worker worker 1080p / 5 Mbps 720p / 3 Mbps 360p / 0.8 Mbps encode is CPU-bound at roughly 1-3× realtime per rendition — split the source into 10-second segments and fan those out
The parallelism unit is the segment, not the file. Fanning out by rendition alone caps a two-hour film at the speed of one CPU.

Three design points to raise. First, transcoding is asynchronous by definition — the upload response returns immediately with a processing status and the client polls or gets pushed a notification, which is the message-queue chapter applied directly. Second, jobs must be idempotent and keyed on the source object plus rendition, so a worker crashing halfway just gets retried without producing duplicates. Third, the output is not one file per rendition but hundreds of small HLS/DASH segments plus a manifest, because that is what lets a player switch bitrate mid-stream — and it means the CDN serves a huge number of small cacheable objects rather than a few enormous ones.

Where not to put blobs: your relational database

Storing a file as a BYTEA or BLOB column is tempting because it gives you transactions and one backup story. It is almost always wrong at scale, for reasons worth being able to list quickly:

  • It destroys your buffer pool. The database caches pages of hot rows in RAM. A 2 MB blob evicts hundreds of useful index and row pages to serve one request. Your unrelated queries get slower.
  • Backups and restores become impossible to schedule. A 200 GB database backs up in minutes; the same database with 8 TB of images does not, and your recovery time objective quietly becomes hours.
  • Replication amplifies it. Every blob write ships to every replica over the replication stream, which is exactly the traffic you least want in a channel whose lag governs your read consistency.
  • You cannot put a CDN in front of a SQL query. Every byte comes out through your connection pool, competing with real queries for connections.
  • Cost. Database storage is priced like block storage, roughly 3-10× object storage, and you are paying it for bytes you never query on.

The correct pattern: blob in object storage, row in the database holding the key, size, content type, checksum and status. You lose atomicity across the two — which is why the pending-then-confirmed state machine in the upload diagram exists, and why an orphan-sweeping job (objects with no row, rows with no object) belongs in the design.

Say it like this → "Blobs go to object storage, metadata goes to Postgres. Uploads go direct from the client via a presigned PUT with a short expiry, a size cap and a server-generated key, so no file byte ever touches an app server. The row starts as pending and is flipped to ready by a worker consuming the bucket's object-created event, after scanning and thumbnailing. Reads are served from the CDN with signed URLs."

Hot, warm, cold: tiering and what it actually saves

Access to stored data is extraordinarily skewed. A photo is viewed heavily in its first week and then approximately never — but must still be there in ten years. Tiering prices that reality.

Tier$/GB-monthRetrievalFirst-byte latencyRight for
Standard (hot)~$0.023free~100-200 msAnything read this month
Infrequent access (warm)~$0.0125~$0.01/GBlike StandardRead a few times a year; 30-day minimum charge
Archive instant~$0.004~$0.03/GBlike StandardCompliance copies you must be able to produce immediately
Archive flexible~$0.0036~$0.01/GBminutes to hoursBackups, old media
Deep archive (cold)~$0.00099~$0.02/GBup to 12 hoursLegal retention you hope never to read

Prices are approximate US East list prices at the time of checking; they vary by region and change, so confirm on the provider's pricing page.

Make the saving concrete. Take 5 PB of user media where 5% is read in any given month. All-hot costs about 5,000,000 GB × $0.023 = $115,000 a month. Move the 95% cold portion to deep archive and it becomes 250,000 × $0.023 + 4,750,000 × $0.00099 ≈ 5,750 + 4,700 = about $10,500 a month — a 90% cut, with the tradeoff that a rare access takes hours and costs a retrieval fee. That is the steady-state number; getting there has a bill of its own. Lifecycle transitions are charged per object (about $0.05 per 1,000 objects into Deep Archive), and Glacier-class storage adds roughly 40 KB of metadata overhead per object (8 KB billed at Standard rates, 32 KB at archive rates). If the 4.75 PB being archived is 500 KB photos, that is about 9.5 billion objects and a one-time transition charge near $475,000, against savings of roughly $104,000 a month: about four and a half months to pay back, plus the 180-day minimum storage charge if you delete early. With 100 MB videos the same transition is a rounding error, so object size decides whether tiering is worth it.

⚠ Aggressive tiering can cost more than it saves Every colder tier adds a minimum storage duration (30, 90 or 180 days) and a per-GB retrieval charge. Push objects to infrequent access after 7 days and then read 20% of them again, and you pay the retrieval fee plus the early-deletion penalty — often more than staying hot. Tier on the access distribution you measured, not on age alone; intelligent-tiering that observes access patterns and moves objects automatically is the safe answer when you do not know the distribution yet.

One more line item people forget entirely: egress. Pulling data out of a cloud region to the internet lists at up to about $0.09 per GB for the first tier (the first 100 GB a month is free, and the rate falls with volume), which is on the order of four times the monthly price of storing the same gigabyte. For a media-heavy product, bandwidth out of the CDN is usually a larger bill than the storage itself, which is the real financial argument for high cache-hit ratios.

Operational details worth one sentence each

  • Request rates. S3 scales per prefix: at least 3,500 write and 5,500 read requests per second per prefix, with no limit on the number of prefixes. Spreading keys across prefixes parallelises hot workloads, and a 503 "Slow Down" means you are scaling faster than the prefix has partitioned.
  • Small files. Billions of tiny objects make per-request cost and per-object overhead dominate. Systems built for photos (Facebook's Haystack and f4) pack many small blobs into large files with an in-memory index to avoid a metadata lookup per read; the lesson for a design is to batch small items into larger objects when you control the write path.
  • Recovery targets. Say the recovery point objective (how much data you can lose) and recovery time objective (how long you can be down) for the blob store, and back them with a 3-2-1 plan (three copies, two media, one off-site or cross-account) and versioning plus object lock against deletion.
  • Origin protection. Put the CDN in front and restrict the bucket to the CDN's identity, so a cache miss storm or a scraper cannot hit the bucket at full price.

Recognizing it in an unseen problem

  • The prompt mentions photos, video, documents, avatars, attachments, backups or "user uploads" — object storage is the answer, and presigned direct upload is the detail that earns the point.
  • A naive design POSTs the file to the API and stores it in the database, then wonders why the app tier autoscales on upload traffic and backups take six hours.
  • Distinguish it from a caching question: caching is about latency for data you already have; storage tiering is about cost for data you rarely touch. They use the same vocabulary (hot/cold) and mean different things.
  • Distinguish durability from availability the moment anyone says "we can't lose the data" — and add that neither one protects against a bad deploy, which is what versioning and object lock are for.
  • Files over ~100 MB, or mobile clients, means multipart and resumable uploads; anything with video means an async transcoding pipeline with idempotent, segment-level jobs.
  • Pitfall: designing the write path beautifully and forgetting the read path. Say how the bytes get back out — signed CDN URLs, cache TTLs, and who pays the egress.
Before you move on
  • Explain why the app server should never see upload bytes, and name the few small JSON calls it handles instead, whatever the file size.
  • Given a file size and a part size, compute how many parts a multipart upload needs, and state the limits (5 MiB to 5 GiB per part, 10,000 parts) that bound the choice.
  • Explain the difference between durability and availability using S3's own numbers, and say what neither one protects against.
  • Name at least two of the five reasons storing blobs in a relational database breaks down at scale.
  • Given an access-frequency split, compute the monthly cost difference between all-hot storage and a hot/cold tiering split, and say what you trade away for the savings.
  • Explain why a lifecycle transition of billions of small objects can take months to pay back, and what you would measure first.
Sources and further reading
  • What is Amazon S3? — the consistency model (strong for PUT, DELETE and LIST; bucket configuration eventual) and S3 Express One Zone
  • S3 multipart upload limits — 48.8 TiB objects, 10,000 parts, 5 MiB to 5 GiB parts
  • S3 data protection and durability — three Availability Zones, checksums, repair, and the 11 nines design target
  • Optimizing S3 performance — 3,500 and 5,500 requests per second per prefix and the 100-200 ms first-byte guidance
  • S3 conditional writes — If-None-Match and If-Match for create-if-absent and compare-and-set
  • Uploading with presigned URLs — what a presigned PUT does and signs, and the 7-day SDK maximum
  • S3 pricing — minimum storage durations, per-object overhead, and the free egress allowance
  • Cost of transitioning S3 objects to Glacier — why per-object transition fees dominate for small objects

Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees. Prices are approximate US East list prices and change.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

I8

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. I7Previous · I7Designing for availability
  2. I8you are here
  3. Next · I9Search systems (surface)The database scans; the inverted index looks up. Everything else in search is ranking and staleness.19 min read

01On this page

9 sections · 0%

  1. 01Three storage abstractions, and the one you should default to
  2. 02Why object storage is the default, and what the durability number means
  3. 03Presigned URLs: get your app servers out of the byte path
  4. 04Large media: chunked, resumable, and parallel
  5. 05Transcoding: the pipeline behind every video product
  6. 06Where not to put blobs: your relational database
  7. 07Hot, warm, cold: tiering and what it actually saves
  8. 08Operational details worth one sentence each
  9. 09Recognizing it in an unseen problem

[] chapters · t top