Skip to main content
Cloud Interviews advanced Lesson 9 of 10

A Full Architecture Design Round

One question worked end to end — clarify, size, sketch, fail it, cost it, trade off — with the weak version of each step shown alongside the strong one.

The question: “Design the backend for a photo-sharing app. Users upload photos, follow other users, and see a feed of photos from people they follow.”

Forty-five minutes. Here is the whole round, with the weak version of each step shown next to the strong one.

Step 1 — clarify (5 min)

WEAK:   "Okay, so we'll need a load balancer, some app servers, S3 for the
         photos, and a database. Let me draw that."

STRONG: "Before I draw — how many users, and how photo-heavy is this?
         Is the feed chronological or ranked?
         And is there a hard latency target for feed load?"

The answers, as given:

100M registered, 20M daily active
each user posts ~1 photo/day, views ~100 feed items/day
photos average 3 MB, thumbnails needed
feed is chronological (ranked is a later phase)
feed p99 under 200 ms
some users have 10M+ followers

The last line is the whole problem. A celebrity with 10 million followers is what makes this question interesting rather than routine, and noticing it in the clarification phase is worth saying out loud:

“The 10M-follower case is going to drive the design — a naive fan-out on write means one post becomes ten million writes. I’ll come back to that.”

Step 2 — size it (5 min)

# sizing.py
DAU, POSTS_PER_DAY, VIEWS_PER_DAY = 20_000_000, 1, 100
PHOTO_MB, THUMB_MB = 3, 0.15
SEC_PER_DAY, PEAK = 86_400, 3

writes_day = DAU * POSTS_PER_DAY
reads_day  = DAU * VIEWS_PER_DAY / 20          # ~20 items per feed request

print(f"write rps  avg {writes_day/SEC_PER_DAY:>10,.0f}   peak {writes_day/SEC_PER_DAY*PEAK:>10,.0f}")
print(f"read  rps  avg {reads_day/SEC_PER_DAY:>10,.0f}   peak {reads_day/SEC_PER_DAY*PEAK:>10,.0f}")
print(f"read:write ratio {reads_day/writes_day:>8,.0f}:1\n")

daily_gb = writes_day * (PHOTO_MB + THUMB_MB) / 1024
print(f"new storage    {daily_gb:>10,.0f} GB/day   {daily_gb*365/1024:>8,.0f} TB/year")
print(f"upload bandwidth peak {writes_day/SEC_PER_DAY*PEAK*PHOTO_MB:>8,.0f} MB/s")
print(f"serving bandwidth peak {reads_day/SEC_PER_DAY*PEAK*20*THUMB_MB:>7,.0f} MB/s (thumbnails)")
$ python sizing.py
write rps  avg        231   peak        694
read  rps  avg     11,574   peak     34,722
read:write ratio       50:1

new storage         61,523 GB/day       21,930 TB/year
upload bandwidth peak    2,083 MB/s
serving bandwidth peak  20,833 MB/s (thumbnails)

Four things now decided by arithmetic rather than preference:

  • 50:1 read:write. This is a read-optimised system. Caching and denormalisation are justified; write-path cleverness mostly is not.
  • 22 PB a year. Object storage with lifecycle tiering, not block storage, and not a database.
  • 20 GB/s peak serving bandwidth, ~7 GB/s average. A CDN is not optional — from the storage lesson, 17 PB a month served straight from origin is roughly $1.5M in egress.
  • 694 writes/sec peak is small. A single well-provisioned database handles it. The write path is not the hard part; fan-out is.

Step 3 — sketch it (10 min)

                        ┌─────────┐
   mobile / web ───────▶│   CDN   │────▶ thumbnails, photos (signed URLs)
                        └────┬────┘
                             │ miss
                        ┌────▼────┐
                        │   API   │  ALB → containers, autoscaled
                        │ gateway │
                        └────┬────┘
          ┌──────────────────┼──────────────────┐
          ▼                  ▼                  ▼
   ┌────────────┐    ┌──────────────┐   ┌──────────────┐
   │  UPLOAD    │    │    FEED      │   │   SOCIAL     │
   │  service   │    │   service    │   │   service    │
   └─────┬──────┘    └──────┬───────┘   └──────┬───────┘
         │                  │                  │
    presigned PUT      ┌────▼────┐      ┌──────▼──────┐
         │             │  Redis  │      │  Postgres   │
         ▼             │ feed    │      │  users,     │
   ┌──────────┐        │ cache   │      │  follows    │
   │  Object  │        └────▲────┘      └──────┬──────┘
   │  storage │             │                  │
   └────┬─────┘        ┌────┴──────────────────▼──────┐
        │ event        │       FAN-OUT WORKERS        │
        └─────────────▶│  queue → write to followers  │
                       └──────────────────────────────┘

Five decisions in that picture, each worth one sentence:

  • Presigned upload URLs. The client PUTs directly to object storage; the 3 MB never touches the application tier. That removes 2 GB/s of ingress from the fleet and is the single highest- leverage decision on the upload path.
  • Object-storage event triggers thumbnailing, asynchronously. The user’s upload completes before the thumbnail exists, and the client shows a placeholder.
  • Feed is precomputed into a cache, because 50:1 reads.
  • Follows in a relational store. The graph is small per user, needs consistency, and the query patterns are simple.
  • Fan-out is a queue, not a synchronous write.

Step 4 — the hard part: fan-out

# fanout.py
FOLLOWERS = {"typical": 200, "popular": 50_000, "celebrity": 10_000_000}
WRITE_MS, READ_MS_PER_SOURCE = 0.5, 0.4

print(f"{'user type':<12} {'followers':>12} {'fan-out on WRITE':>20} {'fan-out on READ':>18}")
for kind, n in FOLLOWERS.items():
    write_cost = n * WRITE_MS / 1000
    read_cost  = 200 * READ_MS_PER_SOURCE                 # reader follows ~200 people
    print(f"{kind:<12} {n:>12,} {write_cost:>17,.1f} s {read_cost:>15,.0f} ms")
$ python fanout.py
user type       followers     fan-out on WRITE    fan-out on READ
typical               200               0.1 s               80 ms
popular            50,000              25.0 s               80 ms
celebrity      10,000,000           5,000.0 s               80 ms

Fan-out on write for a celebrity is 83 minutes of writes for one post. Fan-out on read is constant regardless of the poster, and costs the reader 80 ms every time.

Neither alone works, and saying that is the answer:

FAN-OUT ON WRITE          push the post into every follower's feed at post time
  read:  O(1) — the feed is already built. Fast.
  write: O(followers) — unusable for celebrities.
  waste: builds feeds for inactive users who never look.

FAN-OUT ON READ           gather from everyone the reader follows at read time
  read:  O(following) — 200 queries, or one query over 200 sources.
  write: O(1). Trivial.
  problem: pays the cost on the hot path, 50:1 in the wrong direction.

HYBRID                    push for normal users, pull for celebrities
  a reader's feed = their precomputed timeline
                    MERGED WITH a live pull from the few celebrities
                    they follow, which is cacheable globally

“I’d fan out on write for anyone under, say, ten thousand followers — that is 5 seconds of writes, fine asynchronously — and mark accounts above that threshold as pull. A reader’s feed then merges their precomputed timeline with a live read of the celebrities they follow. The celebrity’s own timeline is one cache entry serving millions of readers, so the pull is cheap. The threshold is a tunable, and I’d want to measure the follower distribution before picking it.”

Two refinements to volunteer if there is time:

  • Only fan out to active users. Someone who has not opened the app in ninety days does not need a precomputed feed; build it lazily when they return. On these numbers that is most of the 100M registered against 20M daily active — an 80% reduction in fan-out writes.
  • Cap the precomputed feed. Store the most recent 500 entries per user, not all history. Older pages fall back to a query. That bounds the cache at 20M × 500 × ~50 bytes ≈ 500 GB.

Step 5 — fail it (5 min)

COMPONENT           FAILS                     EFFECT                    MITIGATION
CDN                 edge outage               latency up, origin load   multi-CDN or
                                              spikes                    origin autoscale
object storage      regional outage           no photos at all          cross-region
                                              (uploads and views)       replication
feed cache (Redis)  node loss                 feeds rebuild from DB;    replicas; and
                                              read amplification        rebuild is the
                                                                        risk, not the loss
fan-out workers     backlog                   feeds go stale;           it degrades, not
                                              posts still succeed       breaks — good
Postgres primary    failover                  follows/unfollows fail    multi-AZ, 30-120s
                                              ~1 min; feeds keep
                                              serving from cache
whole region        outage                    total                     depends on the
                                                                        stated RTO

The row worth pointing at is the fan-out workers: a backlog makes feeds stale but does not stop posting or reading. That is a designed-in degradation, and naming it as deliberate is different from listing it as a risk.

The dangerous row is the feed cache. From the availability lesson, losing a cache sized to absorb a 50:1 read ratio means the database suddenly receives 50x its normal read load:

“If the feed cache is lost, the database gets fifty times its normal reads. I’d want request coalescing so a thousand simultaneous misses for the same feed become one query, and I’d size the database to survive a partial cache loss rather than assuming the cache is always there.”

Step 6 — cost it (5 min)

# cost.py
TB = 1024
SEC_PER_MONTH = 86_400 * 30

# average, not peak — peak was 20,833 MB/s, average is a third of that
serving_gb_month = 6_944 * SEC_PER_MONTH / 1024
stored_gb        = 21_930 * TB / 2                    # ~6 months accumulated

CDN_GB, ORIGIN_GB, CDN_HIT = 0.020, 0.085, 0.95

storage  = stored_gb * 0.023
cdn      = serving_gb_month * CDN_GB
origin   = serving_gb_month * (1 - CDN_HIT) * ORIGIN_GB
compute  = 60 * 60.74 + 40 * 121.48                   # api tier + fan-out workers
database = 2 * 1_200                                  # multi-AZ primary + replica
cache    = 6 * 780                                    # redis cluster

lines = [("storage", storage), ("CDN", cdn), ("origin egress", origin),
         ("compute", compute), ("database", database), ("cache", cache)]
total = sum(v for _, v in lines)
for k, v in lines:
    print(f"{k:<15} ${v:>12,.0f}  {100*v/total:>5.1f}%")
print(f"{'TOTAL':<15} ${total:>12,.0f}")
$ python cost.py
storage         $    258,248   36.9%
CDN             $    351,540   50.2%
origin egress   $     74,702   10.7%
compute         $      8,504    1.2%
database        $      2,400    0.3%
cache           $      4,680    0.7%
TOTAL           $    700,074

Data movement is 61% and storage is 37%. Compute, database and cache together are 2.2%.

That inversion is the finding, and it redirects the whole optimisation conversation:

serve WebP/AVIF instead of JPEG       30-50% smaller → cuts BOTH the CDN line
                                      and future storage growth. The largest lever.
size thumbnails to the device         do not send 1080p into a 320px slot
raise the CDN hit rate to 99%         origin egress $74.7k → $14.9k
negotiate committed CDN pricing       at 17 PB/month, list price is not the price
tier photos older than 90 days        storage is 37% — genuinely worth doing here
rightsize the instances               1.2% of the bill. Not worth the meeting.

Note the last two rows against each other. Storage tiering is often the intuitive saving and is often negligible — here it is the second-biggest line and is worth real effort. Instance rightsizing is the reflex answer in most cost conversations and would save at most a few thousand dollars against a $700k bill.

Saying which optimisation not to bother with, and why, is as valuable as naming the ones that matter. It demonstrates that the percentages drove the answer rather than habit.

Step 7 — trade off (5 min)

CHOSE                          GAVE UP                        WOULD REVISIT IF
hybrid fan-out                 simplicity; two code paths     follower distribution
                                                              were flatter
precomputed feed in a cache    consistency — a new post takes seconds
                               to appear                      strict real-time required
chronological feed             engagement (ranked does        product asks for ranking;
                               better)                        that changes the read path
presigned direct upload        no server-side validation      abuse or malware scanning
                               before the object lands        becomes a requirement
async thumbnailing             a brief window with no thumb   users complain about
                                                              the placeholder
relational store for follows   harder to scale writes         follow-graph writes grow
                               past one primary                past ~10k/sec

A design with no “gave up” column is not finished. Volunteering this table unprompted is the clearest possible signal that you have designed systems rather than read about them.

What the interviewer wrote down

✓ noticed the celebrity case during clarification, before designing
✓ arithmetic out loud; 50:1 ratio drove the design rather than being decorative
✓ presigned upload — kept 2 GB/s off the application tier
✓ named fan-out on write vs read, then hybridised with a stated threshold
✓ active-user optimisation and feed cap volunteered, not prompted
✓ cache loss identified as the dangerous failure, with request coalescing
✓ costed it; identified compute rightsizing as not worth doing, and image
  format as the lever that cuts two lines at once
✓ trade-off table with what would change each decision

△ did not cover: auth, abuse/moderation, GDPR deletion, search, notifications
  — said so explicitly and named which to do next

That last line is scope management, not failure. Saying “I have not covered moderation or deletion-on-request; deletion is the one I would do next because it changes the storage design” reads completely differently from silence.

The template, portable to any question

1. CLARIFY   scale, availability target, hardest constraint.
             Two or three questions. Say which answer changes the design.

2. SIZE      requests/sec, GB/day, read:write ratio. Out loud.
             The ratio decides more than any service choice.

3. SKETCH    boxes and labelled arrows. Where state lives.
             One sentence justifying each decision.

4. HARD PART every question has one. Find it and go deep.
             Name the two naive approaches and why you hybridised.

5. FAIL IT   per component: what dies, who notices, what recovers.
             Name the deliberate degradations as deliberate.

6. COST IT   rough monthly figure, and which line dominates.
             Name an optimisation that is NOT worth doing, and why.

7. TRADE OFF what you gave up, and what would make you revisit.
             Then say what you did not cover.

Practice

1. Start drawing before asking about follower distribution.
You design fan-out on write, and the celebrity case invalidates it at minute 30.

The 10M-follower line is the whole problem. Noticing it during clarification, not after, is what the step is for.

2. Compute the read:write ratio before choosing services.
50:1 — a read-optimised system. Caching and denormalisation are justified.

The ratio decides more than any individual service choice, and it takes thirty seconds.

3. Route 3 MB uploads through the application tier.
2 GB/s of ingress the fleet has to absorb, for no benefit.

Presigned URLs let the client PUT straight to object storage. The highest-leverage decision on the upload path.

4. Propose rightsizing the instances to save money.
Compute is 1.2% of a $700k bill. Data movement is 61% and storage is 37%.

The reflex answer saves a few thousand dollars. Read the percentages first, and say which optimisation is not worth the meeting.

Next: infrastructure as code — the hands-on round, and what a Terraform plan reveals.

Frequently Asked Questions

How long should I spend clarifying before drawing?
Around five minutes of a forty-five minute round. Two or three questions that change the design — scale, availability target, and the hardest constraint — then start sketching. Longer than that and you look like you are avoiding the design; shorter and you risk building the wrong thing.
Should I draw the diagram or talk it through?
Draw it. Boxes and arrows give the interviewer something to point at, and a shared artefact keeps you both anchored to the same design. Label the arrows with the protocol and the data, because unlabelled arrows hide the decisions.
What if I do not know a service the interviewer mentions?
Say so and describe what you would need it to do. "I have not used that specific service — what I need here is something that gives me an ordered, replayable log with consumer groups. If it does that, the design is unchanged." That is a better answer than bluffing, and it demonstrates you are designing rather than name-dropping.
Is it a problem if I do not finish?
No. These questions are deliberately larger than the time available, and the interviewer is sampling how you think. Finishing a shallow design is worse than getting three quarters through a considered one — but say what you would cover next so it reads as scope management rather than running out.