Medium

Design a Photo Sharing App (Instagram)

Design a photo sharing service where users upload images, follow each other, and scroll a reverse-chronological feed with likes and comments. The core problems are a reliable upload and resizing pipeline, feed generation at fan-out scale, CDN-fronted delivery, and counters that survive celebrity-level write rates.

1Requirements

Functional

  • Users can upload photos with a caption; uploads are processed into multiple sizes.
  • Users can follow other users and see followed users' photos in a reverse-chronological feed.
  • Users can like and comment on photos, with visible like and comment counts.
  • Feed supports infinite scroll with cursor pagination.
  • Users have profiles showing their own photo grid.

Non-functional

  • Feed load p99 under 500 ms; image bytes served from CDN edge in under 100 ms.
  • Read-heavy: roughly 100 feed reads per photo upload, so optimize the read path.
  • Uploads must be durable the moment the client gets a 200; resizing can lag by seconds.
  • Eventual consistency is fine for feeds and counters; a like may take seconds to appear globally.
  • Scale target: 10M DAU, 2M photos uploaded per day.
  • No data loss for original images; store them redundantly.

2Back-of-envelope estimation

Upload rate23 photos/sec avg, ~120/sec peak
Blob storage growth~6 TB/day
Feed read QPS~23k req/sec peak
Fan-out write volume~4.6k feed inserts/sec
Metadata size~1 KB/photo row

3API design

POST /v1/photos/upload-url

Returns a presigned S3 PUT URL and a photo_id. The client uploads bytes directly to object storage, keeping large payloads off the API servers.

POST /v1/photos/{photoId}/complete

Client confirms the upload finished; server writes the metadata row with status uploaded and enqueues the resize job.

GET /v1/feed?cursor={photoId}&limit=20

Returns the next feed page. Cursor is the last seen photo id (time-sortable Snowflake id), avoiding OFFSET pagination.

POST /v1/photos/{photoId}/like

Idempotent like; inserts into the likes table and increments the counter asynchronously. DELETE on the same path unlikes.

POST /v1/photos/{photoId}/comments

Adds a comment; returns the comment with its id for optimistic UI insertion.

4High-level design

Uploads bypass the API tier: the client asks for a presigned URL, PUTs the original image straight to object storage (S3), then calls a completion endpoint. This makes uploads durable and cheap before any processing happens. The completion call writes a metadata row and drops a resize job onto a message queue, decoupling the user-facing latency from image processing.

A pool of resize workers consumes the queue, downloads the original, and produces a ladder of variants (thumbnail 150px, feed 1080px, full size) in modern formats like WebP. Workers write variants back to S3 under deterministic keys and flip the photo's status to ready, at which point it becomes eligible for feeds. The queue gives you retries, backpressure, and horizontal scaling of workers for free.

Feed generation uses a hybrid fan-out. For normal users (under ~10k followers), a post triggers fan-out-on-write: a worker inserts the photo id into a Redis list per follower, so reading a feed is a single list read plus a metadata multi-get. For celebrity accounts, fan-out-on-write would mean millions of inserts per post, so their posts are pulled at read time and merged into the precomputed list. This is the exact tradeoff interviewers want articulated.

All image bytes are served through a CDN with the S3 bucket as origin. URLs are immutable and content-addressed (photo id + variant), so cache headers can be set to a year and the CDN hit ratio approaches 99%, which is what actually makes image delivery fast and cheap. The API tier only ever serves JSON.

Likes and comments write to Postgres for truth (a likes table with a unique user-photo constraint gives idempotency) while a Redis counter serves the hot read path. Counter increments flow through the queue so a viral post's like storm becomes sequential queue consumption instead of row-lock contention on one photo row.

5Data model

photos

photo_id (snowflake PK), user_id, caption, status, s3_key_original, variants_json, like_count, comment_count, created_at

Counters here are periodically reconciled from the truth tables; the snowflake PK doubles as the feed cursor.

follows

follower_id, followee_id, created_at

PK (follower_id, followee_id); index on followee_id to enumerate followers during fan-out.

likes

photo_id, user_id, created_at

PK (photo_id, user_id) makes likes idempotent; count(*) here is the source of truth for reconciliation.

comments

comment_id (snowflake PK), photo_id, user_id, body, created_at

Indexed on (photo_id, comment_id) for cursor-paginated comment threads.

6Deep dives

The upload and resizing pipeline

The order of operations is what makes uploads reliable: durable bytes first (direct-to-S3 with a presigned URL), metadata second, processing third. If the resize worker crashes, the original is safe and the job retries; if the client dies mid-upload, no orphan metadata exists, and a scheduled sweep can delete unconfirmed S3 objects after 24 hours.

Resizing is embarrassingly parallel and belongs behind a queue. Each job produces every variant in one pass (decode once, encode many) because decoding the original dominates the cost. Workers should be idempotent: writing variants to deterministic keys means a retried job simply overwrites identical bytes. A poison-pill photo (corrupt JPEG that crashes the decoder) must go to a dead-letter queue after a few attempts rather than blocking the pipeline.

A useful refinement is client-side resizing: the mobile app uploads a pre-shrunk 1080px version alongside the original request path, so the feed variant can be available near-instantly while the full ladder is processed in the background.

Fan-out on write vs fan-out on read

Fan-out-on-write precomputes each user's feed: when someone posts, insert the photo id into every follower's feed list (Redis LPUSH plus LTRIM to cap length at a few hundred ids). Reads become O(1): one list read, one multi-get for metadata. The cost is write amplification proportional to follower count and wasted work for dormant followers.

Fan-out-on-read computes the feed at request time: fetch the recent photo ids of everyone you follow and merge-sort by id. Reads get expensive (hundreds of queries or a scatter-gather) but writes are O(1). Pure read-time fan-out cannot hit a 500 ms p99 at 23k req/sec without heavy caching.

The production answer is hybrid: push for the 99.9% of accounts with modest followings, pull for the few thousand celebrity accounts. At read time, merge the precomputed list with fresh posts from followed celebrities. Also skip fan-out for followers inactive for 30+ days and rebuild their feed lazily on next login, which cuts fan-out volume dramatically since most followers of large accounts are dormant.

Counters that survive viral posts

A naive UPDATE photos SET like_count = like_count + 1 serializes on the row lock: a post receiving 10k likes/sec becomes a single-row contention point and p99 explodes. The truth should live in the likes table (idempotent inserts keyed by user and photo), with the displayed count maintained separately.

The standard pattern is buffered increments: likes enqueue an event, a consumer aggregates increments in memory for 100 ms or so, then applies one UPDATE of +N per photo per window, collapsing 10k row updates into 10. Meanwhile Redis INCR serves the displayed count with single-digit microsecond writes. A nightly reconciliation job recomputes counts from the likes table and repairs any drift from lost increments.

Exact counts stop mattering above roughly 10k; nobody notices 1,340,551 vs 1,340,570. That observation licenses aggressive batching and short-TTL caching of counts on hot posts, which is where all the load is anyway.

CDN strategy for image delivery

Roughly 95% of Instagram's egress is image bytes, so the CDN is the real delivery system and everything else is metadata plumbing. The key enabler is immutability: a photo variant never changes after creation, so URLs like /photos/{id}/feed_1080.webp can carry cache-control max-age of one year, letting edge caches keep hit ratios near 99% and pulling origin traffic down to almost nothing.

Variant selection belongs in the API response, not the edge: the feed JSON includes URLs for each size and the client picks based on viewport and network. Precomputing the ladder beats on-the-fly edge resizing for a feed product because the same few sizes are requested millions of times; on-demand transformation only wins for long-tail sizing needs.

Two practical notes: use signed URLs or signed cookies if private accounts must be enforced at the edge, and set S3 as a private origin reachable only by the CDN so nobody bypasses the cache and runs up your egress bill.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Next.js API routes + Postgres + Redis + S3-compatible storage (Cloudflare R2, free egress) + sharp for resizing, on one $15 VPS.

  1. 01Create tables: users, photos, follows, likes, comments; snowflake-style BIGINT ids so ids double as time cursors.
  2. 02Build POST /photos/upload-url returning a presigned R2 PUT URL plus a new photo_id with status pending.
  3. 03Build POST /photos/:id/complete that marks status uploaded and pushes the photo_id onto a Redis list acting as the resize queue.
  4. 04Write a worker loop (BRPOP on the queue) that downloads the original, generates 150px and 1080px WebP variants with sharp, uploads them, and sets status ready.
  5. 05Implement fan-out on write: on status ready, LPUSH the photo_id to feed:{followerId} for each follower and LTRIM to 500 entries.
  6. 06Build GET /feed: LRANGE the caller's feed list from the cursor, multi-get photo rows, return JSON with R2 public URLs.
  7. 07Add like/unlike with INSERT ... ON CONFLICT DO NOTHING plus Redis INCR/DECR of likes:{photoId}; render counts from Redis.
  8. 08Point a Cloudflare domain at the R2 bucket with max-age 31536000 and verify repeat image loads hit the edge cache.

Resize worker (queue consumer)

python
import io, json, time
import boto3, redis
from PIL import Image

r = redis.Redis()
s3 = boto3.client("s3", endpoint_url="https://<accountid>.r2.cloudflarestorage.com")
SIZES = {"thumb": 150, "feed": 1080}

def process(photo_id: str, key: str):
    raw = s3.get_object(Bucket="photos", Key=key)["Body"].read()
    img = Image.open(io.BytesIO(raw)).convert("RGB")  # decode once
    for name, width in SIZES.items():
        h = int(img.height * width / img.width)
        out = io.BytesIO()
        img.resize((width, h)).save(out, "WEBP", quality=82)
        variant_key = "photos/" + photo_id + "/" + name + ".webp"  # deterministic: retries overwrite
        s3.put_object(Bucket="photos", Key=variant_key, Body=out.getvalue(),
                      ContentType="image/webp", CacheControl="public, max-age=31536000, immutable")

while True:
    item = r.brpop("resize_queue", timeout=5)
    if not item:
        continue
    job = json.loads(item[1])
    try:
        process(job["photo_id"], job["s3_key"])
        r.lpush("fanout_queue", job["photo_id"])  # ready for feed fan-out
    except Exception:
        attempts = job.get("attempts", 0) + 1
        job["attempts"] = attempts
        target = "resize_dlq" if attempts >= 3 else "resize_queue"
        r.lpush(target, json.dumps(job))
        time.sleep(1)

Hybrid feed read (push list + celebrity pull)

typescript
async function getFeed(userId: string, cursor: bigint | null, limit = 20) {
  // 1. Precomputed ids from fan-out-on-write
  const pushedIds = (await redis.lrange("feed:" + userId, 0, 499))
    .map(BigInt)
    .filter((id) => cursor === null || id < cursor);

  // 2. Pull recent posts from followed celebrities (not fanned out)
  const celebs = await db.query(
    "SELECT f.followee_id FROM follows f JOIN users u ON u.id = f.followee_id " +
    "WHERE f.follower_id = $1 AND u.follower_count > 10000", [userId]);
  const celebIds: bigint[] = celebs.rows.length === 0 ? [] :
    (await db.query(
      "SELECT photo_id FROM photos WHERE user_id = ANY($1) AND status = 'ready' " +
      "AND ($2::bigint IS NULL OR photo_id < $2) ORDER BY photo_id DESC LIMIT $3",
      [celebs.rows.map((r: any) => r.followee_id), cursor, limit]
    )).rows.map((r: any) => BigInt(r.photo_id));

  // 3. Merge by id desc (snowflake ids sort by time) and hydrate
  const merged = [...new Set([...pushedIds, ...celebIds])]
    .sort((a, b) => (a > b ? -1 : 1))
    .slice(0, limit);
  const photos = await hydratePhotos(merged); // multi-get metadata + Redis counts
  return { photos, nextCursor: merged.length ? merged[merged.length - 1].toString() : null };
}

Idempotent like with buffered counter

sql
-- Truth: one row per (photo, user); re-likes are no-ops
INSERT INTO likes (photo_id, user_id, created_at)
VALUES ($1, $2, now())
ON CONFLICT (photo_id, user_id) DO NOTHING;

-- Batch applier: every 100 ms, collapse queued increments into one UPDATE per photo
UPDATE photos p
SET like_count = p.like_count + b.delta
FROM (VALUES ($1::bigint, $2::int)) AS b(photo_id, delta)
WHERE p.photo_id = b.photo_id;

-- Nightly reconciliation: repair drift from lost increments
UPDATE photos p
SET like_count = t.actual
FROM (SELECT photo_id, count(*) AS actual FROM likes GROUP BY photo_id) t
WHERE p.photo_id = t.photo_id AND p.like_count <> t.actual;

Bottlenecks & failure modes

  • Celebrity fan-out: one post to 10M followers cannot be pushed synchronously; requires the hybrid push-pull split.
  • Like-counter row contention on viral posts; solved with buffered increments and Redis-served counts.
  • Resize queue backlog during upload spikes delays photo visibility; autoscale workers on queue depth.
  • Feed metadata multi-get fans out to many DB shards; needs a cache layer in front of photo metadata.
  • CDN cache misses on brand-new posts hit origin hardest exactly when a post is going viral; origin shielding mitigates.

Key takeaways

  • Separate the byte path (client to S3 to CDN) from the metadata path (API to Postgres); they scale completely differently.
  • Hybrid fan-out is the canonical answer: push to normal users' feed lists, pull from celebrities at read time.
  • Make every pipeline step idempotent (presigned keys, deterministic variant names, unique like constraint) so retries are free.
  • Counters: truth in a table, speed in Redis, reconciliation to fix drift.
  • Immutable content-addressed URLs are what make a 99% CDN hit ratio possible.

Brush up on the underlying topics