Engineering Atlas › System Design Interviews

Design a photo-sharing app

22 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: Reels and long video (see the Design YouTube and Design TikTok lessons for transcoding and recommendation), direct messages, Explore and search, ads, and content moderation, which I would mention only as an asynchronous hook on the upload pipeline.

Back-of-envelope

Planning numbers, chosen to be in the right order of magnitude for a two-billion-account product.

Users: 500 million daily actives. Each opens the feed about 20 times a day, so 10 billion feed loads per day, which is about 116,000 per second on average and roughly 350,000 per second at a 3x evening peak.

Posts: 100 million feed posts a day is about 1,160 per second on average, about 3,500 at peak. Stories: 500 million a day, about 5,800 per second, peaking near 17,000.

Fan-out writes: 100 million posts times an average of 200 pushed inboxes is 20 billion inbox inserts a day, about 230,000 per second. That number, not post creation, sizes the feed store.

Photo storage: the client downscales to 1080 pixels and compresses, so an upload is about 1 MB; derived sizes (150, 320 and 640 pixels) add about 0.3 MB. 1.3 MB times 100 million is 130 TB per day, about 240 PB over five years, roughly 330 PB with erasure coding at 1.4x. Stories add about 500 TB a day but leave the serving tier after 24 hours; only archived ones stay, in a colder tier.

Metadata: about 500 bytes per post row is 50 GB a day, about 90 TB over five years, comfortable on a sharded relational fleet.

Bandwidth: a feed load renders about 10 images of about 100 KB, so 116,000 loads per second is about 116 GB/s, close to 1 Tbit/s from the CDN on average and 3 Tbit/s at peak, before Stories. The API tier serves only JSON, about 2.3 GB/s.

API

POST /v1/media/uploads                         -> { upload_id, put_url, expires_at }
PUT  <put_url>  (bytes go directly to object storage, resumable)
POST /v1/posts            Idempotency-Key: <uuid>
  body { upload_ids: [..], caption, location_id? }
  -> 202 { post_id, state: "PROCESSING" }        (state becomes "LIVE")

GET  /v1/feed?cursor=<opaque>&limit=12
  -> { items: [ { post_id, author, caption, media: [ { variants: [ {w, url} ] } ],
                  like_count, viewer_has_liked } ], next_cursor }

POST   /v1/posts/{id}/likes                     -> 204   (idempotent per user)
DELETE /v1/posts/{id}/likes                     -> 204

POST /v1/stories          body { upload_id }    -> 202 { story_id, expires_at }
GET  /v1/stories/tray                           -> { authors: [ { user_id, has_unseen, first_story_id } ] }
POST /v1/stories/seen     body { story_ids: [..] }  -> 204   (batched by the client)

Data model

users, follows (relational, sharded by user_id): follows(follower_id, followee_id, created_at) is stored twice, on the follower's shard (who do I follow) and on the followee's shard (who follows me), because both directions are hot queries.

posts (relational, sharded by author user_id): post_id (64-bit, time plus shard), author_id, caption, media (a small JSON array of media IDs and dimensions), state, created_at. Placing posts on the author's shard makes the profile grid ("this user's latest 12 posts") a single-shard index scan on (author_id, post_id desc).

media (object storage, key media/{media_id}/{variant}): immutable objects, never overwritten. Because the key never changes meaning, CDN URLs can carry a one-year cache lifetime.

feed_inbox (wide-column store such as Cassandra, partition key user_id, clustering post_id desc): only (post_id, author_id), about 16 bytes per entry, capped at a few hundred entries with a TTL. Time-sortable IDs make the clustering order chronological for free.

stories (wide-column, partition key author_id, clustering story_id, row TTL of 24 hours plus a margin): the store expires rows itself, so no delete job can fall behind.

story_seen (key-value, key (viewer_id, author_id), TTL 24 hours): the newest story ID seen from that author plus a small set for out-of-order views. A high-water mark instead of one row per (viewer, story) cuts writes and space by an order of magnitude.

like_counts (sharded counter rows, key (post_id, shard_n)) plus likes (post_id, user_id) for the exact "did I like it" check and for dedup.

High-level design

flowchart LR
  APP["Mobile app"] --> GW["API gateway"]
  APP -->|"PUT bytes"| OS[("Object storage")]
  GW --> UP["Upload service"]
  UP --> OS
  GW --> PS["Post service"]
  PS --> PDB[("Posts and graph shards")]
  PS --> Q["Event log"]
  OS --> MP["Media processor"]
  MP --> OS
  MP --> Q
  Q --> FO["Fan-out workers"]
  FO --> INB[("Feed inboxes")]
  GW --> FS["Feed service"]
  FS --> INB
  FS --> PDB
  FS --> RK["Ranker"]
  FS --> MC["Object cache"]
  GW --> SS["Stories service"]
  SS --> SDB[("Stories and seen state")]
  CDN["CDN"] --> OS
  APP --> CDN

Write path: the app asks the upload service for a presigned URL and uploads the downscaled photo directly to object storage, resumably. Then it calls POST /posts with the upload IDs and an idempotency key. The post service allocates a post ID on the author's shard, inserts the row in state PROCESSING, and appends a post_created event to the log. The media processor (triggered by the object-store notification) validates the file, strips EXIF location if the user opted out, produces variants, and writes them under immutable keys. When every media item is ready, the post flips to LIVE and a post_live event goes on the log. Fan-out workers read the author's follower list in pages of a few thousand and append the post ID to each active follower's inbox, unless the author is above the celebrity threshold, in which case nothing is pushed.

Read path: the feed service reads the viewer's inbox (one partition read), adds the latest posts from the handful of followed celebrity accounts, drops anything already served in this session, and sends a few hundred candidates to the ranker. It hydrates the top page from the object cache with one multiget (posts, authors, counts, viewer-has-liked), builds CDN URLs for the variant matching the device width, and returns a page plus an opaque cursor. The app then fetches images from the nearest CDN edge.

Deep dives

Media: upload, processing and serving

Upload: proxying bytes through the API servers ties up a thread for the whole upload over a slow mobile link and makes API capacity depend on mobile network quality. Presigned, resumable uploads (see the Handling Large Files lesson) remove that, and the client can retry a chunk without restarting. This is the Valet Key pattern: the API hands out a short-lived, narrowly scoped token for one object and never touches the bytes (Azure Architecture Center).

Processing: synchronous resize in the request, or asynchronous workers. Asynchronous wins, with one subtlety: the author must see their own post immediately. The app shows the local image optimistically while the server keeps the post PROCESSING until variants exist, so followers never see a broken image. Variant generation costs tens of milliseconds of CPU per photo, so about 4,000 per second at peak needs a few hundred cores in a pool separate from anything user-facing. Video is where this budget explodes: Instagram found that basic video encodings were using more than 80% of its encoding compute, and fixed it by repackaging existing encodings instead of transcoding again (Engineering at Meta).

Serving: immutable keys mean the CDN never needs invalidation; a deleted post stops being referenced and its objects are garbage-collected later. The client picks a variant by screen width and pixel density, which is where most egress is saved. Variant choice belongs in the URL, never in a Vary header, so every variant is exactly one cache entry.

IDs that sort, route and paginate

A feed merges inboxes and outboxes by recency, and pages through them with a cursor. If IDs are random UUIDs, you need a separate timestamp column, a secondary index on it, and a lookup to find the owning shard. Instagram described an ID layout in 2012 that solves all three: 41 bits of milliseconds since a custom epoch, 13 bits of logical shard ID, and 10 bits of a per-shard sequence, generated inside the database by a stored function. Each logical shard was a Postgres schema, and thousands of them were mapped onto a few physical servers (Mike Krieger, SFPUG 2012). Sorting by ID is sorting by time, the shard is (id >> 10) & 8191, and a cursor is just "the last ID I showed you".

The alternatives are a central ticket server (a bottleneck and a single point of failure) or Snowflake-style workers (see Generating Unique IDs), which need worker-ID assignment. Generating in the shard's own database ties allocation to the insert transaction, with no extra hop. Thousands of logical shards map onto far fewer physical Postgres servers, so rebalancing moves a logical shard as a unit and never rewrites IDs.

Stories: ephemeral content and seen state

Stories look like a small feed, but the access pattern is inverted. The product, launched in 2016, is a tray of accounts at the top of the feed whose posts disappear after 24 hours (Instagram). Each viewer opens the tray many times a day, and each tap through a story writes seen state. With 500 million daily actives viewing maybe 50 stories each, that is 25 billion seen events a day, about 290,000 per second, five times the number of feed-post fan-out writes.

Option one: fan out story IDs into a per-viewer tray, like the feed. Option two: build the tray at read time from "who do I follow" and "who has an active story". The second fits better: most followed accounts have no active story at any moment, and the per-author "latest story ID" can sit in a cache keyed by author. The tray read fetches the viewer's followee list (cached), multigets each followee's latest story ID, compares with the viewer's seen high-water mark per author, and orders: unseen authors first (by a closeness score in production, recency in the practice task), then fully seen ones. Playback within an author starts at the oldest unseen story.

Seen state is a high-water mark per (viewer, author) because stories play in order; skipping ahead creates a rare gap, stored as a small exception set. The app batches seen events every few seconds, so 290,000 events per second become perhaps 30,000 writes. Losing a few seconds of seen state on a crash only makes a story show as unseen again, an acceptable failure. Expiry is enforced twice: row TTLs in the store and a now < created + 24h check at read time, so lagging compaction never shows a dead story.

Variant: a short-video app with a recommendation feed

The Instagram feed is built from a graph: you see what the people you chose to follow posted, so the hard part is fan-out. A short-video app such as TikTok flips the premise. The main feed is "For You", a stream the user did not choose, so the hard part becomes ranking, and the follow graph is only one source among several.

The numbers change shape too. With about 1B daily users watching an hour of 30-second clips, views run near 1.4M a second on average, and each view emits several events (impression, progress, finish or skip, like), so the event stream is around 7M events a second. Each feed call returns about ten videos but ranks hundreds to thousands of candidates, so scoring compute, not fan-out, is the bill. Egress is the other bill: tens of millions of concurrent streams at roughly 1.5 Mbit/s is on the order of 60 Tbit/s, with 10 to 20% wasted on unwatched prefetches.

The mechanisms to add:

Down the stack

Layer 1The contract

A POST /posts that returns 202 guarantees the media bytes are durably stored and the post row exists; retries with the same idempotency key return the same post_id. The post becomes visible to followers within seconds for ordinary authors, and is never shown in PROCESSING state to anyone except the author. A feed cursor guarantees no duplicates across pages within one session; it does not guarantee that a post published during scrolling appears in a later page. Like counts are eventually consistent within seconds; viewer_has_liked is read-your-writes for the viewer. A story is never served at or after created + 24h.

Layer 2The mechanism

The inbox is a Cassandra partition per user with post_id as a descending clustering key. An append is a write to the commit log plus an insert into the memtable; a read of the newest 300 entries is a slice of one partition, usually served from one or two SSTables thanks to time-ordered compaction. Capping uses a TTL plus a periodic trim rather than explicit deletes. Expired TTL cells still become tombstones, so the table uses TimeWindowCompactionStrategy, which groups SSTables by write time and drops a whole SSTable once everything in it has expired, instead of compacting tombstones away one by one (Cassandra docs). Hydration goes through a memcache layer with leases, the Facebook technique for avoiding thundering herds when a hot key is invalidated: a server hands out at most one lease token per key every 10 seconds, and other clients wait briefly and retry (Scaling Memcache at Facebook). The stored ID function on each Postgres shard is a few lines of PL/pgSQL: read nextval from the shard's sequence, take mod 1024, read the clock in milliseconds, shift and OR. Like counters are N rows per hot post; an increment picks a random row, and a read sums N rows, cached for a second.

Layer 3The machine

A feed page read touches one inbox partition of 300 times 16 bytes, about 5 KB, one or two 4 KB pages, about 100 microseconds on NVMe if not in the page cache. The memcache multiget for 12 posts plus authors is one round trip of perhaps 15 KB, around 0.5 ms within a zone. The image dominates. A 100 KB image is about 70 TCP segments of 1,460 bytes; on a fresh connection with an initial congestion window of 10 segments (RFC 6928), slow start sends 10, 20, then 40 segments, so four round trips plus the TLS handshake, around 300 ms at 60 ms mobile RTT. Hence warm CDN connections, HTTP/2 or QUIC multiplexing, and prefetching the next screen's images. On the phone, decoding a 1080-pixel image allocates about 4.7 MB of RGBA bitmap (1080 by 1080 by 4 bytes), another reason to fetch the variant sized for the screen.

Why it's built this way

  1. Images are a thousand times larger than metadata, so they get a separate path (object store plus CDN) and never touch the API servers.
  2. The CDN can only cache well if objects never change, so media keys are immutable and variant choice lives in the URL.
  3. Feeds read far more than they are written, so the work is moved to write time with fan-out, but power-law follower counts force the hybrid with pull for celebrities.
  4. Fan-out and pagination both need ordering by time without a global coordinator, so the ID itself carries time and shard.
  5. Stories expire and are viewed many times, so the system stores little per viewer (a high-water mark) and lets TTLs delete content instead of jobs.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Presigned direct uploadAPI tier never handles bytes; resumableTwo-step protocol, orphaned uploads to cleanAny media-heavy product
Proxy upload through APISimple, one requestTies up servers on slow links, costly egressTiny files, internal tools
Time-plus-shard IDs from the databaseSortable, routable, no extra serviceClock skew can reorder within millisecondsSharded relational store
Snowflake ID serviceIndependent of storageExtra hop, worker-ID managementMany heterogeneous stores
Tray built at read timeLittle write amplification; easy expiryMore reads per tray openShort-lived content, few active authors
Tray fanned out on writeSingle read per trayWrites for content that expires unseenLong-lived content, low follow counts
Sharded like countersAbsorbs hot postsReads must sum shards; counts approximateCelebrity posts exist

Leadership lens

Check yourself

  1. Why upload photo bytes directly to object storage through a presigned URL instead of proxying them through the API servers?

    Show answer

    Proxying ties up a server thread for the whole upload over a slow mobile link and makes API capacity depend on mobile network quality. Presigned, resumable uploads keep the API tier handling only IDs and pointers, at the cost of a two-step protocol and orphaned uploads to clean up.

  2. The media processor goes down. What do authors and followers see?

    Show answer

    Posts stay in PROCESSING, so followers never see a broken image, while the author sees their local copy optimistically. Operators alert on the age of the oldest PROCESSING post.

  3. Why is the Stories tray built at read time rather than fanned out on write like the feed?

    Show answer

    Most followed accounts have no active story at any moment, and fanning out would write tray entries for content that often expires unseen. Building it at read time from the cached followee list and per-author latest story IDs adds reads per tray open but keeps write amplification low and expiry easy.

  4. How many seen events do Stories generate, and how does the data model keep the write load manageable?

    Show answer

    500 million daily actives viewing about 50 stories each is 25 billion seen events a day, about 290,000 per second. Storing a high-water mark per (viewer, author) instead of a row per story, and batching seen events on the client, cuts that to perhaps 30,000 writes per second.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →