cf.completefrontend
My progress
SYSTEM DESIGN · #79

Design Twitter (X)

The whole web client of a real-time social network: a timeline, post pages with reply trees, a composer, search, notifications, and public pages that share well. The hard parts are one post shown in many shapes, routes that keep the timeline alive under modals, character counting that matches the server, server-rendered pages for crawlers and preview bots, live counts that don't flood the server, and all of it fast on a cheap phone in any language.

The question

Design the web client for Twitter, now called X. People read a home timeline, open a post to see the conversation under it, write posts and threads with photos, polls, and video, search, and check their notifications. Anyone can share a link to a post or a profile, and that link must open fast for people who are not logged in and show a preview card in other apps.

Focus on the frontend. Hundreds of millions of people use it in dozens of languages, many on cheap Android phones. Walk me through the app as a whole, not only the feed.

Real-life examples: X (formerly Twitter) on the web, Bluesky web app, Threads on the web, Mastodon web client

Requirements: an app, not a feed

The most common mistake with this question is to answer a different one. "Design Twitter" sounds like "design a feed", and many candidates spend 40 minutes on infinite scroll. But a feed is one screen. Twitter is an app: a shell with navigation, about ten kinds of pages, a composer that opens on top of any page, public pages that search engines and chat apps read, and a lot of small live parts.

The feed itself is covered in depth in the news-feed guide: cursor pagination, virtualization, the normalized store, optimistic likes, the new-posts banner, and media. This guide keeps those parts short and refers to it. The depth here goes to what makes Twitter different: routing, conversations, the composer, public pages, counters, search, notifications, abuse, languages, and cheap phones.

The questions that move the design

Ask questions whose answers change what you build. For each one, the table gives the answer this guide assumes and what would change if the interviewer said something else.

QuestionAnswer we assumeWhat it changes
Which screens are in scope?Home, post detail, profile, compose, search, notifications. Direct messages are out.The route map and how much shared state the shell holds.
Must pages work logged out?Yes: post and profile pages are public and get shared everywhere.Server rendering, Open Graph tags, and CDN caching for those routes.
How long can a post be?280 weighted characters. Links count as 23. Paid accounts can write longer posts.A counter that matches the server's rules exactly, not text.length.
What can a post hold?Text, up to 4 images or 1 video or 1 GIF, or a poll. Quotes of other posts.Upload pipeline, alt text, and the many shapes of a post.
How live must it feel?New posts offered within 30 s. Counts on visible posts up to about 20 s old. Notifications within seconds.Push for notifications, cheap batched updates for counters.
Which devices?Web on all screens. Over half of visits from phones, many with 2 to 4 GB of memory.JavaScript budget, list virtualization, image sizes.
Which languages?About 40, including Arabic, Hebrew, Persian, and Urdu written right to left.Logical CSS, bidirectional text handling, per-post direction.

Functional requirements

  • Home timeline with two tabs, "For you" (ranked) and "Following" (newest first). Loads more as you scroll. Offers new posts without moving what you read.
  • Post detail at /ada/status/1849...: the post, the chain of posts it replies to above it, and ranked replies below, with ways to load more.
  • Profile at /ada: header, counts, and tabs for Posts, Replies, Media, and Likes.
  • Compose from any page: text, images with alt text, a poll, or a thread of several posts. Drafts survive a closed tab.
  • Engage: like, repost, quote, reply, bookmark, share. Each shows its result the moment you tap.
  • Search with typeahead suggestions and tabs: Top, Latest, People, Media. Trends in a sidebar.
  • Notifications, grouped: "Ada and 41 others liked your post", with an unread badge in the navigation.

Non-functional requirements, with numbers

Give each quality a number, measured at the 75th percentile of real visits, the way Core Web Vitals are. A number can be checked later. A word like "fast" can't.

  • Public pages paint fast. A shared post link shows the post text within 1.5 s and has an LCP (Largest Contentful Paint, when the biggest visible element paints) under 2.5 s on a mid-range phone over 4G, logged out.
  • Taps answer fast. INP (Interaction to Next Paint, the delay from a tap to the next frame) under 200 ms, on a phone that is four to six times slower than a laptop.
  • Nothing jumps. CLS (Cumulative Layout Shift) under 0.1, even when images load and new posts arrive.
  • Small first load. At most 150 KB of compressed JavaScript before the home timeline is usable. Everything else loads when needed.
  • Correct counting. The composer's count and the server's count always agree, for every script and emoji.
  • Cheap to keep live. Live counts cost the server at most one small request per open tab every 10 to 30 s, not one per post.
  • Usable by everyone. Full keyboard use, screen reader support, right-to-left layouts, and reduced motion.

Out of scope, said out loud

  • Ranking, fan-out of posts to followers, and storage. They are backend problems. The client shows the order it gets.
  • Direct messages. They are a chat app, with its own guide.
  • Ads, Spaces (live audio), and paid features beyond longer posts. Ads are one more item type in the timeline.
  • Native apps. Most ideas carry over, but we design for browsers and their limits.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design of the client the most? Select all that apply.

What makes Twitter hard

Name the properties of this app that drive the decisions. Each one comes back in a later chapter, and saying them early shows the interviewer where you're going.

1. One post, many shapes

The same post shows up as a timeline row, as a repost with "Grace reposted" on top, as a small card inside someone's quote, as the big focal post on its own page, as a reply in someone else's conversation, and as a line in a notification. If Ada likes it in one place, all six must show the new count.

That pushes us toward one copy of each post in a normalized store (a client cache keyed by ID, where lists hold IDs, not copies) and many small view components that read from it.

2. Pages stack on top of each other

People open a post, then a photo inside it, then the composer to reply, then press Back three times and expect to land exactly where they were in the timeline, 300 posts down. Some of those screens are overlays that should keep the page under them alive. That makes routing a real design problem, not just a list of URLs.

3. Much of the traffic is not logged in

A post link is pasted into WhatsApp, Slack, Discord, and news articles. The first visitor is often a bot that builds a preview card, and the next is a person with no account on a cold phone. These pages must work from plain HTML, rank in search, and survive a sudden spike when a post goes viral: 50,000 visits a minute to one URL. A logged-in single-page app is the wrong shape for them.

4. Text is harder than it looks

The 280 limit is counted with weights: a link counts as 23, most Latin letters count as 1, Chinese and Japanese characters count as 2, and an emoji counts as 2 even if it's made of seven code points. Arabic posts mix with English handles and numbers in one line. JavaScript's text.length counts UTF-16 code units, which matches none of this.

5. Everything is a little bit live

Like counts tick, new posts arrive, notifications come in, trends change. None of it needs millisecond precision, but there's a lot of it, and with 100 million open tabs a careless design becomes a self-inflicted attack on your own servers. The question is always: how fresh, for whom, at what cost?

6. Some users are hostile

Spammers post thousands of replies, people paste HTML hoping it runs, and harassment campaigns flood a single post. Rate limits, blocked and muted accounts, sensitive media, and reply controls are part of the product, and the UI must handle each without losing the user's work.

7. The median phone is slow

A large share of users are on Android phones that cost under 200 dollars, with slow CPUs and 2 to 4 GB of memory. On those phones, parsing and running 1 MB of JavaScript can take several seconds before anything responds to a tap. Every feature above has to fit inside a budget.

PropertyDrivesChapter
One post, many shapesNormalized entities, small view components, tombstonesPosts in many shapes
Pages stackModal routes, kept-alive timeline, scroll restorationApp shell and routing
Logged-out trafficServer rendering, meta tags, CDN cache and purgePublic pages
Weighted textShared counting rules, grapheme clustersThe composer
A bit liveBatched, visibility-aware updatesLive counters
Hostile usersRate-limit states, safe rendering, reply controlsAbuse and limits
Slow phonesJavaScript budget, route splitting, virtualizationPerformance

Think first

An interviewer says: "Assume the timeline is solved. What's left?" What would you list, in order of risk?

The app shell and routing

Start the design with the frame everything sits in. An app shell is the part of the page that stays the same as you move around: the navigation, the sidebar, and the unread badge. Only the middle column changes. If the shell stays mounted, moving between pages costs one data request and one column of rendering, not a full page load.

The app shell at three screen sizes. On a desktop: a navigation column on the left, the main column in the middle with three posts, and a sidebar on the right with search and trends. On a tablet: a narrow icon rail and the main column, no sidebar. On a phone: a top bar, the main column, a floating compose button, and a bottom tab bar.desktopnavmain columnpostpostpostsidebarsearchtrendstabletmainpostpostpostphonemainpost+tab barthe shell stays mounted; only the main column swaps
The shell (navigation, sidebar, tab bar) stays mounted while you move around. Only the main column changes, and it's the same component at every size.

The layout has three shapes. On a desktop: a left navigation column, a main column about 600 px wide, and a right sidebar with search, trends, and "who to follow". On a tablet the navigation shrinks to icons and the sidebar goes away.

On a phone: a top bar, a bottom tab bar with Home, Search, Notifications, and Messages, and a floating compose button. The main column is the same component in all three, so it's built once.

The route map

URLPageKindRendered
/homeHome timeline, two tabsPageClient, behind login
/adaProfile, tabs as /ada/with_replies, /ada/mediaPageServer for logged-out, then client
/ada/status/1849...Post detail with conversationPageServer for logged-out, then client
/ada/status/1849.../photo/2Photo viewerModal over the post or timelineClient
/compose/postComposerModal over any pageClient
/search?q=css&f=liveSearch results, tab in fPageClient
/notificationsNotifications, tab for mentionsPageClient, behind login
/exploreTrends and topicsPageClient

One detail matters: /:handle is a catch-all. If someone registers the username home or explore, their profile and the app's page fight over the same URL. The fix is not in the router. The sign-up form must reject a list of reserved names, and the router lists fixed paths before the catch-all.

The composer, the photo viewer, and a few dialogs open on top of whatever page you're on. The naive version keeps them as component state: const [open, setOpen] = useState(false).

It breaks in three ways. On a phone, the Back button or gesture leaves the whole page instead of closing the overlay, which is the most common complaint about web overlays. You can't share or bookmark a photo. And a refresh loses the overlay.

Decision How should overlays like the composer and photo viewer work with the URL?

Component state only

The overlay is a flag in React state.

The URL never changes.

Pros

  • Simplest to build.
  • The page underneath is untouched.

Cons

  • Back leaves the page instead of closing the overlay.
  • Can't share, bookmark, or refresh into it.
  • Analytics can't see it as a screen.

Separate full page

Navigate to /compose/post as a normal page.

The timeline unmounts.

Pros

  • Shareable and refreshable.
  • Back works as expected.

Cons

  • The timeline is thrown away and must be rebuilt, with its scroll position.
  • Feels heavier: a full page change for a small form.

Modal route with a background location

Push the overlay's URL, and store the current location in the history entry's state as background.

Render the page for background underneath, and the overlay on top.

On a direct visit there is no background, so render a default page under it.

Pros

  • Back closes the overlay, the page under it never unmounted.
  • Shareable, refreshable, and visible to analytics.

Cons

  • Two route trees to keep in sync.
  • Focus and scroll locking must be handled carefully.

What we pick: Modal routes with a background location. You can see the same behavior on x.com: open the composer from the timeline and the URL becomes /compose/post while the timeline stays behind it, exactly where it was. In React Router this is a location prop on the main Routes. In the Next.js App Router it's built in as parallel routes plus intercepting routes, where a soft navigation shows the modal and a hard load shows the full page.

A sequence diagram of the composer as a modal route, with four columns: User, Router, Timeline, and Composer. The user taps compose. The router pushes /compose/post with the current location saved as background. The timeline keeps rendering for the background location and stays mounted. The composer opens on top. The user presses Back. The router pops the entry and unmounts the composer. The timeline is still at the same scroll position, with no reload.UserRouterTimelineComposertap composekept underneathpush /compose/postBackpop, unmountsame scroll
Opening pushes /compose/post with background: /home in the history state. Back pops it: the composer unmounts, and the timeline, which never left, is exactly where it was.
tsx
function AppRoutes() {
  const location = useLocation();
  const background = location.state?.background as Location | undefined;
  return (
    <>
      <Routes location={background ?? location}>
        <Route path="/home" element={<Home />} />
        <Route path="/compose/post" element={<Home composeOpen />} />
        <Route path="/:handle/status/:id" element={<PostPage />} />
        <Route path="/:handle/status/:id/photo/:n" element={<PostPage photoOpen />} />
        <Route path="/:handle" element={<Profile />} />
      </Routes>
      {background && (
        <Routes>
          <Route path="/compose/post" element={<ComposeModal />} />
          <Route path="/:handle/status/:id/photo/:n" element={<PhotoModal />} />
        </Routes>
      )}
    </>
  );
}
Two route trees: the page, drawn for the background location if there is one, and the overlay on top. On a direct visit, the first tree alone draws a default page with the overlay open.

Closing needs one rule. If we pushed the overlay's history entry ourselves (there is a background), close with history.back(), so the history stack doesn't grow with each open and close. If the user landed on /compose/post directly, there is nothing to go back to inside the app, so close by replacing the URL with /home.

The overlay itself is a native dialog element opened with showModal(): the browser makes the page behind it inert, so Tab stays inside the dialog, and Escape closes it. Focus must then return to the button that opened it; the browser does this after close(), but not when the route change simply unmounts the dialog, so the app does it.

Coming back to the timeline

The post page is a full page, not an overlay: it has its own long scroll and its own public URL. So when the user taps a post 300 rows down and then presses Back, the timeline has been unmounted.

Two ways to get them back to the same spot: keep the timeline mounted but hidden (React 19.2's Activity component with mode="hidden" does this and keeps its state), or rebuild it from the cache. Keeping it alive costs memory, which is exactly what cheap phones lack.

We rebuild: the loaded pages stay in the query cache, and before leaving we save a scroll anchor, the ID of the first visible post and how many pixels of it were scrolled past the top of the screen. On return we render the list around that post and scroll to it, before the first paint. A raw scrollY fails, because rows can have different heights when measured again.

Check your understanding · Exercise 2

The composer is a modal route at /compose/post with a background location. Which statements are true? Select all that apply.

The home timeline, briefly

The timeline is a news feed, and the news-feed guide designs one in depth. Here is the short version of its decisions, so you can say them in two minutes and move on, followed by the few things that are specific to X.

  • Cursor pagination. Each page ends with an opaque cursor string, and the next request asks for posts after it. Offsets (?offset=40) break on a live list: a new post at the top shifts every position by one, so you see a post twice, and a deleted post makes you skip one. Try both in the lab below.
  • A virtualized list. Only the rows near the screen are in the DOM, about 15 to 25 of them, with spacers for the rest. Row heights are unknown until measured, so the list measures each row once with a ResizeObserver and keeps the heights.
  • A normalized store. Pages hold post IDs. Posts and users live once, keyed by ID. More on this in the next chapter, because on X it matters even more.
  • Optimistic engagement. A like turns pink and the count goes up in the same frame as the tap, the request follows, and a failure rolls back with a small message.
  • A new-posts banner. New posts never insert themselves above what you're reading. A pill says "Show 23 posts" and inserts them when tapped, scrolling to the top. If you're already at the top and idle, they can slide in.
Paging
Timeline paging with offset. The server's newest posts are p30, p29, p28, p27, p26, p25, p24, p23, p22, p21. The client has loaded 5 posts. Duplicates on screen: 0. Posts skipped: none.server, newest firstp30p29p28p27p26p25p24p23p22p21your timeline, in load orderp30p29p28p27p26paging: offsetnext: ?offset=5new above: 0duplicates: 0skipped: none
Page 1 shows p30 to p26. Add new posts or delete one, then load the next page.

Two timelines in one screen

"For you" and "Following" are two different lists with different cursors, different caches, and different scroll positions. Keep them as two independent query keys, such as ["timeline", "for_you"] and ["timeline", "following"], each with its own saved scroll anchor. Switching tabs then shows the cached list at once and only checks for new posts. The selected tab is remembered in localStorage, because people who prefer "Following" want it every time they open the app.

Entries, not posts

A timeline is not only posts. It mixes in a reply shown together with the post it answers, a "who to follow" carousel, a pinned post, ads, and "show more" markers. So the API returns entries, each with a type and a sort key, and the client has one small renderer per type.

A common refinement is to send a list of instructions, such as "add these entries" or "replace this entry", so the server can also change entries already on screen. The rule that keeps old clients alive: an entry type the client doesn't know is skipped, not an error. Then the server can ship a new carousel without breaking everyone still running last week's JavaScript.

ts
type TimelineEntry =
  | { kind: "post"; id: string; sortKey: string; postId: string; socialContext?: string }
  | { kind: "conversation"; id: string; sortKey: string; postIds: string[] }
  | { kind: "who_to_follow"; id: string; sortKey: string; userIds: string[] }
  | { kind: "ad"; id: string; sortKey: string; postId: string; impressionUrl: string };

type TimelinePage = {
  entries: TimelineEntry[];
  cursor: { bottom: string | null; top: string };
  // Entities for this page, merged into the store.
  posts: Record<string, Post>;
  users: Record<string, User>;
};
A timeline page: entries with a type and a sort key, and the IDs they point to.

Counting views

X shows a view count on every post, and the client is where views happen. An IntersectionObserver with threshold: 0.5 notices when half of a post is on screen. If it stays that way for about one second, the post ID goes into a buffer.

The buffer is sent every 10 seconds, and with navigator.sendBeacon when the page is hidden, so closing the tab doesn't lose it. The server removes duplicates again, because a client can't be trusted to count.

Check your understanding · Exercise 3

A new server release adds a timeline entry of kind topic_carousel. Last week's client gets it. What should happen? Select all that apply.

One post, many shapes: reposts, quotes, and the store

Count the places a single post can appear on one screen. In the timeline as its own row. Again as a repost, under "Grace reposted". As a small card inside Lin's quote post. As a reply in someone else's conversation, under "Replying to @ada".

Open it, and it's the large focal post on its own page. In notifications, it's the subject of "Ada and 41 others liked your post". Each shape has a different layout, but it's the same data, and it must never disagree with itself.

One post entity, post 1849, in the normalized store in the middle. Six views read it by ID: a timeline row, a repost row under Grace reposted, a quote card inside Lin's post, the focal post on its own page, a reply in a conversation, and a notification saying 41 people liked it. One write to the entity updates all six.timeline rowAda's own postrepost rowGrace repostedquote cardinside Lin's postfocal postits own pagereplyin a conversationnotification41 liked itpost 1849one entityone writeevery view updates
Six layouts, one copy of the data. A like written once to the entity shows in every view that reads it, which is why lists hold IDs and never copies.

The naive design stores whatever each API response returned, per screen: the timeline query holds 20 full post objects, the post page query holds its own copy, the quote card holds a third. Then Ada opens a post, likes it, and presses Back. The timeline still shows the old count and an empty heart, because its copy never heard about the like. Refetching the timeline would fix it, but that costs a request, may reorder the list, and would lose the scroll position.

Decision Where does the client keep post data so every shape agrees?

A copy per screen, refetched on focus

Each query keeps the posts it returned.

Screens refetch when they become visible again.

Pros

  • No extra library or merge logic.
  • Each screen is independent.

Cons

  • Copies disagree until the refetch lands.
  • Refetching a ranked timeline can reorder it under the user.
  • The same post sits in memory three or four times.

Copies, plus manual patching on writes

After a like, walk every cached query and patch any copy of that post.

Pros

  • Works with any fetching library.

Cons

  • Every new screen must remember to be patched.
  • Easy to miss one, such as the quote card inside another post.
  • Grows into a pile of special cases.

A normalized entity store

Posts and users live once, in maps keyed by ID.

Lists and pages hold only IDs.

Every response's entities are merged in; every view reads by ID.

Pros

  • One write updates every shape at once.
  • Less memory.
  • Works the same for REST, GraphQL, or pushed updates.

Cons

  • Merging rules are needed: what wins when two responses disagree.
  • Entities pile up and need cleanup in long sessions.

What we pick: The normalized store. On X the same post appears in so many shapes that anything else turns into a bug factory. With GraphQL, a normalizing client cache such as Relay or Apollo does this for you, keyed by type and ID. With REST, have the server return entities next to the IDs (the posts and users maps in the timeline page above) and merge them into the store yourself.

How reposts and quotes are modelled

A repost shares someone else's post unchanged. It's its own small record, with its own ID, an author (Grace), and repostOfId pointing at the original. The timeline row shows "Grace reposted" above the original's content, read from the store.

Every action on that row acts on the original: liking it likes Ada's post, and the counts are the original's counts. Undoing a repost deletes Grace's record, not Ada's post.

A quote is a new post with its own text, likes, and replies, plus quotedId pointing at another post, which renders as a card inside it. Actions on the outer post act on the quote; tapping the card opens the quoted post. Render only one level: if the quoted post is itself a quote, the card shows its text and a small "Quote" link, not a card inside a card inside a card. Deep nesting costs layout work and makes no sense on a 360 px screen.

ts
type Post = {
  id: string;              // "1849273648123904000": a string, never a number
  authorId: string;
  text: string;
  entities: TextEntity[];  // mentions, links, hashtags, with ranges
  media: MediaRef[];
  createdAt: string;       // ISO 8601
  replyToId?: string;
  conversationId: string;  // the root post of the conversation
  quotedId?: string;
  repostOfId?: string;     // set only on repost records
  counts: { replies: number; reposts: number; quotes: number; likes: number; views: number };
  viewer: { liked: boolean; reposted: boolean; bookmarked: boolean };
  editedToId?: string;     // a newer version replaced this one
  state: "visible" | "deleted" | "withheld" | "unavailable";
};
The post entity. IDs are strings, counts are numbers, and relations are IDs.

Why the ID is a string

X's post IDs are 64-bit numbers built from a timestamp, a machine ID, and a sequence: the Snowflake scheme. Today they have 19 digits. JavaScript numbers are 64-bit floats and are exact only up to Number.MAX_SAFE_INTEGER, about 9 times 10 to the 15th, which is 16 digits.

JSON.parse silently rounds a bigger number, so two different posts can end up with the same ID and the wrong post opens. Twitter's old API learned this the hard way and added an id_str field next to every numeric id. Rule: IDs that cross the network are strings, everywhere.

Tombstones: posts that are gone

Posts disappear: deleted by the author, withheld in a country, from an account that blocked you, or from a private account. The store keeps the ID with a state instead of removing it, and each shape renders that state: a timeline row disappears, a quote card says "This post is unavailable", and inside a conversation a gray placeholder keeps the thread's shape, so the replies under it still make sense.

Edits are a kind of replacement: on X an edited post gets a new ID, and the old one points at it. The client shows the newest version and a "Last edited" label, with the history one tap away.

Check your understanding · Exercise 4

Lin quoted Ada's post. In the timeline, you tap the heart on the quote card (the small Ada card inside Lin's post). With a normalized store, what should happen? Select all that apply.

The post page: threads and reply trees

The post page at /ada/status/1849... is often the first screen logged-out people see. It answers two questions: what is this post replying to, and what did people say about it? So it has three parts.

Above the post, the parent chain: the posts it replies to, up to the root of the conversation, joined by a thin vertical line. In the middle, the focal post, larger, with the full date, the view count, and all counts. Below it, the reply box and the replies, ranked.

A wireframe of the post page. From the top: the root post, a row saying Show 52 more posts, two parent posts, then the focal post, larger and highlighted, then the reply box, then a reply module made of a reply and the author's answer, then another reply. Notes say the parent chain shows the root and the nearest parents with a gap that loads in place, the focal post is in the first HTML chunk and scrolled to the top, and replies come as ranked modules with a cursor for more.x.com/ada/status/1849root postShow 52 more postsparentparentfocal postPost your replyreply + author's answerreplyparent chain:root + nearestgap loads in placefocal post:first HTML chunkscrolled to topranked modules,cursor for more
The page has the same bounded shape for every post: a short parent chain, the focal post, and one page of ranked reply modules. To go deeper, tap a reply and it becomes the focal post of its own page.

A conversation is a tree, and trees get big

Each reply can have replies, so a conversation is a tree with the root post at the top. Most trees are small. A viral one has 80,000 replies, some of them 40 levels deep.

The naive design fetches the whole tree for the focal post and renders it with indentation. On a viral post that's tens of megabytes of JSON, and deep indentation on a 360 px phone leaves 40 px of width for text by level 10.

Decision What does the post page fetch and show?

The whole tree, indented

One request returns every descendant.

Render nested lists, one indent per level.

Pros

  • Simple model, matches the data.
  • Works for small threads, like Reddit-style discussions.

Cons

  • Unbounded size on viral posts.
  • Deep indentation is unreadable on phones.
  • Ranking is impossible to apply across the whole tree cheaply.

A flat list of all replies, by time

Return ancestors and all descendants as two flat lists, oldest first.

Mastodon's API does roughly this.

Pros

  • Simple to page.
  • No indentation problem.

Cons

  • Loses who replied to whom.
  • Time order buries the useful replies under the first hundred.

Ancestors, focal post, and pages of ranked reply modules

Return the root and the nearest parents, the focal post, and the first page of replies.

Each reply comes as a module: the reply plus at most one or two posts that continue it, such as the author's answer.

Deeper branches load on demand by opening that reply's own page.

Pros

  • Bounded size for every post, viral or not.
  • Ranking puts the useful replies first.
  • No deep indentation: depth is shown by navigation, not by width.

Cons

  • Several cursors to manage.
  • The full tree is never visible at once.

What we pick: Ancestors, the focal post, and ranked reply modules, which is also what X's post page shows. The trick is that depth becomes navigation. To read the replies to a reply, you tap it, and it becomes the focal post of its own page, with the original post now in its parent chain. Every page has the same bounded shape.

The parent chain

A long argument can have 60 posts above the one you opened. Show the root, so the reader knows what started it, and the three to five nearest parents, so they know what this post answers. In between, a gap row says "Show 52 more posts" and loads them in place when tapped.

The server returns the chain as an ordered list of IDs with a gapCursor if posts are missing, and any parent that is deleted or hidden comes back as a tombstone, so the line never connects two posts that don't actually reply to each other.

Opening at the right place

The post you opened must be at the top of the screen, with its parents above it, reachable by scrolling up. The trap: if parents render after the focal post, they push it down and the reader sees a jump. And when "Show 52 more posts" inserts rows above, everything moves again.

  • Render the parents and the focal post in the same pass, from the store if you came from the timeline (they're probably already there) or from the server HTML for a direct visit. Then scroll the focal post to the top before the first paint, in a layout effect, with scroll-margin-top set to the sticky header's height.
  • When inserting rows above, measure the focal post's getBoundingClientRect().top before the insert and scroll by the difference right after it, before paint. CSS scroll anchoring (overflow-anchor) does this for you in browsers that support it, but don't rely on it alone.
  • Back from a reply's page returns to this page at the same reply, using the same saved scroll anchor as the timeline.

Replies: ranked, paged, and filtered

Replies are ranked by the server. A sensible order puts the author's own continuation (a thread they wrote) first, then replies from people you follow, then the rest by quality. The client shows them in order and pages with a cursor, exactly like the timeline.

Two client rules matter. First, your own new reply appears at the top for you right away, even though ranking might put it elsewhere for others: people need to see that it worked.

Second, replies the server marks as low quality or possibly offensive are not sent in the main list at all. A "Show probable spam" row at the end fetches them with a separate cursor, so most readers never pay for them.

json
{
  "focalId": "1849273648123904000",
  "ancestors": { "ids": ["1849201", "1849250", "1849266"], "gapCursor": "anc:c2" },
  "modules": [
    { "ids": ["1849280", "1849291"], "moreCursor": null },
    { "ids": ["1849302"], "moreCursor": "mod:7f" }
  ],
  "cursor": { "bottom": "rep:91a", "lowQuality": "lq:01" },
  "posts": { "1849273648123904000": { "id": "1849273648123904000", "text": "..." } },
  "users": { "u_ada": { "id": "u_ada", "handle": "ada" } }
}
One conversation response. Every post referenced here is in the posts map, merged into the store.

Moving between conversation pages is cheap. Tap a reply, and its page renders at once from the store: it was in the replies list, and its parents are the post you just left. Only its own replies need a request.

Check your understanding · Exercise 5

Users report that opening a post from the timeline "jumps": the post appears, then slides down half a screen. What are likely causes? Select all that apply.

The composer, part 1: counting characters correctly

The character counter is the smallest part of X's composer and the one most candidates get wrong. Its job: tell the user how much room is left, and disable the Post button exactly when the server would reject the post. If the client says 279 and the server says 281, the user taps Post, waits, and gets an error with no idea what to remove. If it's the other way round, the client blocks a valid post.

The naive version is textarea maxLength={280} and 280 - text.length. Here's what text.length actually counts, next to what a person sees and what X counts:

Text.lengthWhat a person seesX's weighted count
hello55 characters5
é typed as e plus a combining accent211 (normalized to one code point first)
thumbs up emoji212
thumbs up with a skin tone412
family emoji: man, woman, girl joined by zero-width joiners812
日本語336
مرحبا (Arabic)555
a 60-character link606023

Three different ideas are mixed up here. JavaScript strings are sequences of UTF-16 code units: most characters take one, but emoji and many rare characters take two (a surrogate pair).

A code point is one Unicode character number, which is what for...of walks over. A grapheme cluster is what a reader sees as one character: a base plus combining marks, or an emoji built from several code points joined by an invisible zero-width joiner (U+200D). None of them is X's count, which is weighted.

X's rules, which are open source

X publishes its counting rules in the twitter-text library, with versions for JavaScript, Java, Ruby, and Objective-C, and a shared configuration file. In version 3 of the configuration:

  • The text is first normalized to Unicode NFC, so e plus a combining accent becomes the single code point é.
  • Every link counts as 23, however long, because X wraps it in a short t.co link. Links include bare domains like example.com, not only ones starting with https://.
  • Code points in a few "light" ranges weigh 1: U+0000 to U+10FF (Latin, Greek, Cyrillic, Hebrew, Arabic, Devanagari, Thai, and more) and some punctuation ranges such as curly quotes and dashes. Everything else, such as Chinese, Japanese, and Korean, weighs 2.
  • An emoji counts as 2, once, however many code points it's made of: a family emoji or a flag is still 2.
  • The limit is 280 for the weighted total. In practice: 280 Latin letters, or 140 Chinese characters.

Decision How does the composer count?

text.length

Count UTF-16 code units.

Pros

  • Free, built in.

Cons

  • Wrong for emoji, links, accents, and every CJK language.
  • Disagrees with the server constantly.

Grapheme clusters

Count with Intl.Segmenter and granularity: "grapheme".

Pros

  • Matches what a person sees as one character.
  • Built into every current browser.

Cons

  • Still not the server's rule: links, CJK weights, and emoji weights differ.

The server's weighted rules, shared

Run the same twitter-text version and configuration on client and server.

Use grapheme clusters to find emoji, and weights for the rest.

Pros

  • Client and server always agree.
  • One configuration change updates both.

Cons

  • A library of a few tens of KB compressed, much of it the list of domain endings used to find links.
  • Client and server versions must be deployed together.

What we pick: The shared weighted rules. The count is a contract between client and server, so both must run the same code with the same configuration. Load the library with the composer, not with the timeline: most page views never open the composer. The server still validates every post and returns a clear error code, because a client can always be out of date.

Counting the text "Nice 👍🏽". JavaScript length in UTF-16 units: 9. Code points: 7. Grapheme clusters: 6. Weighted count with X's rules: 7 of 280.what the reader sees, with weightsN1i1c1e1space1👍🏽2.length (UTF-16): 9code points: 7graphemes: 6weighted: 7 of 280left: 273
Each box is one thing a reader sees as a character, or one whole link. The number under it is its weight: 1 for light scripts, 2 for emoji and CJK, 23 for any link.
ts
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const LINK = /https?:\/\/\S+/gu; // the real rule also finds bare domains
const LIGHT: [number, number][] = [[0x0000, 0x10ff], [0x2000, 0x200d], [0x2010, 0x201f], [0x2032, 0x2037]];
const isLight = (cp: number) => LIGHT.some(([lo, hi]) => cp >= lo && cp <= hi);
const isEmoji = (s: string) => /^\p{RGI_Emoji}$/v.test(s); // whole emoji: flags, keycaps, skin tones, ZWJ sequences

export function weightedLength(raw: string): number {
  let total = 0;
  const text = raw.normalize("NFC").replace(LINK, () => {
    total += 23;
    return "";
  });
  for (const { segment } of graphemes.segment(text)) {
    if (isEmoji(segment)) total += 2;
    else for (const ch of segment) total += isLight(ch.codePointAt(0)!) ? 1 : 2;
  }
  return total;
}
A simplified weighted count, to show the idea. In production, use the twitter-text library itself.

Showing the count

  • A small ring fills as you type. The number appears only in the last 20 characters, and turns red below zero. Showing "213" all the time is noise.
  • Text past the limit gets a red background, so the user sees what to cut. A plain textarea can't style part of its text, which is one reason composers like X's are contenteditable editors that also color mentions, hashtags, and links. The mention-input problem covers that editor.
  • Screen readers hear the count only at thresholds, through a polite live region: "20 characters left", "0 left", "5 over the limit". Announcing every keystroke would drown out what the user is typing.
  • Counting runs on every keystroke, which is fine for 280 characters. For paid accounts' long posts of up to 25,000 characters, count from a deferred value (useDeferredValue), so typing never waits for the count.

Check your understanding · Exercise 6

A user types 260 Latin characters, the last one a space, then pastes a 90-character article link. The limit is 280. What should the composer do? Select all that apply.

The composer, part 2: threads, media, polls, and drafts

The composer opens as a modal route from anywhere, as an inline box at the top of home, and as the reply box on a post page. It's one component with three placements. What it edits is a draft: a list of one or more posts (a thread), each with text, media or a poll, plus who can reply and, for replies and quotes, the post it points at.

ts
type Draft = {
  id: string;                 // created on the client, also the idempotency key
  posts: DraftPost[];         // one post, or a thread of up to 25
  replyToId?: string;
  quoteId?: string;
  replySettings: "everyone" | "following" | "mentioned";
  scheduledAt?: string;       // follow-up feature, ISO 8601 in UTC
  updatedAt: number;
};

type DraftPost = {
  text: string;
  media: { localId: string; blob?: Blob; mediaId?: string; alt: string; state: UploadState }[];
  poll?: { choices: string[]; minutes: number };  // 2 to 4 choices, 5 min to 7 days
};
A draft. The same shape is kept in memory, in IndexedDB, and sent to the server.

Writing a thread

A "+" button adds another post below the current one, joined by the same vertical line the post page uses. Each post has its own counter and its own media. On X, a thread is just posts where each one replies to the one before it, by the same author. The interesting part is publishing it.

Decision How does the client publish a thread of 6 posts?

Six requests from the client, in order

Create post 1, wait for its ID, create post 2 as a reply to it, and so on.

Pros

  • Uses the existing create-post endpoint.
  • Each request is simple.

Cons

  • Six round trips: about 2 s on a slow connection.
  • If the tab closes or the network drops after post 3, a half thread is public, and the rest is lost unless saved.

One request for the whole thread

Send all six posts in one request with an idempotency key.

The server creates them in order and returns their IDs.

If it fails partway, it reports which were created.

Pros

  • One round trip.
  • The server can validate all six before publishing any.
  • A retry with the same key never doubles posts.

Cons

  • A new endpoint.
  • Partial failure still needs handling, though it's rare.

What we pick: One request for the whole thread. Validation of every post (length, media, links) happens before the first one goes public, which removes the most common partial failure. If something still fails after post 3, the response lists the three created IDs, and the client turns the remaining posts into a draft that continues the thread from post 3, so nothing the user wrote is lost.

Media and alt text

In our scope, a post holds up to 4 images, or 1 video, or 1 GIF, and the composer disables the buttons that would break the rule. Uploading starts the moment a file is attached, not when Post is tapped, so publishing is usually instant. Images are resized on the client to at most 4096 px on the long side in a worker.

Video uses a chunked, resumable upload: X's own media API has init, append, and finalize steps, then a status check while the server processes the video. The file-uploads concept covers the mechanics. Each attachment shows its own progress and a remove button that cancels its upload with AbortController.

Every image has an "Add description" button for alt text: the description a screen reader reads instead of the picture. X allows up to 1,000 characters. Shown images with alt text get a small "ALT" badge that anyone can tap to read it, which helps people with low vision and also teaches others to write it.

A setting can remind the user before posting an image without one. The alt text is sent with the media metadata and rendered as the img element's alt attribute everywhere the image appears.

Polls

A poll replaces media: 2 to 4 choices of up to 25 characters each, and a duration from 5 minutes to 7 days. Voting is optimistic, like a like: the bar fills at once and the percentages appear. Results are shown only to people who voted or after the poll ends, so the client must not receive percentages before a vote, or anyone could read them in the network tab. The vote request returns the current results.

Drafts that survive

  • Autosave to IndexedDB about 500 ms after the user stops typing, keyed by the draft ID. IndexedDB can store Blob values, so attached images survive too; that matters because uploaded media IDs expire on the server after a while and may need uploading again.
  • Closing a composer that has content asks: "Save post?" with Save and Discard. Saved drafts appear in a Drafts list inside the composer.
  • Two tabs can edit the same draft. Each save carries updatedAt, and a BroadcastChannel message tells other tabs to reload it, so the newest edit wins and nobody silently overwrites a longer text with an older one.
  • After a successful post, the draft is deleted. After a failure, it stays, with the error shown on it.
A state machine for a draft. Editing goes to uploading when media is attached, then to ready when uploads finish; with no media it goes straight to ready. Ready goes to posting when the user taps Post. Posting goes to sent on success, or to failed on an error. Failed goes back to ready, and a retry uses the same idempotency key. In every state the draft is saved in IndexedDB.editinguploadingreadypostingfailedsentattachdonePostokerrorretry, same keyevery state: draft in IndexedDBno media: editing goes to readyone idempotency keyper draft
Uploads start on attach, so Post is usually instant. A failure never loses text: the draft stays saved, and a retry reuses the same idempotency key, so it can't post twice.

Publishing, and why it's not optimistic

Likes are optimistic because they almost never fail. Posts are different: the server may reject one for length, for a link to a blocked site, for a rate limit, or for spam. So the composer shows a short "Posting" state, and on success closes and shows a toast, "Your post was sent", with a View link. Then the new post is added to the top of the user's profile and home timeline from the response.

Every create request carries the draft ID as an idempotency key (a unique ID the server remembers for a while): if the network drops after the server saved the post, the retry gets the same post back instead of creating a duplicate.

Check your understanding · Exercise 7

A user posts a thread on a train. The request leaves, the train enters a tunnel, and the client gets a network error. What should the client do? Select all that apply.

Public pages: server rendering, link previews, cache, and purge

Most of the app lives behind a login, but post pages and profile pages don't. Look at who requests /ada/status/1849... in the first minute after Ada's post goes viral: preview bots from Slack, WhatsApp, Discord, iMessage, and Facebook building link cards; search engine crawlers; thousands of logged-out people from a news article; and logged-in users. The bots read only the HTML. Most don't run JavaScript at all, so a client-rendered page shows them an empty div and the card says "X" with no text or image.

Decision How are public post and profile pages rendered?

Client rendering, with prerendering for bots

Serve the normal app shell.

Detect bots by user agent and send them HTML from a headless browser.

Pros

  • One app, no server rendering code.

Cons

  • Logged-out people still wait for the whole app to load before seeing a post.
  • User-agent lists are always incomplete, and Google calls this a workaround, not a long-term solution.
  • Two rendering paths that drift apart.

Server rendering per request, for everyone

Render HTML on every request, with the viewer's personal state.

Pros

  • Fast first paint and full HTML for everyone.

Cons

  • Personalized HTML can't be shared from a cache.
  • A viral post sends every request to the rendering servers.

Server rendering of the public view, cached at the CDN

Render the logged-out view of the page: the same HTML for every visitor.

Cache it at the CDN for a short time.

After hydration, fetch the viewer's personal state (liked, following) and fill it in.

Pros

  • Bots, crawlers, and people all get full HTML fast.
  • A viral post is served almost entirely from the CDN.
  • One rendering path.

Cons

  • Counts in the HTML can be a minute or more old until hydration.
  • Deletes must purge the cache.
  • Personal state appears a moment after the post.

What we pick: Server-render the public view and cache it at the CDN. Every visitor gets the same HTML, so one render serves thousands of requests. Personal parts, like the filled heart and the follow button's state, load after hydration in a small request. Logged-in people use the same URL and the same HTML; the app then takes over and later navigations are client-side.

Who requests a public post page. Four kinds of visitors, a preview bot, a search crawler, a logged-out visitor, and a logged-in member, all get the same HTML from the CDN, which caches it for 60 seconds. On a miss, the CDN asks the edge renderer, which renders and streams the page using data from the API. After hydration, the member's browser asks the API directly for viewer state, such as whether they liked the post.preview botSlack, WhatsAppcrawlersearch enginesvisitorlogged outmemberlogged insame HTML for allCDNHTML, 60 sedge rendererSSR, streamsAPI (BFF)posts, usersmissdataviewer state, after hydration
Everyone gets the same cached HTML, so one render serves thousands of visits. Only logged-in members make a second, small request for their personal state after hydration.

What the HTML head carries

html
<title>Ada Lovelace on X: "Shipping the new parser today..."</title>
<meta name="description" content="Shipping the new parser today. It's 3x faster on long posts.">
<link rel="canonical" href="https://x.com/ada/status/1849273648123904000">
<meta property="og:type" content="article">
<meta property="og:title" content="Ada Lovelace on X">
<meta property="og:description" content="Shipping the new parser today. It's 3x faster on long posts.">
<meta property="og:image" content="https://pbs.example.com/media/F1a2.jpg?name=large">
<meta property="og:url" content="https://x.com/ada/status/1849273648123904000">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" as="image" href="https://pbs.example.com/media/F1a2.jpg?name=small">
The head of a post page. Preview bots read the Open Graph and twitter:card tags; search engines read the rest.
  • The description is the post text, cut at a word boundary to about 200 characters, with HTML escaped. A post containing a quote mark must not break the attribute.
  • og:image is the post's first photo. A text-only post uses the author's avatar, or an image generated on the server with the post text on it, cached by post ID.
  • The canonical URL removes tracking parameters such as ?s=20&t=abc, so search engines see one URL per post instead of thousands.
  • The tags must be in the first chunk of HTML. If the page streams, the head with all meta tags goes out before anything that waits for data, because some bots read only the start of the response.
  • Pages of deleted posts return status 404 and protected accounts return a page that's marked noindex, so crawlers drop them.

Streaming the page

The focal post is the LCP element, so it must be in the first chunk of HTML along with its image preload. Replies need ranking, which is slower, so they sit in a Suspense boundary and stream in a few hundred milliseconds later. On a slow phone, the reader sees the post while the replies are still being computed.

The logged-out page ships only the JavaScript for the post, the replies, and a sign-up prompt; the composer, notifications, and DMs never load for a visitor who will read one post and leave.

Cache headers for a viral post

The HTML for logged-out visitors is sent with Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600. The CDN follows s-maxage=60 and serves the same copy for 60 seconds, then for up to 10 more minutes it can serve the old copy while it fetches a new one in the background (stale-while-revalidate).

Browsers follow max-age=0, so their copy is stale at once. stale-while-revalidate applies to browsers too, so a returning visitor may see their old copy once while a fresh one loads; to keep it at the CDN only, send it in a separate CDN-Cache-Control header (RFC 9213).

Do the math for a viral post at 50,000 requests a minute: with about 100 CDN locations, each asks the origin about once a minute, so the origin sees roughly 100 requests a minute instead of 50,000. Most CDNs also collapse simultaneous misses for the same URL into one origin request.

Don't put the session cookie in the cache key. Vary: Cookie would make every user's copy different and the hit rate would drop to almost zero. Because the HTML is the same for everyone, the cookie doesn't need to be part of it. The exceptions are pages that must never be shared: posts from protected accounts are sent with Cache-Control: private, no-store.

Deletes must actually delete

A user deletes a post they regret. If the CDN keeps serving it for 10 minutes thanks to stale-while-revalidate, that's a trust problem, and sometimes a legal one. So every response is tagged with cache tags (Fastly calls them surrogate keys, Cloudflare calls them cache tags): the post page gets post-1849 and user-ada; the profile page gets user-ada.

Deleting, editing, or withholding a post purges post-1849, which removes every cached page containing it within seconds. Suspending an account purges user-ada. The client also removes the post from the store and from every list at once, as a tombstone.

A sequence diagram of deleting a post, with four columns: Ada, Origin, CDN, and Visitor. Ada deletes post 1849. The origin purges the cache tag post-1849 at the CDN and confirms to Ada. A visitor then requests the post page. The CDN has no copy, so it asks the origin, and the visitor gets a 404 page saying the post is gone.AdaOriginCDNVisitordelete post 1849purge post-1849deletedGET the postmiss: ask origin404, post gone
Every cached page that shows the post carries the tag post-1849, so one purge removes them all within seconds, without waiting for s-maxage or stale-while-revalidate to run out.

Check your understanding · Exercise 8

Logged-out post pages are cached with s-maxage=60, stale-while-revalidate=600. Which statements are true? Select all that apply.

Live counters without hammering the server

Under every post sit four numbers: replies, reposts, likes, and views. On a viral post they change thousands of times a second. People like seeing them move, but nobody needs every change. The question is how fresh to make them, and at what cost.

The naive design gives each post component its own timer: setInterval(() => fetch("/posts/" + id), 5000). With 20 posts on screen, that's 4 requests a second, 240 a minute, from one tab. Multiply by 10 million open tabs and it's 40 million requests a second for numbers nobody looks at closely.

The opposite naive design pushes every like on every visible post. A post getting 3,000 likes a second would send each of its 200,000 viewers 3,000 messages a second, which melts both the servers and the phones.

Decision How do counts on visible posts stay fresh?

Poll each post

Every post component fetches its own counts on a timer.

Pros

  • Trivial to build.

Cons

  • Requests grow with the number of posts on screen.
  • Keeps polling posts that scrolled away unless carefully cleaned up.
  • Most responses say nothing changed.

Push every change

Subscribe to each visible post over a WebSocket or SSE.

The server sends an event for every like.

Pros

  • Exact, instant counts.

Cons

  • Message rate follows the post's popularity, unbounded on viral posts.
  • Phones spend battery and main-thread time on updates nobody reads.

Batched, coalesced snapshots of what's visible

The client keeps the set of post IDs on screen and tells the server when it changes.

The server sends one snapshot of changed counts for that set at most every 10 to 15 s.

Without a live connection, the same contract works as one batched poll every 20 s.

Pros

  • Cost per tab is fixed, whatever the posts' popularity.
  • Nothing is sent for hidden tabs or posts off screen.
  • Works over the connection already open for notifications.

Cons

  • Counts are up to 15 s old.
  • The server must track each connection's visible set.

What we pick: Batched, coalesced snapshots of the visible set. Counts are a "roughly now" feature, so freshness of 10 to 15 s is enough, and the cost per tab becomes a small constant. The app already holds one server-sent events connection for notifications and the new-posts count; the visible-set subscription rides on it. If that connection can't be opened, the same request runs as a batched poll.

Posts on screen
Live counter cost with Poll each post. 20 posts on screen, tab visible, no viral post. Per tab per minute: 240 requests and 0 pushed messages. Across ten million tabs: about 40M requests and messages per second. Freshness: 5 s.posts on screenAPI server40M per secondper tab, per minuterequests: 240pushed: 0ten million tabs40M per secondfreshness5 s
Every visible post polls every 5 s. Cost grows with the posts on screen, and a naive timer keeps running in a hidden tab.

Knowing what's visible

  • One shared IntersectionObserver watches every rendered post and keeps a Set of visible IDs. When the set changes, a debounce of about 1 s sends the new set, so fast scrolling doesn't send 50 updates.
  • When the tab is hidden (document.visibilityState === "hidden", from the Page Visibility API), the client sends an empty set or stops polling. When it becomes visible again, it asks for one fresh snapshot right away.
  • After 5 minutes with no input, the interval doubles. Someone who left a tab open overnight shouldn't cost the same as someone reading.
  • Every timer has jitter: a random 0 to 20% added to the interval. After a deploy or an outage, millions of clients reconnect at the same moment. Without jitter their polls stay in sync forever, and the server sees a spike every 20 s.

Merging with the user's own taps

Ada taps like. The heart fills and the count goes from 1,204 to 1,205 instantly. Half a second later a snapshot arrives that was computed before her like reached the server: 1,204, liked false.

If the client applies it, the heart empties and then fills again a second later. That flicker makes people tap again, which unlikes.

ts
type LikeState = {
  server: { likes: number; liked: boolean };  // last snapshot from the server
  pending?: { liked: boolean };               // set on tap, cleared when the request settles
};

function displayed({ server, pending }: LikeState) {
  if (!pending || pending.liked === server.liked) return server;
  return { liked: pending.liked, likes: server.likes + (pending.liked ? 1 : -1) };
}
Keep the server's snapshot and the pending change apart, and compute what to show.

Snapshots only ever replace server. The tap only sets pending. What the user sees is computed from both, so a stale snapshot can't undo a tap, and a snapshot that already includes the like makes pending redundant without a visible change. When the like request succeeds, the counts in its response become the new server value and pending is cleared; snapshots carry a version, so one computed before the like can't overwrite that newer value.

Showing numbers

Counts are formatted with Intl.NumberFormat(locale, { notation: "compact" }): 1,204 shows as "1.2K" in English, and 12,000 shows as "1.2万" in Japanese, which groups large numbers by ten thousands. Compare the formatted string before re-rendering: 1,204 and 1,231 both show "1.2K", so the update changes nothing on screen and the render can be skipped.

When the string changes, a short roll animation draws the eye; with prefers-reduced-motion: reduce it just swaps. Screen readers get the exact number in the button's label, such as "1,231 likes. Like", not "1.2K".

Check your understanding · Exercise 9

Your counters use a batched poll every 20 s for the visible posts. After a big outage, the server team sees a sharp spike of requests every 20 seconds that never smooths out. What's the best fix? Pick one.

Rate limits and abuse, as seen by the UI

An open social network is attacked all day: spam accounts post thousands of replies, scrapers download profiles, and people try to break the page with strange text. Most defenses are on the server, but each one shows up in the client as a state the UI must handle well. A good rule for all of them: never lose what the user wrote, and always say what happened and what they can do.

Rate limits

The server limits how often an account can post, follow, like, or read. When a limit is hit, it answers 429 Too Many Requests, often with a Retry-After header that gives the seconds to wait or a date. Some APIs also report the budget on every response: X's public API sends x-rate-limit-limit, x-rate-limit-remaining, and x-rate-limit-reset (a Unix time in seconds), and an IETF draft standardizes RateLimit and RateLimit-Policy headers.

In July 2023, X put temporary daily limits on how many posts accounts could read, and many people saw an empty timeline with "Rate limit exceeded", a useful story: a limit on reading should never blank what's already on screen.

Decision The user taps Post and gets a 429. What does the composer do?

A generic error toast

Show "Something went wrong" and close the composer.

Pros

  • No extra work.

Cons

  • The user doesn't know why or when to try again.
  • If the composer closes, the text may be lost.

Retry automatically in the background

Wait for Retry-After and send again without telling the user.

Pros

  • Works for short, temporary limits.

Cons

  • Limits on posting can last hours; a silent retry looks like a hang.
  • The user may post the same thing elsewhere meanwhile.

An explicit limited state

Keep the composer open with the draft saved.

Say which limit was hit and when it resets, in the user's local time.

Disable Post until then and offer "Save to drafts".

Pros

  • Nothing is lost, and the user knows what to expect.
  • No requests are wasted while the limit lasts.

Cons

  • Each limit needs a clear message, which the server must provide in the error body.

What we pick: The explicit limited state. The error body carries a machine-readable code (post_limit) and the reset time, and the client turns it into a sentence like "You've reached the posting limit. You can post again at 14:32." For reads, the timeline keeps everything it loaded, shows a message at the bottom instead of the loading spinner, and stops asking until the reset time.

What happens after the user taps Post. From ready, the composer goes to posting. Posting ends in one of four ways: 201, sent; 429, limited until 14:32 with the draft kept; 403, a challenge dialog, after which the request is repeated; or a server error, where the draft is kept with a Retry button.readypostingPost201: sent429: limited to 14:32403: challenge5xx: keep draftevery branch keeps the draftand says what to do next
Every branch except success keeps the draft and tells the user what happened and what to do next. None of them retries a write silently.
ResponseMeaningWhat the UI does
429 with Retry-AfterToo many requestsLimited state with the reset time. No automatic retry for writes.
403 with a challengeLooks automatedOpen a challenge dialog, then repeat the original request. The draft stays.
403 reply restrictedThe author limited who can replyDisable the reply box: "People @ada mentioned can reply".
404 on a postDeleted, or hidden from youTombstone in the store; the post page shows "This post doesn't exist".
5xx or network errorTemporary failureRetry reads with exponential backoff and jitter. Writes keep the draft and offer Retry.

The client limits itself too, so it never causes the limits it then reports. Post and Follow buttons disable while their request is in flight, so a double tap sends one request. A like tapped five times in a second sends only the final state. Retries for reads use backoff with jitter and give up after a few tries.

Rendering text that attackers wrote

  • Post text is never inserted as HTML. The server returns plain text plus entities: the ranges of mentions, hashtags, and links. The client cuts the text at those ranges and builds React elements, so a post that contains a script tag shows it as text. No dangerouslySetInnerHTML anywhere in post rendering.
  • Links show the readable URL but point at the short t.co link, so the server can check for malware at click time and warn. They open with rel="noopener noreferrer nofollow ugc": ugc and nofollow tell search engines not to pass ranking to links from user content, which removes the reason to spam them.
  • Display names can contain direction-changing characters, such as U+202E (right-to-left override), which reverses the text after it. Without isolation, the override leaks out of the name and can reorder the handle and text next to it. Render every display name inside a bdi element, which isolates its direction so the effect ends with the name. The name itself can still look misleading, so many services strip these characters from names; handles are limited to ASCII letters, digits, and underscores, so the handle is the reliable identity.
  • Sensitive media is blurred, with a "Show" button, unless the viewer chose to see it. The blur is applied before the image loads, using a small blurred preview, so the real image is never flashed.

Blocks, mutes, and reply controls

Blocking or muting an account, or muting a word, removes matching posts across the app. The server filters future responses, but what's already loaded must react now. Because posts live in one store, the client can mark every post by that author as hidden in one pass, and every list skips hidden IDs.

Inside a conversation, a blocked author's reply becomes a placeholder, "This post is from an account you blocked", with a View button, so the thread stays readable. Muted words are matched on the client against loaded posts with the same normalization the server uses: lowercase and NFKC, so full-width letters can't sneak past.

Check your understanding · Exercise 12

Which practices protect users when rendering posts and names? Select all that apply.

Architecture, data, and API

With the hard parts decided, the architecture almost draws itself. Each box below exists because a chapter needed it, and each boundary is placed so that something expensive can load late or be shared.

The X web client's architecture. Inside the browser: the app shell above the route pages, which read the entity store; the composer, which keeps drafts in IndexedDB and hands files to the upload worker; the live channel, which writes counts and notifications into the entity store; and a service worker for push and the cached shell. Outside: the CDN serves HTML from the edge renderer, the API sends server-sent events to the live channel, and the media API receives uploads from the worker.Browserapp shellrouter, nav, badgeroute pageslazy chunksentity storeposts, users by IDservice workerpush, shell cachecomposerlazy, on intentupload workerresize, chunkslive channelone SSE streamIndexedDBdraftsCDNpublic HTMLedge rendererSSRAPI (BFF)REST + SSEmedia APIuploads
Every view reads one store, and every outside service talks to one part of the client. The composer and its upload worker load only when someone starts to write.
PartResponsibilityWhy it's separate
App shellRouter, navigation, sidebar, unread badge, modal routesStays mounted, so navigation swaps only the main column
Route pagesHome, post, profile, search, notifications, each a lazy chunkA visitor reading one post never downloads the rest
Entity store and query cachePosts and users by ID; pages of IDs by query keyOne copy of each post for every shape
Live channelOne SSE connection: counts, notifications, new-post countsOne connection per tab instead of one per feature
Composer moduleEditor, counting library, drafts, polls, thread publishLoaded on first open or idle time, not with the timeline
Upload workerResizing images, chunked video uploadKeeps heavy work off the main thread
Draft storeDrafts and media blobs in IndexedDBSurvives closed tabs and crashes
Edge rendererServer HTML for public post and profile pagesBots and logged-out people need HTML; the CDN caches it
Service workerWeb push, cached app shell for repeat visitsWorks when no tab is open

Where each piece of data lives

DataLives inWhy there
Posts, users, notification groupsNormalized store, in memoryShared by every view; rebuilt from the server
Timeline, search, conversation pagesQuery cache, by key such as ["search", q, tab]Each list has its own cursor and staleness
Route, search query, tabURLShareable, reloadable, Back works
Scroll anchorsMemory, keyed by history entryRestored on Back; meaningless in a new session
Drafts and media blobsIndexedDBLarge, must survive closing the tab
Selected home tab, cached recent searcheslocalStorageSmall preferences needed on first render
Composer text while typing, open menusComponent stateOnly one component cares
Where the client's data lives. In memory: posts and users in the normalized store, pages of IDs in the query cache that point into it, and scroll anchors. In the URL: the route, the search query, and the tab. In IndexedDB: drafts with their media blobs. In localStorage: the selected home tab and cached recent searches. In component state: the composer text while typing.memoryposts, usersnormalized storepages of IDsquery cache by keyscroll anchorsURLroute, q, tabshareableIndexedDBdrafts + blobssurvive closinglocalStoragehome tab, recentstiny preferencescomponent statecomposer textone owner
Each piece lives where its lifetime and its sharing fit: memory for what's rebuilt from the server, the URL for what defines a page, IndexedDB for work that must survive, component state for what one component owns.

The API

The client talks to a backend for frontend (BFF): a thin server layer owned by the web team that shapes responses for this client. Every list response returns IDs plus an entity map, so the store merges entities once. Writes are small, specific requests, and every create carries an idempotency key.

http
GET    /api/timeline/for_you?cursor=c1         -> TimelinePage
GET    /api/posts/1849.../conversation?cursor=  -> Conversation
GET    /api/users/ada/posts?tab=media&cursor=   -> UserPostsPage
POST   /api/posts      Idempotency-Key: d_7f3   { posts: [...], replyToId?, quoteId? }
DELETE /api/posts/1849...
PUT    /api/posts/1849.../like                  (DELETE to unlike)
POST   /api/polls/p_77/votes                    { choice: 2 }
POST   /api/media/upload                        init, append, finalize
GET    /api/search?q=css&f=live&cursor=
GET    /api/notifications?tab=all&cursor=
PUT    /api/live/visible                        { ids: ["1849...", ...] }
GET    /api/live                                text/event-stream
The main endpoints. All lists use opaque cursors.
text
event: counts
data: {"1849273648123904000":{"likes":1231,"reposts":88,"replies":40,"views":51022}}

event: notification
data: {"groupId":"g_like_1849","type":"like","total":41,"actorIds":["u_lin","u_ada"],"at":"2026-10-19T08:01:12Z"}

event: new_posts
data: {"timeline":"following","count":12}

HTTP/1.1 429 Too Many Requests
Retry-After: 5400
{"error":{"code":"post_limit","message":"Posting limit reached","resetAt":"2026-10-19T09:32:00Z"}}
Messages on the live channel, and the shared error format.

Server-sent events fit the live channel: every message goes from server to client, the browser reconnects on its own with EventSource, and the client's few messages (like the visible set) are ordinary requests. Over HTTP/2 the stream shares one connection with all other requests. A user with five tabs open still holds five streams; a follow-up improvement is to elect one tab as the leader with the Web Locks API, keep the only stream there, and pass messages to the other tabs with BroadcastChannel.

Check your understanding · Exercise 13

Why is the composer a separate, lazily loaded module rather than part of the home timeline's chunk? Select all that apply.

Every language, every reader

X is used in dozens of languages, and a single screen often mixes several: an English interface, an Arabic post, a Japanese reply, and a handle in Latin letters inside the Arabic text. Accessibility has the same flavor: the app must work for someone using only a keyboard, a screen reader, or a phone at 200% zoom. Both are much cheaper to build in from the first component than to retrofit.

Right-to-left layouts

Arabic, Hebrew, Persian, and Urdu are written right to left. When the interface language is one of them, the root element gets dir="rtl" and the whole layout mirrors: navigation on the right, the sidebar on the left, avatars on the right of each post. That happens for free only if the CSS never says left or right.

The same screen twice. On the left, a left-to-right interface: navigation on the left, the main column, the sidebar on the right. An English post starts at the left with its avatar on the left; an Arabic post in it runs right to left because it has dir auto. On the right, a right-to-left interface: everything mirrors, with navigation on the right and avatars on the right, and an English post in it still runs left to right.interface: ltrnavAEnglishstarts leftBArabicdir=auto: rightsideinterface: rtlsideAArabicstarts rightBEnglishdir=auto: leftnavlogical CSS mirrorsthe layout for freeeach post: dir=autoits own direction
Logical CSS such as margin-inline-start mirrors the layout for free. The text of each post gets dir="auto", so its direction comes from its own words, not from the interface.
  • Use logical properties everywhere: margin-inline-start instead of margin-left, padding-inline-end, inset-inline-start, text-align: start. Flex and grid rows already follow the direction.
  • Mirror icons that point in a direction of travel, such as Back arrows and "show more" chevrons, with a rule on :dir(rtl). Don't mirror icons that show real objects or time, such as the heart, the play button, or a clock.
  • Swipe gestures flip too: swiping to the next photo goes the other way.

Mixed scripts inside one post

The interface direction says nothing about a post's direction. An English user sees Arabic posts; an Arabic user sees English ones. So every post's text element gets dir="auto": the browser picks the direction from the first strong character of that text. The server also detects the post's language, and the client puts it in a lang attribute, so the right font is chosen, a screen reader switches to the right voice, and "Translate post" can appear when the post's language differs from the user's.

Inside a right-to-left post, a mention like @ada_dev, a link, or a number is left-to-right. The Unicode bidirectional algorithm handles most of this, but punctuation next to the boundary can land on the wrong side, so a post reading "ask @ada!" in Arabic can show the exclamation mark in a strange place.

Wrapping each entity in a bdi element isolates it, which is the same fix used for display names in the abuse chapter. It also helps dir="auto": text inside bdi is skipped when the direction is picked, so a reply that starts with @ada_dev still gets the direction of its first real word. The composer gets dir="auto" too, so it switches direction as the user starts typing Arabic.

  • Plurals need ICU message format, not count === 1. Arabic has six plural forms: zero, one, two, few, many, and other. "41 others liked your post" is a message with a plural, not a string with a number pasted in.
  • Short ages ("2h", "3d") come from Intl.NumberFormat with style: "unit" and unitDisplay: "narrow", phrases like "2 hours ago" from Intl.RelativeTimeFormat, dates from Intl.DateTimeFormat, and counts from Intl.NumberFormat with compact notation. Some locales use their own digits; let Intl decide.
  • Translations load per route chunk and per language, so a French user on the post page downloads only the French strings that page needs.
  • A custom brand font covers Latin only, declared with unicode-range. Other scripts fall back to the system's fonts, which are already on the phone and cost nothing to download.

Structure and keyboard

  • Landmarks: nav for the main navigation, main for the column, aside for the sidebar, so screen reader users can jump between them. Each page has one h1, even if it's visually hidden.
  • Each post is an article, labelled by its author and time. The timeline follows the WAI-ARIA feed pattern: a role="feed" container, aria-setsize="-1" because the length is unknown, aria-posinset on each article so virtualization doesn't confuse the count, and aria-busy="true" while a page loads.
  • X has single-key shortcuts: j and k for next and previous post, l to like, r to reply, n for a new post, / for search, and ? to list them. They must be ignored while typing in a field, and WCAG asks that single-character shortcuts can be turned off, because speech input users can trigger them by accident.
  • Every action button has a label with its count, such as "41 replies. Reply", and toggles like Like and Bookmark use aria-pressed. Targets are at least 24 by 24 CSS pixels, the WCAG 2.2 minimum, and ideally 44.

Things that change

  • Route changes in a single-page app are silent to screen readers. After each navigation, move focus to the new page's heading, or announce the page title in a polite live region.
  • Modal routes focus the dialog's first control on open and return focus to the opener on close, as in the routing chapter.
  • Live updates are announced sparingly. The new-posts pill is announced once ("23 new posts"). Counter updates are never announced; their labels just hold the latest numbers when the user gets there.
  • Media: alt text on every image, captions on videos, videos and GIFs that don't autoplay when the user prefers reduced motion, and a visible pause control on anything that moves.

Check your understanding · Exercise 14

An English-interface user sees an Arabic post that mentions @ada_dev and ends with "!". The mark shows up on the wrong side of the handle. Which fixes are right? Select all that apply.

Performance budgets on a cheap phone

The performance target is a phone, not a laptop: think of a 150 dollar Android phone with 3 GB of memory and a CPU four to six times slower than a developer's laptop, on a 4G connection with 100 ms or more of round trip time. A performance budget is a set of limits the team agrees on and checks in CI, so the app doesn't get slower one harmless-looking feature at a time.

BudgetLimitHow it's kept
JavaScript before home is usable150 KB compressedRoute chunks; composer, search, and media viewer load on demand; a CI check fails the build above the limit
Logged-out post page JavaScript80 KB compressedOnly post rendering, replies, and a sign-up prompt; no composer, no live channel
LCP on the post pageUnder 2.5 sFocal post in the first HTML chunk; image preloaded with fetchpriority="high"
INP on like, reply, open postUnder 200 msOptimistic updates; no long tasks over 50 ms during scrolling
CLSUnder 0.1Media boxes sized from width and height sent by the API; trends and banners have reserved space
DOM size on the timelineUnder about 1,500 elementsVirtualization: about 20 posts rendered at once
Memory after 30 minutesUnder 300 MBEvict entities no list references; drop decoded images far from the screen

The JavaScript budget is the one that matters most, because on a slow phone each kilobyte costs twice: once to download and again to parse and run, on the same main thread that must answer taps. Twitter learned this early. Its 2017 progressive web app, Twitter Lite, was built around a small initial bundle, route splitting, and a service worker, and it became the default mobile web experience.

  • Split by route and by intent. Each page is a chunk. The composer, the emoji picker, the GIF search, and the video player load when first opened, and are prefetched on hover, focus, or idle time.
  • Keep the main thread free. Image resizing and upload hashing run in a worker. Analytics are batched and sent with sendBeacon. Third-party scripts are kept out of the timeline route.
  • Images by size. The API offers each photo in several sizes; the client uses srcset and sizes so a 360 px wide phone downloads a small version. Images below the fold get loading="lazy".
  • Fewer re-renders. A like changes one post entity; only components that read that post re-render, thanks to fine-grained subscriptions to the store. A live counts snapshot that changes nothing visible doesn't render at all.
  • Clean up long sessions. When the store holds 3,000 posts, remove those no cached list references any more. Unloaded media elements release their decoded bitmaps.
  • Measure real users. Collect LCP, INP, and CLS from real sessions with the web-vitals library, split by device class and route, and alert when the 75th percentile crosses a budget.

Check your understanding · Exercise 15

After a release, INP on the home timeline goes from 150 ms to 420 ms on low-end Android phones, but stays the same on desktop. What are likely causes? Select all that apply.

Running it in the interview

"Design Twitter" is usually a 45 to 60 minute question. There is far more here than fits, so the skill being tested is choosing. Here's a plan for 60 minutes; for 45, shorten each step and pick two deep dives instead of three.

MinutesWhat you doWhat you draw
0 to 6Scope questions; agree on the screens and the numbersThe numbers in a corner: 2.5 s, 200 ms, 0.1, 150 KB, 280
6 to 12App shell, route map, modal routesThe shell wireframe and a list of URLs
12 to 20Architecture and the normalized storeBoxes for shell, pages, store, live channel, composer, edge renderer
20 to 26Data model and API, brieflyThe Post type, one list response, the SSE messages
26 to 50Two or three deep dives the interviewer cares aboutThe flow for each: post page, counter, SSR and purge
50 to 60Performance, accessibility, languages; then questionsThe budget table, briefly

For the deep dives, offer a short menu instead of guessing: "I can go deep on the post page and reply trees, the composer and character counting, public pages with caching and purge, or live counters. Which matters most to you?" Interviewers often have one in mind, and asking costs ten seconds. If they let you choose, pick public pages and the composer: they show the widest range, from HTTP caching to Unicode.

  • Say early that the timeline is one part and you'll keep it short. If the interviewer wants the feed in depth, they'll say so, and you can switch to the news-feed design.
  • Draw the shell before the boxes. Interviewers asking about an app want to see that you think in screens and URLs, not only in components.
  • For each deep dive, use the same rhythm: the naive way, the scenario where it breaks with a number, the options, your choice and why, then one edge case.
  • Name the numbers out loud when choosing: "20 visible posts, 10 million tabs, one request each every 5 seconds, that's 40 million a second, so we batch."
  • Keep a list of what you're skipping on the board, and come back to it at the end. It shows you didn't forget it.

Follow-up questions to expect

Think first

How would you support editing a post after it's published?

Think first

What happens when the user loses the connection for two minutes?

Think first

A post is getting a million views a minute. What breaks first on the frontend side, and what do you do?

Think first

Power users keep 8 X tabs open. What would you change?

Summary

  • Twitter is an app, not a feed. Cover the timeline briefly and spend the depth on routing, the post page, the composer, public pages, and live data.
  • Put overlays like the composer and photo viewer on modal routes with a background location: shareable URLs, a working Back button, and a timeline that never unmounts.
  • Keep one copy of each post in a normalized store. Reposts point at the original, quotes are their own posts, and deleted posts become tombstones that every shape can render.
  • IDs are strings: 64-bit IDs lose precision as JavaScript numbers.
  • The post page is ancestors, the focal post, and ranked reply modules. Depth becomes navigation, so every page has a bounded size.
  • Count characters with the server's weighted rules, shared on both sides: links count as 23, emoji as 2, CJK as 2. text.length is wrong for almost everything.
  • Publish threads in one request with an idempotency key, and keep drafts with their media in IndexedDB until the server confirms.
  • Server-render the logged-out view of public pages, cache it briefly at the CDN, load personal state after hydration, and purge by cache tag on delete.
  • Make live counters cost a constant per tab: batch the visible set, coalesce on the server, pause hidden tabs, add jitter.
  • Use logical CSS, dir="auto", and bdi for mixed scripts; follow the feed pattern and announce sparingly; budget JavaScript for a cheap phone.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next