Design Facebook (Social Network)
The frontend of a social network: a mixed feed of many post types, the post composer, and a notifications system that stays correct across tabs.
Published · by Frontend Masters India
"Design Facebook" is broad enough that you can drown in it. The interviewer doesn't want the whole company. They want to see whether you can take one screen, a feed full of different post types with a composer on top and a notification bell in the corner, and reason about it like an engineer who has shipped this before.
The generic feed mechanics (cursor pagination, IntersectionObserver, virtualization, layout shift) are covered in the news feed write-up, so I'll move past those quickly and spend the time where Facebook is actually different: a heterogeneous feed, the composer, and notifications.
1. Scope it first
Pick a slice and say so out loud. I'd scope to the home feed plus composer plus notifications, and skip Marketplace, Groups, and Messenger.
Questions worth asking:
- What post types are in the feed? Text status, single image, photo album, link preview, shared post, video, life event, "X commented on Y". Each renders differently and each loads differently.
- How fresh is the feed? Pull-to-refresh on load is enough for most answers. Live push matters more for notifications than for the feed itself.
- Can users post and react inline? Yes, which means a composer and optimistic reactions.
- Web only, or web plus mobile web? Mobile web changes image budgets and gesture expectations.
Assume logged-in web, a personalized ranked feed, mixed post types, and a notification bell that updates without a refresh.
2. Model the feed as a list of typed posts
The thing that makes Facebook's feed harder than a plain list is that every item can be a different shape. Don't model a post as one giant object with every optional field. Model it as a discriminated union and render by type.
type FeedItem =
| { type: "status"; id: string; author: User; text: string }
| { type: "photo"; id: string; author: User; media: Image[] }
| { type: "link"; id: string; author: User; url: string; preview: LinkPreview }
| { type: "shared"; id: string; author: User; original: FeedItem }
| { type: "video"; id: string; author: User; video: Video };On the render side, a single switch maps each type to a component. A <SharedPost> can recurse into the embedded original, which is how a reshare of a reshare renders without special casing.
function Post({ item }: { item: FeedItem }) {
switch (item.type) {
case "status": return <StatusPost item={item} />;
case "photo": return <PhotoPost item={item} />;
case "link": return <LinkPost item={item} />;
case "shared": return <SharedPost item={item} />;
case "video": return <VideoPost item={item} />;
}
}The payoff is that the feed list stays dumb. It only knows about FeedItem[] and a cursor. All the type-specific logic lives in leaf components, which is also what lets you code-split the heavy ones.
3. Variable heights make virtualization harder here
A status update is 60px tall. A photo album with a caption and 40 comments expanded is 1200px. When you windowing a feed like this, fixed row heights are a non-starter, and even estimated heights drift as comments expand or images finish loading.
The pattern that holds up: measure each row after it mounts (a ResizeObserver per item or the measurement hook your virtualization library gives you), cache the measured height by post ID, and feed those measurements back so the scroll math stays right. When a post grows because someone expanded comments, you remeasure that one row and the list above your scroll position must not shift. Anchor the scroll to a stable item so growth happens below the fold, not under the user's cursor.
4. The composer
The composer is its own little app and a great place to show product sense. It starts as a one-line "What's on your mind?" box and expands into a modal with text, photos, a feeling/activity picker, and an audience selector (Public, Friends, Only me).
A few things I'd call out:
- Draft persistence. People get interrupted. Persist the in-progress draft to
localStorage(or IndexedDB if there are image blobs) so a refresh or accidental close doesn't lose it. Clear it on successful post. - Image uploads happen before submit. Don't wait for the user to hit Post to start uploading. Upload each selected image as soon as it's picked, show per-image progress, and keep the returned media IDs. Posting then sends text plus a list of already-uploaded IDs, so the submit is fast and the perceived wait is hidden inside the time they spend typing.
- Optimistic insert. On Post, prepend the new item to the top of the feed immediately with a "Sending..." state, then swap in the server's real item (real ID, real timestamp) when it lands. On failure, keep the item visible with a Retry affordance rather than silently dropping what they wrote.
function submit(draft) {
const tempId = "temp-" + crypto.randomUUID();
prependToFeed({ ...draft, id: tempId, pending: true });
api.createPost(draft)
.then((real) => replaceInFeed(tempId, real))
.catch(() => markFailed(tempId)); // show Retry, don't delete
}- Accessibility. The composer modal traps focus, returns focus to the trigger on close, closes on Escape, and has a labelled audience selector. The image picker needs a real file input behind the styled button, not a div with a click handler.
5. Reactions, not just likes
Facebook has six reactions, so the like button is a popover that opens on hover (desktop) or long-press (touch). The optimistic update is the same idea as a like (update count and your own reaction immediately, reconcile in the background, roll back on failure), but the state is richer: you track your current reaction and the aggregate counts per type.
The thing people miss is the count math when you change your reaction. Going from Like to Love should decrement Like and increment Love in one optimistic step, not increment Love and leave a stale Like. Compute the diff against your previous reaction.
6. Notifications that stay correct
The bell in the corner is where I'd spend real time, because it's the part most candidates wave at. Three concerns:
Delivery. A polling endpoint every 30-60 seconds is the simple, honest answer and it's fine for an interview. Mention that WebSockets or Server-Sent Events are the upgrade when you need instant delivery, and that SSE is the lighter choice since notifications are server-to-client only.
The unread badge. The count needs to be correct, and "correct" is harder than it looks because the same user often has two tabs open. If they read notifications in tab A, the badge in tab B should drop too. Use the BroadcastChannel API to fan out a "notifications read" message to other tabs so they update their badge without a network round trip.
const channel = new BroadcastChannel("notifications");
function markAllRead() {
setUnread(0);
api.markRead();
channel.postMessage({ type: "read" });
}
channel.onmessage = (e) => {
if (e.data.type === "read") setUnread(0);
};The dropdown. Opening the bell shows the latest notifications and marks them seen (seen, not read; "seen" clears the badge, "read" is per-item when you click through). The list paginates with the same cursor approach as the feed. Group repetitive ones ("Alice and 3 others reacted to your photo") on the client when the server sends them pre-aggregated.
7. Performance across the whole screen
A few cross-cutting decisions:
- Code-split by post type. The video player and the photo lightbox are heavy. Lazy-load those components so a feed of text posts never pays for them.
- Pause off-screen video. Use IntersectionObserver to play the video that's in view and pause the rest. Autoplaying five videos at once will jank the scroll and burn battery.
- Defer comments. Render a post with its comment count, and only fetch and mount the comment thread when the user expands it. Most posts never get expanded.
What the interviewer will push on
- "How do you keep the unread count consistent across tabs and after a reconnect?" BroadcastChannel for tab-to-tab, and on reconnect refetch the authoritative count from the server rather than trusting accumulated local state.
- "A reshare embeds another post that itself was deleted. What renders?" The server sends a tombstone for the original ("This content isn't available"), and
<SharedPost>renders that fallback instead of recursing into nothing. - "How do optimistic posts survive a refresh mid-send?" They don't, unless you persist the draft. The pending item lives in memory; the draft lives in storage. On reload you restore the draft, not a fake feed item, so you never show a post that was never sent.
- "How would you handle a user who reacts, then immediately un-reacts, on a slow network?" Coalesce. Track the latest intended state and only send the final value, or send both and let the server's last-write-win settle it. Don't fire and apply two racing requests blindly.
The one-paragraph recap
Treat Facebook's feed as a list of typed posts rendered through one switch, with per-type leaf components that you code-split so a text feed never loads the video player. The composer uploads images as they're picked, persists drafts, and optimistically prepends new posts with a retry path on failure. Reactions are optimistic with careful diff math when switching. The notification bell polls (with SSE as the upgrade), keeps its badge correct across tabs via BroadcastChannel, and reconciles against the server on reconnect. Lead with the typed-feed model and the cross-tab notification problem, since those are the parts that show you've actually built something like this.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…