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.
| Question | Answer we assume | What 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.
| Property | Drives | Chapter |
|---|---|---|
| One post, many shapes | Normalized entities, small view components, tombstones | Posts in many shapes |
| Pages stack | Modal routes, kept-alive timeline, scroll restoration | App shell and routing |
| Logged-out traffic | Server rendering, meta tags, CDN cache and purge | Public pages |
| Weighted text | Shared counting rules, grapheme clusters | The composer |
| A bit live | Batched, visibility-aware updates | Live counters |
| Hostile users | Rate-limit states, safe rendering, reply controls | Abuse and limits |
| Slow phones | JavaScript budget, route splitting, virtualization | Performance |
Think first
An interviewer says: "Assume the timeline is solved. What's left?" What would you list, in order of risk?
Public post pages first, because they carry the most traffic per URL, must work without JavaScript for bots, and a mistake there is visible to everyone, including search engines. Then the composer, because a wrong counter or a lost draft directly breaks trust. Then routing and the post page, because they decide whether the app feels like one app or a pile of pages.
Counters, search, notifications, and trends are real work but lower risk: each can start simple (polling, a basic list) and improve without changing the rest of the design.
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 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
| URL | Page | Kind | Rendered |
|---|---|---|---|
/home | Home timeline, two tabs | Page | Client, behind login |
/ada | Profile, tabs as /ada/with_replies, /ada/media | Page | Server for logged-out, then client |
/ada/status/1849... | Post detail with conversation | Page | Server for logged-out, then client |
/ada/status/1849.../photo/2 | Photo viewer | Modal over the post or timeline | Client |
/compose/post | Composer | Modal over any page | Client |
/search?q=css&f=live | Search results, tab in f | Page | Client |
/notifications | Notifications, tab for mentions | Page | Client, behind login |
/explore | Trends and topics | Page | Client |
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.
Overlays that have their own URL
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?
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.
/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.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>
)}
</>
);
}
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
cursorstring, 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
ResizeObserverand 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.
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.
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>;
};
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.
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?
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.
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";
};
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 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?
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-topset to the sticky header's height. - When inserting rows above, measure the focal post's
getBoundingClientRect().topbefore 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.
{
"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" } }
}
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 | .length | What a person sees | X's weighted count |
|---|---|---|---|
hello | 5 | 5 characters | 5 |
é typed as e plus a combining accent | 2 | 1 | 1 (normalized to one code point first) |
| thumbs up emoji | 2 | 1 | 2 |
| thumbs up with a skin tone | 4 | 1 | 2 |
| family emoji: man, woman, girl joined by zero-width joiners | 8 | 1 | 2 |
日本語 | 3 | 3 | 6 |
مرحبا (Arabic) | 5 | 5 | 5 |
| a 60-character link | 60 | 60 | 23 |
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
eplus a combining accent becomes the single code pointé. - Every link counts as 23, however long, because X wraps it in a short
t.colink. Links include bare domains likeexample.com, not only ones starting withhttps://. - 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?
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.
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;
}
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
textareacan't style part of its text, which is one reason composers like X's arecontenteditableeditors that also color mentions, hashtags, and links. Themention-inputproblem 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.
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
};
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?
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
Blobvalues, 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 aBroadcastChannelmessage 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.
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?
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.
What the HTML head carries
<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 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:imageis 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.
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?
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.
Knowing what's visible
- One shared
IntersectionObserverwatches every rendered post and keeps aSetof 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.
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) };
}
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.
Search: typeahead and four kinds of results
Search on X has two parts: a box with suggestions as you type, and a results page with tabs. The box is an autocomplete, which has its own guide (autocomplete): debounce keystrokes by about 150 ms, cancel stale requests with AbortController, cache results by prefix, and follow the WAI-ARIA combobox pattern so arrow keys and screen readers work. Here we focus on what's particular to X.
What the typeahead suggests
- Recent searches, shown the moment the box gets focus, before any typing. They're stored on the server per account, so they follow the user across devices, and cached locally so they show with no request. Each has a remove button.
- People: avatar, name, handle, and a verified badge. Typing
@restricts suggestions to people only. - Queries and topics: popular searches that start with what you typed. Typing
#restricts to hashtags. - Operators are passed through untouched:
from:ada,since:2026-01-01,filter:media,min_faves:100. Power users type them, and the box must not "correct" them. A small hint row can explain an operator once its prefix is typed.
Choosing a person goes straight to their profile; it's a navigation, not a search. Choosing a query or pressing Enter goes to the results page, with src=typed_query or src=recent_search in the URL so analytics can tell the paths apart.
The results page and its tabs
The results page is /search?q=css+grid&f=live. Everything that defines the results is in the URL: the query and the tab, so a search can be shared, bookmarked, reloaded, and reached with Back. The four tabs are four different lists that happen to share a query.
| Tab | URL | Shape of results | Live behavior |
|---|---|---|---|
| Top | no f, the default | Ranked posts, with a "People" module of 3 accounts near the top | None: ranked lists don't grow at the top |
| Latest | f=live | Posts newest first, like the Following timeline | A "See new posts" pill, like the home banner |
| People | f=user | User cells with bio and follow button | None |
| Media | f=media | A 3-column grid of thumbnails | None |
q and f. Each tab is a separate list with its own cache key, cursor, and scroll position.- Each tab is its own query with its own key,
["search", q, tab], its own cursor, and its own scroll anchor. Switching back to a tab shows its cached results at once. - Changing the query pushes a history entry. Switching tabs replaces it, so Back returns to the previous search instead of stepping back through tabs.
- Don't prefetch all four tabs on load: most people never leave Top, and each tab is a ranked query that costs real server time. Prefetch a tab when the user hovers or focuses it, which gives 100 to 300 ms of head start for free.
- Media is a grid of fixed-size square thumbnails, so it can be virtualized by rows with known heights, which is easier than the timeline. Tapping a thumbnail opens the photo modal route over the grid.
- Normalize the query before using it as a key: trim and collapse spaces. Keep the case and the operators as typed.
Search results show posts from people the user doesn't follow, so filters apply: muted words, blocked accounts, and the sensitive content setting. These are applied on the server, but the client must pass the right settings and, for sensitive media that is shown, render it behind a warning that needs a tap.
Check your understanding · Exercise 10
Which of these belong in the search results URL? Select all that apply.
Notifications and trends
Notifications are where people go to see reactions to what they posted. A popular post gets thousands of likes; listing each one as a row would make the page useless. So most notifications are grouped: "Ada, Lin, and 39 others liked your post".
Grouping rules
- Likes and reposts group by type and by post: all likes on post P become one row, showing the first 2 or 3 avatars and "and 39 others".
- Follows group by type: "Ada and 5 others followed you".
- Replies, mentions, and quotes are never grouped. Each one has new text and may need an answer, so each gets its own row with the full post.
- A group stays open for a while, for example until the user has seen it, or for a day. A like after that starts a new group, so a week-old post doesn't jump to the top every time someone likes it.
Decision Who groups notifications, the client or the server?
What we pick: The server. Grouping needs all the events and must look the same on every device, which only the server can guarantee. The client renders groups and updates them live.
New notifications while you're on the page
New notifications arrive on the same server-sent events connection the app already holds. Each message is either a new group or an update to an existing one, identified by its group ID: a new actor, a new total, a new time.
If the user is not on the notifications page, the client only updates the unread badge. If they are on the page and at the top, the updated group moves to the top. If they are scrolled down, nothing moves; a pill says "New notifications", exactly like the timeline banner.
- The unread count shows on the bell, in the page title as "(3) Home / X", and on the app icon of an installed web app with
navigator.setAppBadge(3). - Opening the page marks everything read by sending the newest group ID seen. A
BroadcastChannelmessage clears the badge in other open tabs, and the server clears it on other devices. - When no tab is open, web push through a service worker delivers notifications to the system tray. Ask for permission after the user does something that shows intent, like turning on notifications for an account, not on the first visit: a prompt on load is mostly denied, and a denial can't be asked again.
- The Mentions tab is a separate, ungrouped query: only replies and mentions, for people who get too many likes to find the conversations.
Trends
Trends are a short list in the right sidebar on desktop and in Explore on phones: "Trending in India", a name like #WorldCup, and a label like "125K posts". The server computes them per location and interest. For the client they're simple, and the design is mostly about staying out of the way:
- Load them after the main column: they're never the reason someone opened the page, so they must not compete with the timeline or post for bandwidth or main-thread time.
- Reserve their space with a fixed-height placeholder of the same row heights, so they never cause layout shift.
- Cache them for a few minutes and refresh in the background. Trends change over tens of minutes, not seconds.
Check your understanding · Exercise 11
Ada is reading her notifications, scrolled halfway down. A new like arrives on a post that already has a group 30 rows up. What should the client do? Select all that apply.
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?
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.
| Response | Meaning | What the UI does |
|---|---|---|
429 with Retry-After | Too many requests | Limited state with the reset time. No automatic retry for writes. |
403 with a challenge | Looks automated | Open a challenge dialog, then repeat the original request. The draft stays. |
403 reply restricted | The author limited who can reply | Disable the reply box: "People @ada mentioned can reply". |
404 on a post | Deleted, or hidden from you | Tombstone in the store; the post page shows "This post doesn't exist". |
5xx or network error | Temporary failure | Retry 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
dangerouslySetInnerHTMLanywhere in post rendering. - Links show the readable URL but point at the short
t.colink, so the server can check for malware at click time and warn. They open withrel="noopener noreferrer nofollow ugc":ugcandnofollowtell 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
bdielement, 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.
| Part | Responsibility | Why it's separate |
|---|---|---|
| App shell | Router, navigation, sidebar, unread badge, modal routes | Stays mounted, so navigation swaps only the main column |
| Route pages | Home, post, profile, search, notifications, each a lazy chunk | A visitor reading one post never downloads the rest |
| Entity store and query cache | Posts and users by ID; pages of IDs by query key | One copy of each post for every shape |
| Live channel | One SSE connection: counts, notifications, new-post counts | One connection per tab instead of one per feature |
| Composer module | Editor, counting library, drafts, polls, thread publish | Loaded on first open or idle time, not with the timeline |
| Upload worker | Resizing images, chunked video upload | Keeps heavy work off the main thread |
| Draft store | Drafts and media blobs in IndexedDB | Survives closed tabs and crashes |
| Edge renderer | Server HTML for public post and profile pages | Bots and logged-out people need HTML; the CDN caches it |
| Service worker | Web push, cached app shell for repeat visits | Works when no tab is open |
Where each piece of data lives
| Data | Lives in | Why there |
|---|---|---|
| Posts, users, notification groups | Normalized store, in memory | Shared by every view; rebuilt from the server |
| Timeline, search, conversation pages | Query cache, by key such as ["search", q, tab] | Each list has its own cursor and staleness |
| Route, search query, tab | URL | Shareable, reloadable, Back works |
| Scroll anchors | Memory, keyed by history entry | Restored on Back; meaningless in a new session |
| Drafts and media blobs | IndexedDB | Large, must survive closing the tab |
| Selected home tab, cached recent searches | localStorage | Small preferences needed on first render |
| Composer text while typing, open menus | Component state | Only one component cares |
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.
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
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"}}
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.
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-startinstead ofmargin-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.NumberFormatwithstyle: "unit"andunitDisplay: "narrow", phrases like "2 hours ago" fromIntl.RelativeTimeFormat, dates fromIntl.DateTimeFormat, and counts fromIntl.NumberFormatwith compact notation. Some locales use their own digits; letIntldecide. - 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:
navfor the main navigation,mainfor the column,asidefor the sidebar, so screen reader users can jump between them. Each page has oneh1, 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: arole="feed"container,aria-setsize="-1"because the length is unknown,aria-posinseton each article so virtualization doesn't confuse the count, andaria-busy="true"while a page loads. - X has single-key shortcuts:
jandkfor next and previous post,lto like,rto reply,nfor 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.
| Budget | Limit | How it's kept |
|---|---|---|
| JavaScript before home is usable | 150 KB compressed | Route chunks; composer, search, and media viewer load on demand; a CI check fails the build above the limit |
| Logged-out post page JavaScript | 80 KB compressed | Only post rendering, replies, and a sign-up prompt; no composer, no live channel |
| LCP on the post page | Under 2.5 s | Focal post in the first HTML chunk; image preloaded with fetchpriority="high" |
| INP on like, reply, open post | Under 200 ms | Optimistic updates; no long tasks over 50 ms during scrolling |
| CLS | Under 0.1 | Media boxes sized from width and height sent by the API; trends and banners have reserved space |
| DOM size on the timeline | Under about 1,500 elements | Virtualization: about 20 posts rendered at once |
| Memory after 30 minutes | Under 300 MB | Evict 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
srcsetandsizesso a 360 px wide phone downloads a small version. Images below the fold getloading="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-vitalslibrary, 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.
| Minutes | What you do | What you draw |
|---|---|---|
| 0 to 6 | Scope questions; agree on the screens and the numbers | The numbers in a corner: 2.5 s, 200 ms, 0.1, 150 KB, 280 |
| 6 to 12 | App shell, route map, modal routes | The shell wireframe and a list of URLs |
| 12 to 20 | Architecture and the normalized store | Boxes for shell, pages, store, live channel, composer, edge renderer |
| 20 to 26 | Data model and API, briefly | The Post type, one list response, the SSE messages |
| 26 to 50 | Two or three deep dives the interviewer cares about | The flow for each: post page, counter, SSR and purge |
| 50 to 60 | Performance, accessibility, languages; then questions | The 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-feeddesign. - 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?
On X, an edit creates a new version with its own ID, and the old ID points to the newest. The client resolves any ID to its latest version through the store, shows "Last edited" with a link to the history, and keeps showing the original's counts if the server carries them over.
Quotes of the old version still show the version they quoted, with a note that a newer version exists, because the quote was written about that text. The public page for the old ID redirects or links to the newest version, and the edit purges both IDs' cache tags.
Think first
What happens when the user loses the connection for two minutes?
Reading keeps working on what's loaded; the store and the cached pages are in memory, and the service worker serves the app shell on reload. Loading more shows an inline "You're offline" row instead of a spinner, and retries when the online event fires.
Likes made offline are queued and sent in order when the connection returns, with their final state only. Posting keeps the draft in IndexedDB with a "Will post when you're back online" note, sent with its idempotency key. The live channel reconnects on its own, and the client asks for one fresh snapshot of counts and notifications.
Think first
A post is getting a million views a minute. What breaks first on the frontend side, and what do you do?
The public post page: every view is a request for its HTML. With the CDN caching the logged-out HTML for 60 seconds and collapsing misses, the origin sees about one request per CDN location per minute, so it holds. The replies are the expensive part to compute, which is why they stream after the focal post and can be served from a slightly older ranking.
The counters don't break, because their cost is per tab, not per post. Without coalescing, a million viewers each receiving every like would be the first thing to fall over.
Think first
Power users keep 8 X tabs open. What would you change?
Eight tabs means eight live connections, eight visible-set subscriptions, and eight notification badges that can disagree. Elect one leader tab with the Web Locks API; it holds the only SSE connection and forwards messages to the others with BroadcastChannel. When the leader closes, its lock is released and another tab takes over.
Hidden tabs already send an empty visible set, so their counter cost is zero. Drafts and read markers sync through the same channel, so marking notifications read in one tab clears the badge in all of them.
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.lengthis 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", andbdifor 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.
References
- twitter-text: X's open-source parsing and counting library The weighted counting rules and their configuration, shared by client and server.
- Intl.Segmenter (MDN) Splitting text into grapheme clusters, words, and sentences.
- The Open Graph protocol The meta tags that preview bots read to build link cards.
- RFC 5861: HTTP Cache-Control extensions for stale content Where
stale-while-revalidateis defined. - Dynamic rendering as a workaround (Google Search Central) Why serving bots separate HTML is not a long-term solution.
- Feed pattern (WAI-ARIA Authoring Practices) Roles, keys, and states for an endless list of articles.
- Inline markup and bidirectional text in HTML (W3C) When to use
dir="auto"andbdifor mixed-direction text. - Page Visibility API (MDN) Pausing live updates in hidden tabs.
- Interaction to Next Paint (web.dev) How INP is measured and how to improve it.
- 429 Too Many Requests (MDN) The rate-limit status and its
Retry-Afterheader.
Concepts behind it
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- Short pollingThe browser asks the server "anything new?" on a timer: the simplest way to keep a page fresh, and the right answer more often than people think.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.