What you'll learn: By the end of this lesson you will be able to (1) design an upload path that keeps large files off your application servers, (2) split work into a fast synchronous path and a slow asynchronous path, (3) explain how metadata, blobs, CDN and search index fit together, and (4) name the failure modes of each stage and how to recover.
Think of a photo lab with a front counter and a back room. At the counter, a customer hands over a roll of film and gets a numbered receipt in seconds. In the back room, technicians develop the film, print several sizes, and file the results. The counter stays fast because the slow work happens somewhere else, and the receipt lets the customer come back later.
A photo-sharing app works the same way. The upload endpoint is the counter. Resizing, scanning for unsafe content, and indexing are the back room. If you force the customer to wait while every print is made, the line behind them grows and everyone leaves.
This is the running example from the big-picture overview. Here we work it end to end, thinking aloud the way a strong engineer would in a design review. We assume you know the Foundations and Building Blocks courses.
The core idea
Never push large files through your application servers, and never make the user wait for work they do not need to see yet. Bytes go straight to object storage. Small metadata goes to a database. Everything derived (thumbnails, moderation labels, search entries) is produced asynchronously from an event. Delivery goes through a CDN so most reads never touch your own systems.
Requirements and scope
Functional (in scope): upload a photo with a caption, view a photo, view a user's photos, view thumbnails in lists, delete a photo, search by caption and tags, flag content for review.
Upload feels instant: the API responds in well under a second, even for a 5 MB photo on a mobile network (the transfer itself takes longer, but the user sees progress).
Reads are fast: p99 image load under about 300 ms from a warm CDN edge.
Durability is the hardest promise: a photo that was acknowledged must never be lost.
Availability: viewing should survive the loss of an availability zone. Uploading may degrade before viewing does.
Eventual consistency is fine for derived data. A thumbnail appearing a few seconds after upload is acceptable. Metadata should be read-your-writes for the uploader.
Back-of-envelope estimate
Every number below is an illustrative assumption, chosen to make the arithmetic easy.
Quantity
Assumption
Result
Daily active users
10 million
Photos uploaded per day
2 million
about 23 per second average
Peak factor
5x
about 115 uploads per second at peak
Average original size
3 MB
about 6 TB per day of originals
Stored derivatives
4 sizes, roughly 0.5 MB total
adds about 1 TB per day
Photo views per day
100 views per user
1 billion views per day
Read rate
1 billion / 86,400 s
about 11,600 per second average, 58,000 at 5x peak
Read to write ratio
1 billion / 2 million
about 500 to 1
Three conclusions follow. First, this is overwhelmingly a read system, so the CDN does most of the work. Second, storage grows by roughly 2.5 PB per year at these assumptions, so storage tiers and lifecycle rules matter for cost. Third, 115 uploads per second is small for the metadata database. The heavy part is bytes, not rows.
API design
Uploads use a two-step handshake so the bytes never touch our servers.
POST /v1/photos -> 201 { "photoId": "p_8f3a", "uploadUrl": "https://...", "expiresIn": 900 }
PUT <uploadUrl> (client uploads directly to object storage)
POST /v1/photos/p_8f3a/complete -> 202 { "status": "processing" }
GET /v1/photos/p_8f3a -> 200 { "status": "ready", "urls": { "thumb": "...", "large": "..." } }
GET /v1/users/u_42/photos?cursor=...&limit=30
DELETE /v1/photos/p_8f3a -> 204
POST /v1/photos/p_8f3a/flag -> 202
GET /v1/search?q=sunset&cursor=...
The first call creates a metadata row in status pending and returns a presigned URL: a time-limited URL, signed by our credentials, that lets the client write one specific object key directly into the bucket. The client uploads, then calls complete. Large photos can use multipart upload so a dropped connection resumes instead of restarting. Listing endpoints use cursor pagination (see API design) because offset pagination gets slower as users accumulate photos.
TIP
Make POST /v1/photos accept an idempotency key. A mobile client on a flaky network will retry, and you do not want two metadata rows for one photo.
Data model
Three stores, each chosen for what it is good at:
Metadata database (a relational database such as PostgreSQL is a reasonable default at this scale): photos(photo_id, owner_id, status, caption, object_key, width, height, created_at, moderation_state) plus a tags table. An index on (owner_id, created_at DESC) serves the profile page.
Object storage (S3-style): originals and derivatives, with keys such as orig/p_8f3a and thumb/p_8f3a/256.jpg. Objects are immutable. An edit creates a new object.
Search index (an inverted-index engine such as Elasticsearch or OpenSearch): caption, tags and owner, populated asynchronously. It is a derived copy, never the source of truth.
The database stores a key, not the bytes. Putting megabytes into database rows bloats backups, replication and caches.
The relationships between the core records look like this.
How to read it: PHOTOS is the only table the write path must keep correct. DERIVATIVES rows can be rebuilt from the original, and the search index is built from PHOTOS plus PHOTO_TAGS.
High-level design
The upload sequence in detail:
sequenceDiagram
participant C as Client
participant A as Photo API
participant D as Metadata DB
participant S as Object storage
participant Q as Queue
participant W as Worker
C->>A: create photo with idempotency key
A->>D: insert row as pending
A-->>C: photoId and presigned URL
C->>S: PUT original bytes
S->>Q: object created event
C->>A: complete
A-->>C: 202 processing
Q->>W: deliver event
W->>S: fetch original and write thumbnails
W->>D: mark ready
Notice that the storage event, not the client's complete call, is the trigger for processing. If the client crashes after uploading, the photo still gets processed. The complete call exists so the API can confirm the object exists and flip the row to uploaded. A background sweeper handles rows stuck in pending past the URL expiry by deleting the row and any orphan object. Two details keep this safe: status updates must only move forward (a late complete must not overwrite ready), and the storage event should be scoped to the orig/ prefix so the derivatives a worker writes do not trigger more events.
Reference deployment topology
The logical design above becomes the following zones when deployed. Everything stateless runs in at least two availability zones.
How to read it: user-facing traffic enters at the edge, the object store and the queue decouple the app tier from the async tier, and only the workers write to the search index (the API only queries it). Losing the async tier delays thumbnails but does not stop viewing.
Deep dive 1: the asynchronous processing pipeline
A worker receives an event and must: validate that the file really is an image (do not trust the extension or the Content-Type the client claims), strip or normalise metadata, correct orientation, produce derivatives, call moderation, and publish to search.
Make every step idempotent. Queues deliver at least once, so a worker may process the same event twice. Writing thumb/p_8f3a/256.jpg twice is harmless because the key is deterministic. Updating status = ready is harmless too. Avoid steps like "increment a counter" unless you guard them.
Separate queues by cost. Thumbnailing is CPU heavy and predictable, while moderation may call a slower ML service. On one shared queue, a moderation backlog delays thumbnails. Fan out one event to several queues or consumer groups (see message queues).
Dead-letter queue. A corrupt file fails every retry. After a few attempts with backoff, move the message to a dead-letter queue, set the photo to failed, and alert on DLQ depth, so one poison message cannot waste the pool.
Autoscale on queue depth or age, not CPU alone. The metric that matches the user's pain is "oldest message age".
Pipeline choice
Use when
Cost
Alternative
Queue plus worker pool
Steady, CPU-heavy work, simple retries
You operate workers and DLQs
Serverless functions triggered by storage events
Event stream (log)
Many independent consumers, replay needed
More operational weight
Queue with fan-out topics
Synchronous processing in the API
Tiny images, prototype
Slow uploads, API overload
Anything asynchronous
Pre-generate all sizes
Predictable sizes, simple delivery
Storage cost for unused sizes
On-demand resizing at the CDN or an image service
Deep dive 2: delivery through the CDN
At about 58,000 reads per second at peak, serving from our own servers would be wasteful. Photos are immutable, so they are ideal CDN content: give each derivative a versioned, content-based URL and a long Cache-Control: max-age with immutable. An edit produces a new URL, which avoids the hard problem of invalidation. Deletion is different, because the old URL may still be cached (see below).
Choose the cache key carefully. If your URL includes a user-specific token, every user gets a separate cache entry and the hit ratio collapses. Use a stable URL for public photos. For private photos, use signed URLs or signed cookies with a short expiry, and accept a lower hit ratio for that class.
Delivery ladder. The browser cache is checked first, then the CDN edge, then an optional shield tier (an intermediate cache that collapses simultaneous edge misses into one origin request), then object storage. A popular photo falling through to storage means a misconfigured CDN or a viral photo that many edges missed at once.
Deletion is the awkward case. The database row is gone immediately, but edges may keep serving the cached image until its TTL ends. Options: a CDN purge (supported by most CDNs but not instant everywhere), short TTLs for sensitive content, or an authorization check at the edge. For legally required takedowns, combine purge with deletion from storage and accept a brief propagation window. Say that window out loud in an interview.
Deep dive 3: moderation and search
Moderation hook. Treat moderation as a consumer of the same event. The worker sends the image (or a derivative) to a classifier and records a label and score. Three outcomes are typical: clearly fine (publish), clearly unsafe (block and notify), uncertain (queue for human review). While in review the photo may be visible only to its owner.
The design question is whether to publish first or hold until checked. Publishing first is faster for users but exposes the platform briefly. Holding until checked is safer but adds latency and a hard dependency. Many designs let a new account's photos wait for checks while trusted accounts publish first, and run checks anyway. State the policy explicitly, because it changes the state machine: pending, uploaded, processing, ready, held, failed, deleted. The decision flow below is the central trade-off of this design.
flowchart TD
S["New photo uploaded"] --> A{"Account trusted and old enough?"}
A -->|yes| B["Publish now, moderate in background"]
A -->|no| C{"Is the classifier fast and available?"}
C -->|yes| D["Hold until the check returns"]
C -->|no| E["Owner-only view, queue for review"]
B --> F{"Flagged later?"}
F -->|yes| G["Hide and purge from CDN"]
F -->|no| H["Stay published"]
D --> I{"Verdict"}
I -->|clean| B2["Publish"]
I -->|unsafe| G
I -->|uncertain| E
How to read it: trust decides whether you pay latency up front or exposure risk later, and the classifier's availability decides whether holding is even possible.
WARNING
User-reported flags must reach a human review queue, and the reviewer tooling must be able to remove content from the CDN too. A report button that only sets a database flag does not remove anything from the edges.
Search. The indexer consumes a "photo ready" event and upserts a document. Because the index is derived, you can rebuild it from the database if it is lost or its schema changes. The risk is drift: if you dual-write (API writes to DB and index) and one write fails, they diverge. Prefer one write to the database and an event (or change-data-capture) that updates the index. On delete, publish a delete event, and also filter results by checking the database for visibility, so a lagging index cannot leak deleted photos. See search and indexing for the mechanics.
Bottlenecks and scaling
Metadata reads. At about 58,000 image reads per second, the database is not on the image path, but page loads also fetch metadata. Cache hot photo rows with the cache-aside pattern, and add read replicas for profile listings.
Metadata writes. About 115 writes per second at peak is easy for one primary. Shard by owner_id only if growth or write volume eventually demands it (see sharding). Sharding by owner keeps a profile page on one shard.
Object storage. Managed object storage scales horizontally, but request rates per key prefix and per-account limits exist, so spread keys and read your provider's documented limits rather than assuming none.
Worker pool. Scale on queue age. Set a maximum so a backlog cannot exhaust budget or overwhelm downstream services.
Egress and storage cost. Egress usually dominates. A good hit ratio at the CDN, modern formats such as WebP or AVIF where supported, and appropriate image sizes cut it. Move old originals to colder storage tiers when access drops, with lifecycle rules.
Failure modes
Failure
What users see
Defence
Client drops mid-upload
Stuck progress bar
Multipart resume, retry with idempotency key
Upload succeeds, complete never arrives
Photo stays pending
Storage event triggers processing, sweeper cleans up
Worker crashes mid-job
Delay
Visibility timeout returns message to queue, idempotent steps
Poison message (corrupt file)
One photo fails
Retry limit, DLQ, status failed with a clear message
Queue backlog
Thumbnails late
Serve the original scaled by the client as a placeholder, autoscale workers, alert on age
Search index down
Search errors
Degrade gracefully: hide search, keep uploading and viewing
Metadata primary fails
Writes fail
Replica promotion, retry with backoff, read from replicas meanwhile
Region outage
Everything
Cross-region replication of storage and database, see multi-region
Durability deserves its own sentence. Managed object stores typically replicate data across several devices and zones, but replication does not protect against your own bugs or a malicious delete. Soft deletes (mark first, purge after a delay) and versioning protect against those.
Trade-offs worth stating
Decision
Option A
Option B
How to choose
Upload path
Via API servers
Presigned direct upload
Direct upload for anything over a few hundred KB
Resizing
Pre-generate sizes
On demand at the edge
Pre-generate for a fixed few sizes, on demand for flexible layouts
Publish policy
Publish then moderate
Moderate then publish
Depends on risk, abuse history and legal obligations
Private photos
Signed URLs
Proxy through API
Signed URLs unless every read must be audited
Index sync
Dual write
Event or CDC
Event or CDC, to avoid drift
Interview cheat sheet
Step
What to say
Key numbers (illustrative)
Requirements
Upload, view, list, delete, search, flag. Durability first
10M DAU
Estimate
Read heavy, byte heavy
115 uploads/s peak, 58k reads/s peak, about 2.5 PB per year
API
Create, direct PUT, complete, cursor lists
Idempotency keys
Data
Metadata in SQL, blobs in object storage, search index derived
Key, not bytes, in DB
Design
Presigned upload, event to queue, workers, CDN
Async derivatives
Deep dives
Idempotent pipeline and DLQ, CDN cache keys and deletion, moderation state machine
Queue age as scaling signal
Failures
Orphans, poison messages, backlog, purge lag
Soft delete, versioning
Trade-offs
Publish policy, resize strategy, signed URLs
Egress cost dominates
In practice: operating the reference design
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
A design is only finished when someone can run it. Assume a team of eight engineers owns the platform on Kubernetes, with PostgreSQL, Redis, an S3-compatible object store, a managed CDN, OpenSearch, Prometheus and Grafana.
CDN hit ratio, 5xx by region, origin requests per second
Durability
Zero acknowledged photos without an original
Nightly reconciliation: ready rows with no object
Takedown
99% invisible within 15 minutes
Purge request age, takedown backlog
Alert on burn rate of the first three, page on durability mismatches immediately, and keep hit ratio as a ticket-level signal because it drives cost rather than correctness.
Capacity-review cadence
Weekly: queue age percentiles, worker saturation, DLQ contents, replica lag.
Monthly: storage growth against the 2.5 PB per year forecast, egress per view, CDN hit ratio by content class, search index size.
Quarterly: load test at twice the assumed peak (about 230 uploads per second and 116,000 reads per second), restore drill for the metadata database, lifecycle-rule audit.
Before any campaign or launch: re-run the estimate with the marketing forecast.
Runbook
Symptom
Likely cause
First check
Mitigation
Photos stuck in processing
Worker backlog or crashed pool
Oldest message age, worker pod restarts
Scale workers to the cap, pause low-priority queues
DLQ depth climbing
Poison file or a bad worker release
Sample three DLQ messages, compare with the deploy time
Roll back the release, replay after the fix
Hit ratio drops suddenly
Cache key change or purge storm
Recent CDN config changes, purge request count
Revert the config, enable the shield tier
Deleted photo still visible
Purge lag or an unfiltered index
Purge status, a direct fetch of the URL
Re-issue purge, filter by database visibility
Search results missing new photos
Indexer lag or index down
Indexer consumer lag
Replay events, degrade search quietly
Upload 403 from storage
Clock skew or expired presigned URL
Signing host time, URL expiry setting
Fix NTP, lengthen expiry slightly
Decision record: publish policy
Field
Entry
Context
Growth wants instant posting. Trust and safety wants nothing unsafe visible. The classifier is a separate service with its own failures.
Decision
Trusted accounts publish first and are moderated in the background. New accounts are held until the check returns, with a 30 s budget after which the photo is owner-only and queued for human review.
Consequences (good)
Most uploads feel instant, and new-account abuse is blocked before exposure.
Consequences (bad)
Two code paths, a trust score to maintain, and some new users wait longer.
Revisit when
Removed-after-publish photos exceed an agreed share of uploads for two weeks, or classifier p95 exceeds 30 s.
Cost view
Egress dominates, then storage, then compute. Request counts matter too, because each upload creates several objects.
Cost driver
Lever
CDN egress
Raise hit ratio, serve AVIF or WebP where supported, serve the right size per screen
Interruptible nodes, scale on queue age with a hard cap
Search and database
Right-size the index, keep bytes out of PostgreSQL
Architect's notes
Measure the size mix before choosing derivatives. Log which sizes clients actually request for a month. Sizes nobody requests are pure storage cost.
Ask who owns takedowns. In the design review, name the person who can order a purge at 3 a.m. and the audit trail they leave.
Orphans accumulate quietly. Abandoned multipart uploads and pending rows cost money. Put a lifecycle rule on them on day one.
Treat the classifier as a dependency with an SLO. Its outage must degrade to owner-only visibility, not to a blocked upload path.
Defer sharding. At about 115 writes per second, a single PostgreSQL primary with replicas is enough. Set the revisit trigger as a number, not a feeling.
Test the restore, not the backup. Durability claims are only as good as the last restore drill.
Common mistakes
Uploading through the API servers. Large bodies tie up connections and memory and force you to scale app servers for bandwidth. Use presigned direct uploads.
Storing image bytes in the relational database. Backups, replicas and caches balloon. Store keys, keep bytes in object storage.
Doing thumbnailing inside the upload request. Latency and timeouts follow. Acknowledge fast and process asynchronously.
Non-idempotent workers. At-least-once delivery will duplicate work. Use deterministic keys and conditional updates.
Forgetting deletion and takedown paths. Cached copies and index entries outlive the row. Plan purge, delete events and visibility checks.
Making the search index the source of truth. It will drift or be lost. Treat it as rebuildable from the database.
Trusting the client's file type or size. Validate content server-side after upload, and constrain size at upload time where the storage service supports it (for example with a presigned POST policy).
Key takeaways
Keep bytes off the app tier. Presigned URLs let clients write directly to object storage.
Acknowledge fast, process later. A queue and workers separate the user-visible path from slow derived work.
Idempotency is mandatory. At-least-once delivery means every pipeline step must tolerate repeats.
The CDN does most of the reading. Immutable content with stable URLs gives a high hit ratio.
Derived data is rebuildable. Thumbnails and search entries can be regenerated from originals and metadata.
Deletion is harder than creation. Plan for purge lag, soft deletes and visibility checks.
Scale on the right signal. Queue age reflects user pain better than CPU does.
Name your policies. Publish-then-moderate versus moderate-then-publish changes the whole state machine.
Practice exercises
Exercise 1 — Starter (30 min): Draw the photo state machine with every state in this lesson (pending through deleted). Label each transition with the event or actor that causes it, and mark which transitions the sweeper performs.
Exercise 2 — Intermediate (2 hours): Redo the estimate for 50 million daily users with 200 million uploads per day and an average original of 2 MB. Compute uploads per second at peak, daily and yearly storage growth, and the egress per day if the CDN hit ratio is 95% versus 80% and each view transfers 150 KB. State which assumption moves the cost most.
Exercise 3 — Advanced (half day): Design the takedown flow for a legally required removal: database, object storage, CDN purge, search index, backups, and any derived caches. Write the order of operations, what happens if each step fails, and the maximum time the content can remain visible. Then decide what you would monitor to prove the takedown completed.
Lesson 10 of 14 · Advanced System Design and Case Studies
Design a photo-sharing service end to end: presigned uploads, object storage, async processing, CDN delivery, moderation, search, and how it fails.
In the rest of this lesson
High-level design
Reference deployment topology
Deep dive 1: the asynchronous processing pipeline
Deep dive 2: delivery through the CDN
Deep dive 3: moderation and search
Bottlenecks and scaling
Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.