Design Photo Sharing
Design a photo app with follows and a feed: direct uploads, a resizing pipeline, CDN delivery, the metadata partition key and hot celebrity posts.
On this page
You take a photo, add a caption and tap Share. Seconds later your friends see it in their feed, sharp on a big phone and quick on a slow network. Instagram is the kind of product this question means: upload photos, follow people, scroll a feed.
Interviewers like it because it mixes two kinds of data. Photos are huge, never change and are read millions of times, so they belong in object storage behind a CDN. Metadata (posts, follows, likes) is tiny, changes constantly and must be partitioned well. A good answer keeps the two apart. The steps follow A Framework for Any Design. The feed itself is covered in Design a News Feed, so this lesson spends its words on what photos change.
Clarify the problem
Design a photo sharing app.
Users upload a photo with a caption, follow people, see a feed of followed accounts, view a profile grid, and like posts. Are comments, stories, video or messages in scope?
Leave out stories, video and messages. Likes yes.
What scale, and how skewed are follower counts?
200 million daily users, about 50 million new photos a day. Most people have a few hundred followers; some accounts have 50 million.
Then the bytes are almost all images. My priorities: an uploaded photo is never lost, images load fast anywhere, and the feed is quick even if a few seconds stale. A post reaching followers within 10 seconds is fine, and like counts can be approximate.
- Functional: upload a photo with a caption; follow and unfollow; home feed, newest first; profile grid; like and unlike.
- Non-functional: no photo lost once the app says "posted"; feed served in under 200 ms; images start showing within about a second; posts reach followers within 10 seconds; reads stay available, a few seconds stale at worst.
- Out of scope: video, stories, messages, search, ads, ranking models.
Estimate the scale
A day has 86,400 seconds. Assume peaks are 3 times the average.
- Uploads: 50 million ÷ 86,400 ≈ 580 photos a second, about 1,700 at peak.
- Feed loads: 10 opens per user per day is 2 billion ÷ 86,400 ≈ 23,000 a second, about 69,000 at peak.
- Bytes per photo: a 2 MB original plus three sizes we make: 30 KB thumbnail, 120 KB feed size, 250 KB full screen. 2 MB + 400 KB = 2.4 MB.
- Ingest: 580 × 2 MB ≈ 1.2 GB/s into storage.
- Metadata: about 500 B per post: 50 million × 500 B = 25 GB a day, about 9 TB a year.
How much photo storage does one year of uploads add, before replication?
- 50 million new photos a day
- 2 MB original plus 400 KB of resized copies per photo
- Nothing is deleted
Show the working
- Per day: 50 × 106 × 2.4 MB = 120 TB
- Per year: 120 TB × 365 ≈ 44 PB
- Originals are 2 ÷ 2.4 of that, about 36.5 PB; the sizes people view are about 7.3 PB
Estimate 44 PB (anything from 15 PB to 150 PB is a good answer)
Originals are rarely read after processing, so moving them to a colder storage class after a month cuts the cost of most of the bytes.
Each feed load shows about 10 photos at the 120 KB feed size. What is the average outgoing image bandwidth?
- 23,000 feed loads a second
- 10 photos per load
- 120 KB per photo
Show the working
- Per load: 10 × 120 KB = 1.2 MB
- 23,000 × 1.2 MB ≈ 28 GB/s, about 83 GB/s at peak
Estimate 28 GB/s (anything from 9 GB/s to 90 GB/s is a good answer)
That is a CDN's job. If the CDN serves 95% of it, the photo store still sends 5% × 28 GB/s ≈ 1.4 GB/s.
The feed JSON for those 10 posts is about 10 KB, so 1.2 ÷ 1.21 ≈ 99% of what a user downloads is pictures.
Write the endpoints for uploading and posting a photo, reading the feed, following and liking. Where do the image bytes travel?
Compare with a model answer
Ask the API for an upload slot and get a short-lived signed URL, send the bytes straight to object storage, then create the post. Feed reads return metadata with CDN URLs for each size; image bytes never pass through the API.
Define the API
POST /v1/uploads body: { "content_type": "image/jpeg", "bytes": 2048000 }
201 { "photo_id": "p7001", "upload_url": "https://store.../p7001/original?sig=...",
"expires_in": 900 }
413 Payload Too Large over the 20 MB limit
PUT {upload_url} the raw bytes, straight to the photo store
POST /v1/posts Idempotency-Key: (client-generated)
body: { "photo_id": "p7001", "caption": "Harbour at dusk" }
202 { "post_id": "1849...", "status": "processing" }
409 Conflict photo already used by another post
GET /v1/feed?cursor=...&limit=10
200 { "posts": [ { "post_id", "author", "caption", "likes": 12400,
"images": { "thumb": "https://cdn.../p7001/t.v1.jpg", "feed": "...", "full": "..." } } ],
"next_cursor": "..." }
GET /v1/users/{id}/posts?cursor=... profile grid, newest first
PUT /v1/users/{id}/followers/me follow (DELETE to unfollow)
PUT /v1/posts/{id}/likes/me like (DELETE to unlike)The decision worth arguing: creating a post returns 202 with status processing. The bytes reached storage without the API seeing them, so nothing is promised until a worker has checked the file and made the sizes. The author's app shows the post from its local copy; followers see it once it is ready. Follow and like are PUT on me, so a retry is harmless: liking twice is still one like.
Sketch the records for posts, follows and likes. Pick a partition key for each and say which reads it serves.
Compare with a model answer
Posts partitioned by author and sorted by a time-ordered id, so a profile is one partition read; feed entries store (author, post) pairs. Follows stored once per direction. Likes partitioned by the user who liked, with a separate count per post. A wide-column store fits.
Model the data
posts PRIMARY KEY ((author_id), post_id) -- post_id DESC
author_id BIGINT
post_id BIGINT -- 64-bit, time-ordered
photo_id VARCHAR(16)
caption TEXT -- up to 2,200 chars
status VARCHAR(10) -- processing | ready | failed | deleted
width, height, image_version INT
followers PRIMARY KEY ((followee_id, bucket), follower_id)
following PRIMARY KEY ((follower_id), followee_id)
photos PRIMARY KEY (photo_id)
post_id BIGINT NULL, processed BOOLEAN -- see "upload"
likes PRIMARY KEY ((user_id), post_id) -- did I like it?
like_counts PRIMARY KEY (post_id)
count BIGINT, last_offset BIGINT -- see "hot posts"Every access is "one key, newest first": an author's posts, a user's followers, a batch of posts for a feed page. Nothing joins. That fits a wide-column store such as Cassandra: it partitions by key, keeps rows sorted inside a partition and takes a high write rate. A relational database would also work for a while, then need sharding by hand.
Post ids come from a time-ordered ID generator, so sorting by id is sorting by time and a cursor is "older than this id". Keep 3 replicas and read and write with a quorum of 2: since 2 + 2 > 3, every read overlaps a replica that has the latest acknowledged write.
Sketch the boxes: where do bytes go on upload, what makes the three sizes, where are images served from, and when does a post reach followers?
Compare with a model answer
The phone sends bytes to a photo store with a signed URL. The store emits an event; resize workers make the sizes, mark the post ready and trigger fan-out. A CDN serves images; the API serves metadata and asks the feed service for post ids.
High-level design
Two paths that barely touch: a media path moving gigabytes a second, and a metadata path moving small JSON. The "ready" step joins them.
request and replyasynchronous message
1Mia picks a 2 MB photo. The API reserves
p7001and returns a URL signed for one PUT of at most 20 MB, valid 15 minutes.2The app sends the bytes straight to the photo store, so 1.2 GB/s of uploads costs the API fleet nothing.
3The app creates the post. It is stored as
processing, the API answers 202, and Mia's app shows the post from its local copy.- attempt
- 1
4The store publishes "object created". A resize worker decodes the file, which proves it is an image, and strips the camera's location data.
- attempt
- 2
5The worker crashes halfway. It never acknowledged the event, so after a 60-second visibility timeout the queue gives it to another worker.
- attempt
- 2
6The second worker writes 3 sizes to keys fixed by photo id and size, overwriting any half-written file, and sets
processedon the photo row. The post has already claimed the photo, so the worker marks itready.7Only now does fan-out start. Mia has 400 followers, so (Mia, post id) goes into 400 cached feeds. No follower sees a post without its picture.
8Leo opens his feed. The API gets 10 (author, post) pairs from the feed service and reads each post from its author's partition: about 10 KB of JSON.
9His app fetches each 120 KB feed image from the CDN. The first viewer near this edge misses, so the edge fetches it from the store once and keeps it.
10Another user gets an upload URL and never sends the file. A sweeper deletes posts still
processingafter 24 hours; a store rule deletes originals with no post.
Deep dives
The media path: upload, resize, serve
Upload. Signed URLs are explained in Blob Storage. What photos add is distrust: the bytes never passed your code. So the signed URL fixes the key, maximum size and content type, and the post stays processing until a worker has decoded the file. A file that does not decode is deleted and the post marked failed.
Ordering. The worker can finish before the app creates the post, find nothing to mark, and leave the post stuck in processing. So both sides write the photo's own row with a compare-and-set: the post request sets post_id only if it is empty (this is also the 409), and the worker sets processed. Whichever write lands second sees both fields, marks the post ready and starts fan-out, exactly once.
Resize. Say 0.2 seconds of CPU per photo for all three sizes: 580 × 0.2 ≈ 120 cores on average, 1,700 × 0.2 ≈ 350 at peak. Autoscale on queue depth, not CPU, because depth is what delays posts.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Eagerly, after upload Usual default | Every size exists before anyone views the post; serving is a plain file read. | A few seconds before the post is ready. A new size later means a backfill over every photo. | A small fixed set of sizes. The usual answer here. |
| On demand at the edge | Any size or format on request; nothing made for photos nobody views. | The first view of each size waits for a resize, and a viral post's first seconds send a burst of resize work to the origin. | Many devices need many sizes and most photos are rarely viewed. |
| On the phone | No server CPU; smaller uploads. | You cannot trust or control the output, and you still need a server path for new formats. | As a first step to shrink uploads, never the only one. |
I would resize eagerly and accept the backfill cost: one year of photos is 18 billion × 0.2 s = 3.6 billion CPU-seconds, about 4,000 cores for 10 days, so choose the size list with care.
Serve. Each image URL holds the photo id, size and a version: /p7001/t.v1.jpg. A URL's content never changes, so the CDN may cache it for a year; re-processing writes v2 and updates the post, and nothing needs purging. Add a regional origin shield between edges and store, so hundreds of edges missing on the same new photo send one request to the store. Private accounts get short-lived signed CDN URLs (see CDNs).
Metadata and the partition key
Posts are read two ways: a profile grid (one author, newest first) and a feed page (10 posts by different authors). One key must serve both.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| By post id | Load spreads evenly; a feed page is 10 direct lookups. | A profile grid needs a second table, author to post ids, kept in step. | Posts are fetched one at a time and profiles are rare. |
| By author, sorted by post id Usual default | A profile is one sorted partition read. Feed entries carry the author, so a feed page is still 10 direct lookups. | A famous author's partition gets heavy reads; feed entries are 16 bytes instead of 8. | Profiles are a main screen, as here. |
| By day | Easy to archive old data. | All 580 writes a second and all fresh reads land on today's partition. | Almost never as the main key for user data. |
Partition by author. A heavy poster's partition is 10,000 posts × 500 B = 5 MB, which is small; the hot-read risk is a caching problem, covered next. Follows are written twice, because "who follows Mia" (fan-out) and "whom does Leo follow" (pulling big accounts) live in different partitions. A 50-million-follower list would be 50 × 106 × 16 B = 800 MB in one partition, too big to repair easily, so followers adds a bucket of 1 million followers each: 50 partitions for the biggest account, 1 for everyone else.
Hot celebrity posts
An account with 50 million followers posts. Four things heat up at once.
- Fan-out. 50 million feed inserts at 200,000 a second take 250 seconds. Use the hybrid from the news feed design: above 100,000 followers, followers pull that author's posts at read time.
- The post record. If 20% of followers open it within an hour, that is 10 million ÷ 3,600 ≈ 2,800 reads a second of one key, more in the first minutes. The caption and image list never change, so cache the post and keep a copy for a few seconds in each API server's memory.
- The image. The CDN takes the reads; the origin shield stops the first burst of misses.
- Likes. If 5% of followers like it in 10 minutes, that is 2.5 million ÷ 600 ≈ 4,200 likes a second on one post.
Likes need the most care. The likes table is partitioned by the user who liked, so those 4,200 writes spread over every partition, and it answers "did I like this?", which every feed render asks. The count is separate: each like also goes to a stream partitioned by post id, and the aggregator that owns a stream partition sums likes per post for one second, then writes count += n once. 4,200 updates a second become 1.
The stream delivers at least once, so after a crash the aggregator may see events again. It stores the last stream offset it applied in the same row as the count, in the same write, and on a replay skips every event at or below it, so no like counts twice. This works only because one aggregator owns all of a post's events, which arrive in offset order. A nightly recount from likes repairs any other drift. The shown count lags about a second and is rounded ("12.4K"), as the requirements allow.
The feed: what photos change
Reuse the hybrid fan-out, cursors and caches from the news feed design. Three things differ: fan-out starts when the post is ready, not when it is created; feed entries hold (author, post) pairs to fit the partition key; and since 99% of the bytes are images, the app picks the size for its screen and prefetches the next page's images, which does more for perceived speed than a faster feed query.
Bottlenecks and failures
At 10x (500 million photos a day) the pain is bytes and money. Storage grows about 440 PB a year, so originals move to a cold storage class after 30 days, with erasure coding instead of full copies. Images average 280 GB/s, so CDN cost becomes the main budget line and a second CDN provider is worth having. The resize fleet grows to 1,200 to 3,500 cores. Metadata and feeds scale by adding partitions.
| When this dies | What happens | What you do |
|---|---|---|
| An API server | Requests in flight fail. | The load balancer drops it; post creation is idempotent, so the app retries. |
| The upload event queue | Uploads succeed but posts stay processing. | A job re-enqueues posts still processing after 10 minutes. No photo is lost. |
| Resize workers | Backlog grows; posts appear later. | Autoscale on queue depth; redelivery and fixed output keys make retries safe. |
| A metadata replica | Some partitions lose one of three copies. | Quorum reads and writes (2 of 3) continue; the replica is rebuilt. |
| The feed service | Cached feeds are gone. | Pull recent posts of followed authors: slower, but working. |
| The CDN | Images stop loading; the store cannot serve 28 GB/s. | Switch the image host name to a second CDN provider. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Puts images in object storage and metadata in a database, adds a CDN, and gets a basic feed working with hints. |
| Mid | Estimates storage and bandwidth, uploads with signed URLs, resizes with a queue of workers, and picks push or pull for the feed with a reason. |
| Senior | Separates media from metadata with numbers, holds posts until processed, derives partition keys from access patterns, handles the celebrity post on every axis, and plans cold storage for originals. |
Why partition posts by author? Doesn't that create hot spots?
Profiles read one author's posts newest first, so that's one sorted partition read, and feeds still fetch posts directly because each feed entry carries the author. The hot spot is reads of a famous author's posts. Those are immutable, so a cache plus a few seconds of in-process copies absorbs them. Likes are different: they're keyed by the user who liked, and the count is batched.
Follow-up questions
How would you support several photos in one post?
The post holds an ordered list of photo ids and becomes ready only when all are processed. Each photo is uploaded and resized on its own, so ten photos are ten parallel jobs.
A user deletes a photo. What has to happen?
Mark the post deleted so the API and feeds skip it, then delete the objects and purge the CDN in the background. On private accounts, short-lived signed URLs mean copied links die even before the purge.
The phone retries a post after a timeout. Do you get two posts?
No. Post creation carries an idempotency key, so the retry returns the first post, and a photo id can belong to only one post, which the 409 enforces.
How do you make images load fast on a slow network?
Keep a tiny blurred preview, a few hundred bytes, in the post metadata so the feed paints at once. Then load the size that fits the screen, in a modern format if the phone supports it.
How would you add content moderation?
Add a second consumer of the upload events that scores each image. Flagged posts stay out of feeds until reviewed, which is easy because posts already wait for a "ready" state.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".