Where files go. Effectively infinite, absurdly durable, remarkably cheap — and the answer to “where do we put the images?” in almost every design.
Prerequisites: CDN, Databases Overview Time to read: ~18 minutes
Users upload profile photos. Where do they go?
Option A — in the database, as a BLOB. 🚨 Almost always wrong. Your 50 GB database becomes 5 TB. Backups take hours and restores take longer. Replication ships megabytes per row. Your buffer pool fills with image bytes instead of the indexes you actually query, and every query gets slower. You’re paying database prices (~$0.10+/GB with replication) for data that needs none of a database’s features.
Option B — on the server’s local disk. 🚨 Also wrong, and it breaks the moment you have two servers. The upload lands on server 1; the next request hits server 2, which returns 404. It makes your app tier stateful, so instances aren’t interchangeable, and a disk failure loses user data permanently.
Option C — object storage. A separate system built for exactly this.
A flat key-value store for large immutable blobs, accessed over HTTP.
PUT /my-bucket/users/42/avatar.jpg (with metadata + content-type)
GET /my-bucket/users/42/avatar.jpg
DELETE /my-bucket/users/42/avatar.jpg
That’s essentially the whole API. What you get for that simplicity:
| Property | Detail |
|---|---|
| Effectively unlimited | Petabytes. No capacity planning, no provisioning. |
| Extreme durability | S3 advertises 99.999999999% — eleven nines. Data is erasure-coded across multiple facilities. |
| Cheap | ~$0.023/GB/month, and far less for cold tiers. Compare to ~$0.10/GB for block storage. |
| HTTP-native | Serve directly to browsers; put a CDN in front trivially. |
| Versioning | Keep every version of an object; recover from accidental deletes. |
| Lifecycle rules | Automatically move to cheaper tiers or delete after N days. |
| Server-side encryption | At rest by default, with your keys or theirs. |
🚨 Eleven nines of durability, but only ~99.99% availability. The distinction matters: your data is essentially never lost, but you might not be able to read it for a few minutes. → Availability & Reliability
Systems: AWS S3, Google Cloud Storage, Azure Blob Storage, Cloudflare R2, Backblaze B2, MinIO (self-hosted, S3-compatible).
A distinction that gets asked, and the differences have real consequences.
| Block storage | File storage | Object storage | |
|---|---|---|---|
| Abstraction | Raw disk | Directory tree | Flat key → blob |
| Access | Mount it, use a filesystem | NFS/SMB mount | HTTP API |
| Partial update | ✅ Modify any byte | ✅ | ❌ Replace the whole object |
| Attach to | One machine at a time | Many machines | Anything with HTTP |
| Latency | ~0.1 ms | ~1 ms | ~10–100 ms |
| Scale | Limited by volume size | Limited | Effectively unlimited |
| Cost/GB | High (~$0.10) | High | Low (~$0.023) |
| Examples | EBS, local SSD | EFS, NFS, FSx | S3, GCS, Azure Blob |
🚨 Objects are immutable. You cannot append a byte or edit in place — you replace the entire object. This is why object storage is wrong for database files, active logs being written, or anything requiring random writes. It’s exactly right for photos, videos, backups, and static assets.
The latency point matters too. ~50 ms for a first byte is fine for a photo behind a CDN and completely wrong for something in a request’s hot path. Don’t put your session store in S3.
This is the standard design, and it’s the answer expected in interviews.
CREATE TABLE photos (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
storage_key TEXT NOT NULL, -- 'photos/2026/07/a3f5c9e2.jpg'
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
width INT,
height INT,
created_at TIMESTAMPTZ DEFAULT now()
);
The database stores what you query and join on; object storage stores the bytes. The database stays small and fast; storage stays cheap and infinite.
🎙️ “Photo metadata goes in Postgres — that’s what we query and join on. The bytes go to S3 behind a CDN. Putting 2 MB blobs in the database would balloon it and push our indexes out of the buffer pool.”
🚨 Never proxy large uploads through your application servers. It’s one of the most valuable things to say in a design interview involving media.
The naive flow:
Client → [2 MB] → Your API server → [2 MB] → S3
Your server’s bandwidth, memory, and request duration are all consumed moving bytes it does nothing with. At 1 Gbps, one server caps out around 60 concurrent 2 MB uploads.
The correct flow — pre-signed URLs:
sequenceDiagram
participant C as Client
participant A as API
participant S as S3
C->>A: POST /photos/upload-url (filename, size, type)
Note over A: authorize, validate size/type,<br/>create DB row (status: pending)
A-->>C: pre-signed PUT URL (expires in 5 min) + photo_id
C->>S: PUT the bytes directly ⚡ (never touches your servers)
S-->>C: 200 OK
C->>A: POST /photos/{id}/complete
Note over A: mark ready, enqueue thumbnail job
The pre-signed URL is a time-limited, cryptographically signed link that grants permission for one specific operation on one specific key. S3 validates the signature itself.
What your API still controls: who may upload, the key/path, the maximum size, the allowed content type, and the expiry. You keep authorization; you just don’t carry the bytes.
Downloads work the same way — issue a pre-signed GET for private content, so the CDN serves it without your origin being involved per request. → CDN
🚨 The orphan problem is the follow-up question. If the client uploads and then never calls
/complete (they closed the tab), you have bytes in S3 with no database row — or a pending row
forever. Fix: a lifecycle rule deleting incomplete uploads after 24 hours, plus a reconciliation job
comparing storage to database rows. Mentioning this unprompted is a strong operational signal.
Not all data deserves the same price.
| Class | Cost/GB/mo | Retrieval | Use for |
|---|---|---|---|
| Standard | ~$0.023 | Instant | Active content |
| Infrequent Access | ~$0.0125 | Instant, per-GB fee | Accessed monthly |
| Glacier Instant | ~$0.004 | Instant, higher fee | Archives you might need fast |
| Glacier Flexible | ~$0.0036 | Minutes to hours | Compliance archives |
| Deep Archive | ~$0.00099 | 12 hours | Long-term retention you’ll probably never read |
Lifecycle rules automate the transitions:
Day 0 → Standard
Day 30 → Infrequent Access
Day 90 → Glacier
Day 365 → Deep Archive
Day 2555 → Delete (7-year retention policy satisfied)
📐 Why this matters: 1 PB in Standard is ~$23,000/month. The same petabyte in Deep Archive is ~$1,000/month. For data like old user uploads or compliance logs, lifecycle policies are a 20× cost reduction for one config file.
🚨 The trap: retrieval from cold tiers costs money and time. A “cheap” archive you actually read often is more expensive than Standard. And IA/Glacier have minimum storage durations (30/90/180 days) — deleting early still bills you for the minimum. Also: transitioning has a per-object fee, so lifecycle-ing billions of tiny objects can cost more than it saves.
🎙️ “I’d put uploads in Standard and lifecycle them to Infrequent Access at 30 days and Glacier at 90 — most photos are viewed heavily in the first week and almost never after. That’s roughly a 5× storage cost reduction with no user-visible change.”
S3 has been strongly read-after-write consistent since December 2020. Write an object, read it, and you get the new version — including overwrites and deletes.
🚨 This changed, and older material says otherwise. If you say “S3 is eventually consistent, so we need to handle stale reads,” you’ll be describing a system that hasn’t existed for years. Know the current behaviour.
What’s still true:
Key naming. Keys are flat strings; “folders” are just prefixes. Historically, sequential prefixes
(2026-07-22-...) created hot partitions, and the advice was to add a random prefix. S3 now
auto-scales partitions, so this is largely obsolete — but organizing keys by a hash prefix or by
tenant is still good practice for listing performance and for lifecycle rules.
Multipart upload. For files over ~100 MB, upload in parallel chunks. You get better throughput, resumability (retry only the failed part), and the ability to upload files larger than 5 GB. Required over 5 GB.
Versioning. Keeps every version of an object. Excellent protection against accidental deletion or a bad deploy overwriting assets. Costs storage for every version, so pair it with a lifecycle rule that expires non-current versions.
Costs beyond storage. 🚨 Egress is usually the biggest line item. Storage at $0.023/GB looks cheap; egress at $0.05–0.09/GB does not. Serving 100 TB/month costs ~$2,300 to store and ~$7,000+ to serve. This is a major reason to put a CDN in front (cheaper egress, and it absorbs most requests), and why Cloudflare R2’s zero-egress pricing is disruptive. Request costs matter too at very high volume: millions of tiny objects incur real per-request charges.
Security. Buckets should be private by default. Public buckets are a well-known source of data breaches — misconfigured S3 buckets have exposed medical records, voter data, and credentials repeatedly. Serve private content via pre-signed URLs or a CDN with origin access control, enable “block public access” at the account level, and turn on access logging.
| Decision | Gain | Cost |
|---|---|---|
| Object storage over DB blobs | Small fast database; cheap infinite storage | Two systems to keep consistent; orphan cleanup |
| Pre-signed direct upload | Servers never carry bytes | Orphan objects; more client-side complexity |
| Lifecycle to cold tiers | Up to 20× cheaper storage | Retrieval cost and delay; minimum durations |
| Versioning | Recover from mistakes | Storage cost per version |
| CDN in front | Lower latency and cheaper egress | Cache invalidation; another layer |
| Multi-region replication | Survives a region failure; lower global latency | 2× storage cost; asynchronous |
1. Run S3 locally. MinIO in Docker is S3-compatible and takes two minutes:
docker run -p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=admin -e MINIO_ROOT_PASSWORD=password123 \
minio/minio server /data --console-address ":9001"
Then use the AWS SDK against http://localhost:9000. Everything you learn transfers to real S3.
2. Implement the pre-signed upload flow end to end. An endpoint that issues a pre-signed PUT, a client that uploads directly, and a completion callback. This is genuinely the most useful 45 minutes in this chapter — it’s a pattern you’ll implement repeatedly in real work, and it makes the interview answer concrete.
3. Prove the orphan problem exists. Request a pre-signed URL, upload the file, then don’t call the completion endpoint. Look at your database and your bucket. Now write the reconciliation job.
4. Compare costs honestly. Take a realistic scenario — 10 million photos at 2 MB, each viewed 20 times a month — and compute storage vs egress vs CDN-fronted egress. The ratio will change how you argue for a CDN.