Skip to content

    System Design

    System Design Case Study: Photo Sharing App

    Design a photo-sharing service end to end: presigned uploads, object storage, async processing, CDN delivery, moderation, search, and how it fails.

    By Published 17 min read

    Share

    This lesson is part of Advanced System Design and Case Studies. Enroll for free, then sign in, to read it in full.

    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.

    Out of scope for this lesson: the social graph and feed (see the news feed case study), video (see the video streaming case study), comments, and payments.

    Non-functional targets (assumptions):

    • 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.

    erDiagram USERS ||--o{ PHOTOS : owns PHOTOS ||--o{ PHOTO_TAGS : has PHOTOS ||--o{ DERIVATIVES : produces PHOTOS ||--o{ FLAGS : receives USERS { string user_id PK string trust_level } PHOTOS { string photo_id PK string owner_id FK string status string moderation_state string object_key datetime created_at } PHOTO_TAGS { string photo_id FK string tag } DERIVATIVES { string photo_id FK string size string object_key } FLAGS { string flag_id PK string photo_id FK string reason }

    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

    Photo sharing high-level designClient app to Photo API (1 create); Photo API to Client app (URL); Photo API to Metadata DB (pending); Client app to Object storage (2 upload); Client app to CDN (3 view); CDN to Object storage (miss); Object storage to Queue (event); Queue to Processing workers; Processing workers to Object storage (read); Processing workers to Object storage (write); Processing workers to Metadata DB (ready); Processing workers to Moderation service; Processing workers to Search indexer; Search indexer to Search index; Photo API to Search index (search)Client appClient appPhoto APIPhoto APIMetadata DBMetadata DBSearch indexSearch indexCDNCDNObject storageObject storageProcessing workersProcessingworkersSearch indexerSearch indexerQueueQueueModeration serviceModerationservice1 createURLpending2 upload3 viewmisseventreadwritereadysearch

    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.

    Photo sharing reference deployment topologyLoad balancer to Photo API pods; Photo API pods to PostgreSQL (writes); Photo API pods to Redis; Photo API pods to Read replica (on a miss); Photo API pods to Search index (search queries); PostgreSQL to Read replica (replication); Object storage to Queue (events); Queue to Worker pool; Queue to Dead-letter queue (after retries); Worker pool to Object storage; Worker pool to PostgreSQL; Worker pool to Search index; CDN to Object storage (miss)EDGEAPP TIER, TWO ZONESDATA TIERASYNC TIERCDN: with shield tierCDNwith shield tierLoad balancerLoad balancerPhoto API pods: zones A and BPhoto API podszones A and BRedis: cache-aside readsRediscache-aside readsObject storageObject storagePostgreSQL: primaryPostgreSQLprimaryRead replicaRead replicaDead-letter queueDead-letterqueueQueueQueueWorker poolWorker poolSearch indexSearch indexwriteson a misssearch queriesreplicationeventsafter retriesmiss

    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.

    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
    Accidental deletion or bug Data loss Object versioning, soft delete window, tested backups

    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.

    SLOs and dashboards

    SLI SLO (assumed) Dashboard panels
    Create and complete calls succeed 99.9% over 30 days Success ratio, p99 latency, 5xx by endpoint
    Time to ready 99% of photos ready within 60 s Oldest queue message age, per-stage duration, DLQ depth
    Image delivery 99.5% of edge requests succeed 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
    Storage Lifecycle rules to colder tiers, expire abandoned multipart uploads, avoid unused sizes
    Worker compute 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.

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments