Part 7 · 6 chapters · ~45 min

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.

44

The brief and the questions

the brief

"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."

the questions, and the answers
  1. 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.
  2. 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).
  3. Devices and network? 80% phones, mobile web (a PWA), median 4G with 3G tails; some metered plans.
  4. Where does a post appear? Feed, profile, thread, notification, search, a share link.
  5. Freshness? New posts within a minute; a like visible to the liker instantly, to others within seconds.
  6. Ranking? Chronological at launch; ranked by the server later; the client should not have to change.
  7. Notifications? In-app badge and list; push when closed; across multiple tabs.
  8. Moderation and the graph? Blocks and mutes must take effect instantly in the UI.
the requirements, with numbers
  1. 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.
  2. 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.
what the client sees of a feed
The feed is a ranking problem the client only sees the end of. Whether it was pulled or pushed, the client receives pages with ids and a cursor; its design is M6's list with M3's media in every row, M1's normalisation across five surfaces, and M8's channel under the notifications.
45

v1: a chronological feed, and where it is assembled

code
// 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
the server side the client depends on
  1. 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.
  2. 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.
v1 on the client
  1. Pages in a window (M6's v4): useInfiniteQuery with a cursor; a virtualised list keyed by id; fetch-ahead one page.
  2. 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.
  3. 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.
the sentence
  1. 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.
WHERE THE FEED IS ASSEMBLED
pull on read, push on write, and what the client sees of either
swipe the figure sideways, or tap expand for full screen
1/6
pull
Pull: GET /feed → the server finds who I follow (2,000 ids), fetches each one's recent posts (2,000 queries or one big IN), merges and ranks, returns 20. Read cost grows with follows; nothing is stored per user. Fine at 200 follows; 2 s at 2,000; the client cannot fix it.
46

Round two: the feed as a media list

code
// 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)
the break
  1. 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.
v2
  1. 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.
  2. 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.
  3. 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.
  4. Prefetch one screen ahead, none on saveData or 2G (load on visibility); three screens ahead is bytes never seen and bandwidth the current screen needs.
  5. Decoded memory is released by unmounting rows; keep the overscan to a screen and measure after 200 posts on a phone, in Safari too.
the sentence
  1. 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).
V2: THE FEED AS A MEDIA LIST
variable heights, images that decide them, video on intent, and the pixel budget per screen
swipe the figure sideways, or tap expand for full screen
1/6
reserved heights
Height without the image: the post payload carries each media item's width and height (or aspect ratio); the row reserves the slot with aspect-ratio before the image arrives; the virtualiser's estimate for a row is text lines plus media slots, computed from the payload, so measured heights rarely differ from estimates and the scrollbar does not jump.
47

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.

v3
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
the sentence
  1. 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).
V3: LIKES, COUNTS AND THE GRAPH ON THE CLIENT
optimistic interactions, denormalised counts, and the same post in five places
swipe the figure sideways, or tap expand for full screen
1/6
entities
The entity cache: posts by id, users by id; every list (feed pages, profile pages, thread, notifications, search) is ids. A like on post p_9 in the thread updates one entity; the feed row, the profile row and the notification all re-render from it. Without this, the user likes in the thread and sees it unliked in the feed.
48

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.

v4
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
the sentence, and the stop
  1. 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).
  2. 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.
V4: NOTIFICATIONS, UNREADS AND THE BADGE
a counter nobody trusts, a list that must be exact, and push when the tab is closed
swipe the figure sideways, or tap expand for full screen
1/6
the count
The count: GET /notifications/unread → { count, version }. Polled every 60 s, refetched on focus, decremented locally when the user reads; shown in the badge and the title ("(3) Feed"). The version lets the client ignore an older response arriving after a newer one. The count is allowed to be stale by a minute and wrong by one; the list is not.
49

The whole board, and the exercise

RoundThe numberThe breakThe designPaid in
v110k 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 onlyA backend contract; a product decision on freshness
v2Four images or a video per post; two decodersLayout shift; 190 MB decoded; twelve videosAspect ratios in the payload; sized variants; one-video controller; connection-aware prefetch; small overscanA media contract; a controller; a policy; an autoplay negotiation
v3Five surfaces per postA like inconsistent across surfaces; stale blocksEntities by id; optimistic mutations with rollback; approximate counts; a client graph; threads as adjacencyA schema; rollback paths; approximate counts; graph upkeep
v4Five tabs; events per minute; hours closedDisagreeing badges; missed events; silence when closedVersioned count + exact grouped list; tab leader; live channel; service worker push with badge and tagsVersions; grouping; election; M8; a service worker
what the sequence teaches
  1. 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.
  2. A feed is a media list; every row is a decode budget and a potential decoder, and the payload must carry sizes.
  3. Surfaces multiply, so normalise once; optimism and approximate counts are the feed's version of consistency.
  4. 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.
the exercise
Open a feed you use on a phone with remote DevTools. Scroll 200 posts; read decoded image memory and the number of playing videos; like something in a thread and check the feed. Then write the sentence for the round that product is in, and the number that moves it to the next.