M7: Social Media
A community feed in four rounds: a chronological feed as pages with ids and the fan-out the client never sees, the feed as a media list with one video and reserved heights, likes and counts and the graph normalised across five surfaces, and notifications as a versioned count, an exact list, a tab leader and a service worker.
The brief and the questions
"A social app for our community: a feed of posts with photos and short videos from people you follow, likes and replies, profiles, notifications. Mobile web first. Ranked, not just chronological, eventually. Launching with ten thousand users."
- Users, follows, posts? 10k users at launch, 500k in two years; median 150 follows, max 5,000; ~50 posts a day per thousand users; a few accounts with 100k followers.
- What is in a post? Text, up to four images, or one video up to 60 s; counts (likes, replies, reposts); my state (liked, reposted).
- Devices and network? 80% phones, mobile web (a PWA), median 4G with 3G tails; some metered plans.
- Where does a post appear? Feed, profile, thread, notification, search, a share link.
- Freshness? New posts within a minute; a like visible to the liker instantly, to others within seconds.
- Ranking? Chronological at launch; ranked by the server later; the client should not have to change.
- Notifications? In-app badge and list; push when closed; across multiple tabs.
- Moderation and the graph? Blocks and mutes must take effect instantly in the UI.
- FR: an infinite feed; posts with images and video; like, reply, repost; profiles and threads; follow, block, mute; notifications with a badge, list and push; share links.
- NFR: first feed screen under 2 s on 4G; scroll at 60 fps on a mid phone; no layout shift as media loads; decoded memory under 100 MB per screen; one video playing at a time; interactions optimistic under 50 ms; a like consistent across every surface instantly; badge right across tabs within a minute; push delivered when closed; the client unchanged when ranking turns on.
v1: a chronological feed, and where it is assembled
// v1: the feed as a paged list with ids, hydrated enough for first paint; the client keeps pages coherent and offers new content at the top
const feed = useInfiniteQuery({
queryKey: ['feed', { tab }], queryFn: ({ pageParam, signal }) => api.feed.page({ cursor: pageParam, limit: 20 }, { signal }),
initialPageParam: undefined, getNextPageParam: last => last.nextCursor ?? undefined,
staleTime: 5 * 60_000, // this session's pages are a snapshot; new content is an offer, not a splice
})
const ids = useMemo(() => feed.data?.pages.flatMap(p => p.items.map(i => i.id)) ?? [], [feed.data])
// "N new posts": a cheap head check with the first page's top cursor, polled or pushed
const { data: newer } = useQuery({ queryKey: ['feed', 'newer', topCursor], queryFn: () => api.feed.countSince(topCursor), refetchInterval: 60_000, enabled: !!topCursor })
// on tap: feed.refetch({ refetchPage: (_, i) => i === 0 }) and scroll to top; or reset the query and let the first page reload
// each item: { id, author: { id, handle, avatar: { url, w, h } }, text, media: [{ url, w, h, kind, poster? }], counts, me: { liked, reposted } }
// the entity cache (v3) is fed from every page: upsert posts and users by id; the list keeps ids; rows read entities- Pull (query every followed account on read) is the backend's v1: right at 150 follows, 2 s at 5,000; push (append a post id to every follower's feed list on write) makes reads O(20) and writes proportional to followers; the hybrid pushes for small accounts and pulls the few large ones at read. The client cannot tell and should not care.
- The contract the client needs: a stable cursor (the same page twice is the same page), ids on every item, and hydration sufficient for first paint (author, text, media with sizes, counts, my state). A second request per item is twenty round trips per page.
- Pages in a window (M6's v4):
useInfiniteQuerywith a cursor; a virtualised list keyed by id; fetch-ahead one page. - Freshness as an offer: a cheap "newer than my top cursor" count, polled or pushed; a pill; tapping loads a new first page and scrolls to top. Never splice new posts under a reader (M6's rule). This session's pages stay coherent; the next session starts fresh.
- Client-side ranking is presentation inside a page (collapse runs by one author, pin replies from people I talk to, place promoted posts) and never crosses pages: the cursor assumes server order. When the server turns ranking on, the cursor becomes (score, id) and the client is unchanged.
- v1 buys a feed at launch in a week on top of M6 and pays: a contract with the backend about cursors and hydration (a negotiation), a "new posts" affordance instead of live splicing (a product decision), and nothing in memory or latency that M6 did not already pay.
Round two: the feed as a media list
// v2: one video plays: the most visible. a single controller over observer ratios; rows register and unregister
const ratios = new Map<HTMLVideoElement, number>()
const io = new IntersectionObserver(entries => {
for (const e of entries) ratios.set(e.target as HTMLVideoElement, e.intersectionRatio)
let best: HTMLVideoElement | null = null, bestR = 0.5 // must be at least half visible to play
for (const [v, r] of ratios) if (r > bestR) { best = v; bestR = r }
for (const [v] of ratios) {
if (v === best) { if (v.paused) v.play().catch(() => {}) } // muted autoplay; play() may reject: ignore
else if (!v.paused) v.pause()
}
}, { threshold: [0, 0.25, 0.5, 0.75, 1] })
export function useFeedVideo(ref: RefObject<HTMLVideoElement>) {
useEffect(() => {
const v = ref.current; if (!v) return
io.observe(v); ratios.set(v, 0)
return () => { io.unobserve(v); ratios.delete(v); v.pause(); v.removeAttribute('src'); v.load() } // release the decoder on unmount (iOS)
}, [ref])
}
// posters from the payload; manifests prefetched for the next screen's videos on fast connections only (navigator.connection?.effectiveType === '4g' && !saveData)- Posts shift as images arrive; the virtualiser's estimates are wrong by hundreds of pixels; four full-bleed originals are 190 MB decoded on a phone; product wants every video autoplaying and the phone has two hardware decoders. The numbers are M3's, inside M6's window.
- Heights from the payload: every media item carries width and height; rows reserve slots with
aspect-ratio; the virtualiser's estimate is computed from text lines plus media slots, so measured heights match and the scrollbar stays still. Blurhash placeholders colour the slot before bytes arrive. - Sizing to the slot (M3): full-bleed on a phone, ~600 px on desktop; srcset 600/1200; four posts at 2× is 17 MB decoded, not 190.
- One video plays: a single controller over IntersectionObserver ratios picks the most visible (at least half), plays it muted, pauses the rest, and releases the decoder on unmount (
removeAttribute('src'); load(), which iOS needs). Posters everywhere else; manifests prefetched for the next screen on fast connections only. - Prefetch one screen ahead, none on
saveDataor 2G (load on visibility); three screens ahead is bytes never seen and bandwidth the current screen needs. - Decoded memory is released by unmounting rows; keep the overscan to a screen and measure after 200 posts on a phone, in Safari too.
- v2 buys a feed that does not shift, plays one video and fits a phone and pays: sizes in every media payload (a contract), an observer-driven video controller (complexity), a connection-aware prefetch policy (complexity), and a negotiation with product over autoplay grids (the decoder count wins).
Round three: likes, counts and the graph
A user likes a post in a thread and sees it unliked in the feed; a reply count on the profile differs from the one in the feed; a blocked user's posts stay visible until a refresh. The number that broke is surfaces per post (five) with a cache per surface. The fix is M1's normalisation, with the feed's particular twists: optimism, approximate counts, and a graph.
- Entities by id: posts and users in one cache; feed pages, profile pages, threads, notifications and search are lists of ids; a row reads its post by id. A like updates one entity; every surface re-renders from it. Relay and Apollo do this for GraphQL; normalizr with a store for REST.
- Optimistic interactions with rollback: on tap, update the entity (liked, +1), send the request, apply the server's count on success (others liked too), revert and toast on failure. Server-side idempotent (a like is set membership) so retries are safe. The React course part 8's mutation shape, applied to every interaction.
- Counts are approximate by design: the server's denormalised number plus my pending deltas; never counted from a list (it is a page); "1.2K" hides jitter; exact counts on the detail view, refetched.
- The graph on the client: follows, blocks and mutes as sets of ids (thousands; fetched once; updated on action). A block hides that user's posts in every loaded list now, as a render-time filter; a follow shows "following" on every instance now. The server's feed filtering catches up on the next page.
- Threads as adjacency: parent → child ids beside the entities; rendering is a walk with depth (flattened into M6's window for deep threads); my reply inserts into adjacency, the entities and the parent's count everywhere, optimistically.
- v3 buys instant, consistent interactions on every surface and pays: a normalised cache with a schema the client must know (complexity), a rollback path per interaction (complexity), counts the product accepts as approximate (consistency), and a client graph kept in step with the server's (complexity).
Round four: notifications, the badge and push
Five tabs show five different badge counts; the list misses events; nothing arrives when the app is closed. The numbers that broke are tabs per user, events per minute, and the hours the app is closed. v4 splits the count from the list, elects a tab, and adds a service worker.
- The count is cheap and allowed to be stale by a minute and wrong by one:
{ count, version }polled every 60 s, refetched on focus, decremented locally on read, versions so an old response cannot overwrite a newer one; shown in the badge and the title. - The list is exact: cursor-paged, newest first, grouped on the client by (type, target) within a window ("Ada and 4 others liked your post"); opening marks the page read with a batch of ids, and the count is refetched, not guessed.
- Across tabs: one leader (Web Locks, or the oldest tab via BroadcastChannel) polls and broadcasts; followers render; a read in any tab is broadcast. Five tabs, one poll, one number.
- Live: with M8's channel open, events push { count, version, event }; the badge pulses and the list prepends (the reader is rarely scrolled inside the notification list, so the insert-above rule relaxes here); the poll stays as the fallback.
- Closed app: a service worker with Web Push (VAPID keys, a subscription stored server-side); the push handler shows a system notification with a tag so repeats replace rather than stack, sets the app badge (
navigator.setAppBadge), and opens the target on click. Permission is asked in context, after the first reply received, never on load. Safari supports it on macOS and on iOS for installed PWAs.
- v4 buys a badge that is right enough everywhere and an exact list, open or closed; pays: a versioned count endpoint (complexity), client grouping (complexity), leader election (complexity), a real-time channel (M8), and a service worker with its own lifecycle and permission UX (complexity; the browser course part 7).
- Stop: every number in the brief is met; ranking turned on without a client change. Stories, DMs (M8 plus M5's offline), and creator analytics (M4) are the next products, built on the same cache and lists.
The whole board, and the exercise
| Round | The number | The break | The design | Paid in |
|---|---|---|---|---|
| v1 | 10k users; 150 follows | (the backend's: pull at 5,000 follows) | Pages with ids and a stable cursor in M6's window; "new posts" as an offer; client ranking within a page only | A backend contract; a product decision on freshness |
| v2 | Four images or a video per post; two decoders | Layout shift; 190 MB decoded; twelve videos | Aspect ratios in the payload; sized variants; one-video controller; connection-aware prefetch; small overscan | A media contract; a controller; a policy; an autoplay negotiation |
| v3 | Five surfaces per post | A like inconsistent across surfaces; stale blocks | Entities by id; optimistic mutations with rollback; approximate counts; a client graph; threads as adjacency | A schema; rollback paths; approximate counts; graph upkeep |
| v4 | Five tabs; events per minute; hours closed | Disagreeing badges; missed events; silence when closed | Versioned count + exact grouped list; tab leader; live channel; service worker push with badge and tags | Versions; grouping; election; M8; a service worker |
- The client sees the end of the ranking; its contract is the cursor and the ids, and it must survive the server changing how the feed is assembled.
- A feed is a media list; every row is a decode budget and a potential decoder, and the payload must carry sizes.
- Surfaces multiply, so normalise once; optimism and approximate counts are the feed's version of consistency.
- Notifications are a count and a list with different budgets, one poller per browser, and a service worker for the hours the app is closed.