Design a Social App End to End
Designing a social app end to end means building one complete design for posting photos, following accounts, reading a home feed, receiving notifications and showing presence. This capstone uses only tools from the earlier courses, from the interview prompt to the honest trade-offs.
The ask
The prompt arrives with almost nothing attached. Design a social app. No scale is given, no feature list is given and no starting design is given.
The app must let a person post a photo. It must let a person follow other accounts. It must show a home feed built from the accounts a person follows. It must send notifications and show which accounts are online.
This article builds the whole design in one pass. It follows the interview method from the earlier courses. Each missing detail is settled out loud before it shapes the design.
Clarify the requirements
The functional requirements name what the app does. Posting a photo is the main write. Following an account shapes the feed. Reading the home feed is the main read. Notifications and presence support those flows.
The non-functional requirements name how well the app must work. The feed should open quickly. A photo should still exist after a post reports success. The app should stay available when a single part fails.
The choices for this design are stated plainly. Freshness can lag a little for feeds and notifications. A feed entry that arrives a moment late is acceptable. A lost photo is not acceptable, so posting favours safety over speed.
Some features stay out of scope. Messages between accounts stay out. Video stays out. The design covers photos only, so its storage and feed choices stay honest.
Estimate the scale
The estimate starts from the stated size of this app. The app has 10,000,000 daily users. Each user posts 1 photo a day, and an average photo weighs 200 KB. The average account follows 150 accounts on this app. That follow count is this app's own assumption. The feed problem in this course used 200 for a different service. Each user opens the app 5 times a day. Notifications reach 30,000,000 a day.
The scale estimate, computed step by step
daily_users = 10000000
posts_each = 1
photo_kb = 200
follows_each = 150
opens_each = 5
notifications_day = 30000000
seconds_day = 86400
posts_day = daily_users * posts_each
posts_sec = round(posts_day / seconds_day)
feed_entries_day = posts_day * follows_each
reads_day = daily_users * opens_each
reads_sec = round(reads_day / seconds_day)
notifications_sec = round(notifications_day / seconds_day)
kb_day = posts_day * photo_kb
tb_day = kb_day // 1000000000
print(f"daily users: {daily_users:,}")
print(f"posts per user per day: {posts_each}")
print(f"posts per day: {posts_day:,}")
print(f"posts per second: {posts_sec}")
print(f"average follows: {follows_each}")
print(f"feed entries per day: {feed_entries_day:,}")
print(f"app opens per user per day: {opens_each}")
print(f"feed reads per day: {reads_day:,}")
print(f"feed reads per second: {reads_sec}")
print(f"notifications per day: {notifications_day:,}")
print(f"notifications per second: {notifications_sec}")
print(f"average photo size: {photo_kb} KB")
print(f"new photo storage per day: {tb_day} TB")Read the lines as one story. Posting is light, at 10,000,000 posts a day and 116 posts per second. Fan-out on write turns those posts into 1,500,000,000 feed entries a day. Reading is steady, at 50,000,000 feed reads a day and 579 per second. Notifications add 347 sends per second. New photos add 2 TB of storage a day.
The pattern shapes the whole design. Writes are few, but each write spreads widely through fan-out. Reads are many, but each read is cheap when the feed is ready in advance. Storage grows every day and never shrinks back.
High-level design
Every request enters through an API gateway. An API gateway is the single front door that checks each request before it reaches the app servers. Rate limiting at the gateway caps how many requests one account can make in a short window, so one faulty or abusive client cannot flood the app.
A load balancer passes each request to one of many app servers. App servers are stateless. Any app server can take any request, and more servers can be added as traffic grows.
Blob storage holds the photo bytes. Each photo is stored as one whole object under a fresh name. A CDN holds copies of popular photos near viewers, so most photo downloads never reach the home data center.
The database holds the small records. A record names an account, a follow link or a post and where its photo is stored. Records are sharded by user id. Sharding splits the data across machines by that key, so one account's records live together and lookups stay direct.
A feed store holds one list of post ids for each user. The list is that user's home feed, ready to read. The feed store is also sharded by user id. A cache holds the feeds and photo records read most often, in memory, where reads are quickest.
Queues and workers carry the slow work. A queue is a waiting line of jobs. A worker takes the next job and does it away from the request. Fan-out jobs and notification jobs both travel this way, so a busy background never blocks a post or a read.
A small presence store tracks who is online. It lives in a cache, because presence changes often and only the latest state matters.
Posting a photo
Posting starts on the client. The person picks a photo and taps post. The request passes the gateway, where rate limiting checks the account, and a load balancer hands it to an app server.
The app server stores the photo first. It writes the photo bytes into blob storage under a fresh name. Only stored bytes count, so the steps after this one build on a photo that exists.
The app server then writes the post record. The record names the author, the time and the stored location of the photo. The record lands in the author's shard, because the database is sharded by user id.
The post carries one id from the client. A retry with the same id creates one post and not two. That property is idempotency, and it makes a flaky connection safe to retry.
Fan-out waits for the background. The app server puts a fan-out job on the queue and reports success. A worker later copies the post id into each follower's feed and prepares the smaller copies of the photo. The post feels instant, and the spreading work happens out of sight.
The home feed
The home feed is built ahead of time. Fan-out on write means each new post is copied into the feed of every follower at post time. The reading side then has almost nothing to assemble.
A tiny model shows the copy step. One post lands in the feed list of each follower.
One post fanning out into every follower feed
followers = ["mia", "noa", "oli", "pam", "raj", "sue"]
feeds = {name: [] for name in followers}
post = "photo-42"
for name in followers:
feeds[name].append(post)
total = 0
for name in followers:
total += len(feeds[name])
print(name, "feed:", feeds[name])
print("followers:", len(followers))
print("feed entries written:", total)The demo shows the shape, not the scale. At this app's scale, the same copy step runs for the followers of every post. The estimate already counted that cost, at 1,500,000,000 feed entries a day.
Reading the feed reverses the work. The app server reads the user's ready-made list of post ids, cache first and feed store on a miss. It then loads the post records for one page of the list. Pagination sends one page at a time, and the photos on that page come from the CDN.
The cost moved to write time. Posting does more work so that reading does less. That trade fits this app, because feed reads arrive far more often than posts.
Notifications and presence
Notifications tell a person that something happened. A like, a comment and a new follower each create a notification event. The event names the account to notify, the account that acted and the post involved.
Events go on a queue first. Workers take events from the queue and send them, so a slow channel never blocks the person who acted. Each event carries a notification id. Dedupe means the worker checks that id before sending and skips a copy it has already sent, so a retried job never notifies twice.
A notification queue with dedupe
events = [
("n-1", "ana liked your photo"),
("n-2", "bo started following you"),
("n-1", "ana liked your photo"),
("n-3", "cy commented on your photo"),
("n-2", "bo started following you"),
]
queue = list(events)
seen = set()
sent = []
skipped = 0
while queue:
note_id, text = queue.pop(0)
if note_id in seen:
skipped += 1
else:
seen.add(note_id)
sent.append(note_id)
print("events received:", len(events))
print("duplicates skipped:", skipped)
print("notifications sent:", len(sent))
print("sent in order:", ", ".join(sent))The demo shows the guard at work. Repeated copies reach the queue, and only the first copy of each notification is sent. A send that keeps failing moves to a dead-letter queue, a holding place for failed jobs, where it can be inspected instead of lost.
Presence shows which followed accounts are online. The client sends a heartbeat, a small still-here message, every 30 seconds while the app is open. The presence store keeps the latest heartbeat for each account in a cache.
Missed heartbeats end the listing. When heartbeats stop, the account shows as offline. Presence accepts eventual consistency, because a status that lags a little harms no one.
One deep dive: the celebrity post
One account on this app is famous. Its follower count sits far beyond the average follow count of 150. A post from that account would copy into an enormous number of feeds at post time. The fan-out workers would spend a long burst on that single post, and normal posts would wait behind it.
The hybrid answer splits the work. Hybrid here means push for normal accounts and pull for celebrity accounts. A celebrity post is stored once, on the celebrity account's own list of posts. Nothing is copied into follower feeds at post time.
The pull happens at read time. When a follower opens the feed, the app server merges the ready-made feed with the latest posts from the celebrity accounts that person follows. The merge work is small, because it runs for one reader at a time.
A scaled-down model compares the two paths. The celebrity in the model has 1,000 followers, far fewer than a real one, and the shape still shows clearly.
Push for a normal post, pull for a celebrity post
def push(post, followers, feeds):
for name in followers:
feeds[name] = feeds.get(name, []) + [post]
return len(followers)
normal_followers = []
for i in range(150):
normal_followers.append("user-" + str(i))
celebrity_followers = []
for i in range(1000):
celebrity_followers.append("fan-" + str(i))
feeds = {}
pushed_normal = push("photo-1", normal_followers, feeds)
celebrity_outbox = ["photo-2"]
entries_after = sum(len(feed) for feed in feeds.values())
pushed_celebrity = entries_after - pushed_normal
reader_feed = feeds.get("fan-0", []) + celebrity_outbox
print(f"followers of the normal account: {len(normal_followers):,}")
print(f"entries pushed for the normal post: {pushed_normal:,}")
print(f"followers of the celebrity in this demo: {len(celebrity_followers):,}")
print(f"posts stored on the celebrity outbox: {len(celebrity_outbox)}")
print(f"entries pushed for the celebrity post: {pushed_celebrity}")
print(f"celebrity posts in one reader's merged feed: {len(reader_feed)}")The model pushes a normal post into every follower feed and pushes a celebrity post into none. The celebrity post is still stored once and still reaches readers, because readers pull it when they open the feed.
The app decides which accounts count as celebrities by follower count. That line is a design choice, not a law of nature. Accounts near the line behave well under either path, so the exact place of the line matters little.
Wrap-up and trade-offs
The design in short is this. Photos live in blob storage and travel through a CDN. Records live in a database sharded by user id. Feeds are pushed at post time, with a pull escape for celebrity posts. Notifications travel through a queue with dedupe, and presence rides on heartbeats into a cache.
The honest trade-offs are these. Fan-out makes reads cheap and writes heavy, and the celebrity escape adds a merge step to feed reads that need it. Feed copies can lag a moment behind a post, so a follower may see a new photo a little late.
One weak part deserves honesty. The whole design sits in one region, so a failure of that region would take the app down. The next hour would add copies in a second region and practise failover before it is needed.
Every choice here came from the earlier courses. Nothing in the design is new. That is the point of the capstone. A complete design is mostly familiar tools, placed carefully and traded off honestly.
Exercise
A celebrity account on this app posts a photo. Under the hybrid design, what happens at post time?