cf.completefrontend
My progress
SYSTEM DESIGN · #87

Design a blogging platform like Medium

A long-form reading and publishing platform where most visitors arrive from search or social links to read one article, a few become members, and a smaller group writes. The hard parts are serving the article page from the edge while still showing each reader their own claps, highlights, and paywall meter, keeping a 3,000-word page fast and readable, metering free reads in a way that can't be bypassed but still lets search engines index the text, anchoring highlights to text that authors later edit, and a writing flow with autosave, previews, scheduling, and email delivery.

The question

Design the web front end of a blogging platform like Medium. Writers publish long articles with images and embeds. Readers find them through search, social links, and a home feed. Readers can clap, highlight passages, and leave responses. Some articles are for paying members only, and non-members get a few free ones a month.

Focus on the article page: how it reaches the reader fast, how it stays readable, how the paywall works, and how highlights and claps work. Then cover the writing and publishing flow at a high level.

Real-life examples: Medium, Substack, Hashnode, dev.to, Ghost

Requirements: a reading product with a writing tool attached

A blogging platform looks like one product, but it serves three groups with very different needs. Readers arrive on one article from Google, X, LinkedIn, or a newsletter, read it, and leave; most are not logged in. Members pay a monthly fee, read many articles, clap, highlight, and follow writers. Writers draft, edit, and publish.

Readers outnumber writers by orders of magnitude, so the article page is the product. The editor matters, but it serves a small, logged-in, patient audience.

Before drawing boxes, ask the questions whose answers change the design. The table lists them, the answer this guide assumes, and what moves if the interviewer says something else.

Scope questions for a blogging platform and how each answer moves the design.
QuestionAnswer we assumeWhat it changes
Where do readers come from?Most article views come from outside: search, social links, and email. Many are first-time, anonymous visitors on phones.The first view must be fast with a cold cache and no login. Search engine optimization (SEO) and link previews are core features, not extras.
How many articles, how many reads?Tens of millions of published articles. A popular one gets hundreds of thousands of reads in a day; most get a handful (estimate).A long tail means we can't keep every page warm in a cache. Popular pages must survive spikes without hitting the origin, our own application servers.
Is there a paywall?Yes. Some stories are member-only. Non-members get a small number of free member-only reads per month.The server must decide per request how much text to send, which fights with edge caching: keeping one shared copy of each page on servers near readers.
What can readers do on a page?Clap, highlight, add a private note, respond, follow the writer, save to a reading list, share.Each of these is per-viewer data. It can't live in the cached HTML.
What does the editor need?Rich text with images, code blocks, and embeds; autosave; preview links; scheduling; publishing to a publication or newsletter.We reuse a rich text editor and focus on the publishing lifecycle around it.
Which devices?Phones are the majority of reads; desktop is the majority of writing (estimate).The reading layout is designed mobile first. The editor can assume a keyboard.

Functional requirements

  • Read an article: title, subtitle, author, publish date, reading time, hero image, body with images, code, quotes, and embeds.
  • Clap for an article, up to a per-reader limit, and see the total.
  • Highlight a passage, optionally add a private note, and see passages that many readers highlighted.
  • Hit a paywall on member-only stories after the free reads run out, with a clear path to subscribe or sign in.
  • Follow writers and publications, save articles to a reading list, and read saved articles offline.
  • Write: draft with autosave, preview through a private link, schedule or publish, send to email subscribers, edit after publishing.
  • Share: links that render rich previews on social apps, and links that jump to a quoted passage.

Non-functional requirements

  • Fast first view on a mid-range phone over 4G: Largest Contentful Paint (LCP) of 2.5 seconds or less at the 75th percentile, which is the Core Web Vitals good threshold.
  • No layout shift while reading: Cumulative Layout Shift (CLS) of 0.1 or less, even with images and embeds loading late.
  • Responsive interactions: Interaction to Next Paint (INP) of 200 ms or less for clap, highlight, and menus.
  • Search friendly: the article text is in the server HTML, with correct structured data (a machine-readable description of the article), canonical URLs (the one official address of each page), and sitemaps.
  • Edits show up fast: a published edit reaches readers within seconds to a minute, not hours.
  • The paywall holds: a non-member can't read a locked story by turning off JavaScript or editing CSS.
  • Accessible: proper landmarks and headings, keyboard access to every action, alt text on images, reduced motion respected.

Out of scope, and where it's covered

Several nearby problems already have their own guides. Mention them in the interview, then move on. The editor's document model, contenteditable, the toolbar, and paste handling are in the rich-text-editor guide, and Markdown with live preview is the markdown-editor guide.

Threaded responses are the comments-thread guide, and the ranked home feed is the news-feed guide. Image upload is the file-uploader guide, and mentions in the editor are the mention-input guide. Payment checkout, recommendation ranking, and writer payouts are backend problems and we leave them out.

Check your understanding · Exercise 1

Which answers to scope questions would most change the design of the article page, select all that apply? Select all that apply.

What makes this hard

A blog page looks like the easiest thing on the web: some text and a few pictures. Four properties of this product turn it into a real design problem, and every later chapter answers one of them.

  1. Same page, different readers. The article text is identical for everyone, which makes it ideal for a shared cache. But the page also shows whether you clapped, what you highlighted, whether you follow the writer, and how many free reads you have left. One page, two kinds of data with opposite caching needs.
  2. Long pages on phones. A typical story is 1,500 to 4,000 words with 5 to 15 images and a couple of embeds (estimate). Each image or embed that loads without reserved space shoves the text down while someone is reading. Each third-party embed can pull in hundreds of KB of script.
  3. Text that is both the product and the paywall. Search engines must see the full text to rank it. Non-members must not get the full text for free. Doing both without breaking Google's rules on cloaking is subtle.
  4. Text that changes under readers' marks. Readers highlight sentences. Writers fix typos, add paragraphs, and delete sections after publishing. A highlight saved as "characters 1,204 to 1,288" points at the wrong words after the first edit.

A quick estimate shows why the reader path dominates. Say a popular story gets 300,000 reads in its first day, mostly in the first few hours. That's a peak around 20 to 50 requests per second for one URL (estimate).

If each read needs a server render plus five database queries for claps, follows, and highlights, one story keeps a whole server busy. If the HTML comes from an edge cache, the origin sees a few requests per minute for that page, and only the small per-reader calls reach it.

Think first

Before reading on: if you could only cache one thing for an anonymous reader, what would it be, and what would you have to remove from it first?

Getting the article to the reader: static at the edge, personal on the side

Start with the naive version. The article route is server-rendered on every request: look up the post, look up the viewer's clap count, follow state, highlights, and meter, then render one HTML page. Then a story gets shared by a big account. Every one of the 30 requests per second runs the full render and five queries, the origin's p95 response time (the time 95 percent of requests beat) climbs from 150 ms to two seconds, and LCP for every reader goes with it.

The fix is to notice that the expensive part, the article, is the same for everyone, and the personal part is small. So split them along that line and cache the shared part as close to readers as possible, on a CDN (content delivery network) whose servers sit near them. The ssg-and-isr and cdn-caching lessons explain the general tools, and the wikipedia guide applies them to another read-heavy article site; here's how they apply to this page.

Decision How should the article HTML reach an anonymous reader?

Client-side rendering

Serve one app shell from the CDN.

The browser downloads the bundle, then fetches the article as JSON and renders it.

Pros

  • The shell caches perfectly.
  • One code path for logged-in and anonymous readers.

Cons

  • Nothing to read until the bundle and the JSON both arrive: two or three round trips on a phone.
  • Link preview bots and some crawlers don't run JavaScript, so shares show a blank card.
  • Worse LCP, because the hero image URL is only known after the JSON arrives.

Server render on every request

Each request goes to the origin, which renders the article plus the viewer's data into one page.

Pros

  • Always fresh.
  • Simple mental model: one request, one complete page.

Cons

  • Cost grows with reads, not with edits.
  • A viral story lands on the origin.
  • Per-viewer data in the HTML means the page can never be shared by a cache.

Statically generated, cached at the edge, revalidated on publish

Render the article once per version and store the HTML at the CDN with a long lifetime.

When the writer publishes or edits, the server marks that page stale or purges it.

Per-viewer data is fetched by the client after the page shows, from small uncached endpoints.

Pros

  • Most reads are served from a CDN node near the reader, with a time to first byte of tens of ms.
  • Origin load grows with edits, which are rare.
  • Fresh within seconds, because publishing drives freshness, not a timer.

Cons

  • Needs reliable invalidation for every place an article appears.
  • Per-viewer state appears a moment after the text.
  • The paywall needs extra care, because it changes how much body text is in the HTML.

What we pick: Pick the static, edge-cached page revalidated on publish, with personal data fetched on the side. A blog is read thousands of times per edit, and its freshness is driven by an event (publish, edit, unpublish), not by a clock. The cost is a second small request for per-viewer state, which we make cheap and invisible.

Data flow for an article view. The browser sends GET for the story to the edge cache, which returns cached HTML. On a miss, the edge asks the renderer at the origin, which renders HTML once per version. The article HTML hydrates small islands for claps and highlights. The islands send GET viewer and PUT claps to the private viewer API, which returns the viewer state, about 2 KB. When a writer publishes, the publish service purges the post's tag at the edge cache.BrowserCDN edgeOriginArticle HTMLstatic, readableIslandsclaps, highlightsEdge cacheper variantRendereronce per versionPublishrevalidate tagsViewer APIprivate, no-storeGETHTMLmisspurge taghydrateGET viewer, PUT clapsViewerState, 2 KB
Two paths with opposite caching: the article HTML is the same for everyone and comes from the edge, while one small private request carries everything about this reader. Publishing is the only thing that refreshes the cached copy.

How the cache stays fresh

There are two ways to keep a cached article fresh. Time-based revalidation says "this page is good for N seconds", for example Cache-Control: public, s-maxage=60, stale-while-revalidate=86400. The CDN serves the cached copy, and after 60 seconds the next request triggers a refresh in the background while still getting the old copy.

On-demand revalidation says "this page is good until I tell you otherwise": the page is cached with a very long lifetime, and the publish handler invalidates it. We use on-demand as the main path and a long time-based lifetime as a safety net, in case an invalidation message is lost.

In Next.js this maps to cache tags. The article's data fetch is tagged with post:8f3a2c and author:41. When the writer publishes an edit, the server action calls revalidateTag for the post tag.

Next.js marks that cached data stale: the next request still gets the old page while a fresh render runs, then later readers get the new one. Next.js recommends revalidateTag(tag, "max") for exactly this kind of content, where a few seconds of staleness is fine. When the writer must see the edit immediately on their own redirect, updateTag in a server action expires the entry so the writer's next request waits for the fresh version.

ts
// app/[username]/[slug]/page.tsx
import { cacheTag } from "next/cache";

async function getPost(id: string) {
  "use cache";
  const post = await db.posts.findPublished(id);
  cacheTag(`post:${id}`, `author:${post.authorId}`);
  return post;
}

// app/editor/actions.ts
"use server";
import { revalidateTag } from "next/cache";

export async function publish(postId: string) {
  const post = await db.posts.publish(postId);
  // The article page, the author's profile page, and every
  // publication or tag page that lists this post share the tag.
  revalidateTag(`post:${postId}`, "max");
  revalidateTag(`author:${post.authorId}`, "max");
  await cdn.purgeByTag([`post:${postId}`]); // if a CDN sits in front
}
Tag the article data, then revalidate every page that shows it when the writer publishes.

Why tags and not paths? One article appears on many pages: its own URL, the author's profile, publication and tag pages, the RSS feed. With paths, the publish handler must know every URL that shows the post. With tags, each page declares what it shows and the handler invalidates one name.

If a separate CDN sits in front, use its tag purge (Fastly surrogate keys, Cloudflare cache tags) so the edge copy is dropped too. Fastly also offers a soft purge, which marks the tagged objects stale instead of deleting them, the same trade as the max profile.

The personal layer

After the cached HTML shows, one client request fetches everything about this viewer and this post: GET /api/posts/8f3a2c/viewer. It returns my clap count, whether I follow the author, my highlights and notes, whether the post is in my reading list, and the meter state. It's Cache-Control: private, no-store, it's tiny (1 to 3 KB), and it doesn't block reading.

The UI is designed so that its arrival never moves layout. The clap button already has its final size and only changes its number and color, and highlights paint as background marks on text that's already there.

Signed-out readers skip the call: no session cookie, nothing personal to fetch. The stack-overflow guide uses the same split for question pages.

Sequence diagram of publishing an edit. The writer sends publish to the server. The server calls revalidate tag on the edge cache, which marks the story stale, and replies published to the writer. A reader sends GET story to the cache, which returns the stale HTML at once and asks the server to re-render. The server returns fresh HTML to the cache. The next reader's GET gets the fresh HTML.WriterServerEdge cacheReaderpublishrevalidate tagpublishedGET storystale HTMLre-renderfresh HTMLnext GET: fresh
Invalidation marks the page stale instead of deleting it, so no reader ever waits for a render. One reader sees the old version once; everyone after sees the edit, and the origin renders once per edit, not once per view.

Check your understanding · Exercise 2

A writer fixes a typo in a published story that is listed on their profile page and two tag pages. Which statements about invalidation are correct? Select all that apply.

A long page that loads fast, holds still, and reads well

An edge-cached page fixes time to first byte. It doesn't fix what happens after: a 400 KB hero image, two web fonts, twelve inline images, a YouTube video, a GitHub Gist, and an embedded post. Each one can hurt a Core Web Vital. The core-web-vitals, image-optimization, fonts, and third-party-scripts lessons go deep on each tool; this chapter is about the choices specific to a long article.

Wireframe of the article page. A header with logo, search, and Write. The story title as the h1. A byline with the author, a Follow button, and 6 min read. A hero image, the LCP element. The body text in a serif column of about 65 to 75 characters. An embed shown as a click-to-load facade with a 16 by 9 box. A clap bar with the clap count, responses, and save. Notes say the title, body, and images are cached HTML for everyone; Follow, claps, and highlights are per-viewer islands; and images and embeds have reserved space.medium.com/@ana/story-8f3a2cheader: logo, search, WriteStory title (h1)Ana, 6 min readFollowhero image: LCPbody: serif, 55chembed facade, 16:9clap 1.8K, respond, savecached HTML,same for everyoneper viewer:follow, claps,highlightsreserved space:width and heightlazy below fold,facade on click
Most of the page is cached HTML that's identical for every reader. Only the small marked islands change per viewer, and every image and embed has its box reserved before it loads, so nothing shifts while someone reads.

LCP: the hero image or the title

On most stories the LCP element is the hero image; on stories without one, it's the title or the first paragraph. For the image case, three things matter. The image URL must be in the HTML, not discovered by JavaScript. It gets fetchpriority="high" and no loading="lazy", so the browser fetches it ahead of other images.

And it's served in a size that fits the screen: a srcset with widths like 400, 800, 1200, and 1600 in AVIF or WebP, with sizes set to the article column width. A 1600-pixel JPEG at 380 KB becomes an 800-pixel AVIF of around 60 KB on a phone (estimate). On a slow 4G link, that saving alone can move LCP from poor to good.

Fonts: the serif body text

Long-form sites set the body in a serif and the UI in a sans: with bold and italic, often five or six font files. If the LCP text waits for a font, LCP waits too. Self-host WOFF2 files, preload only the regular body weight, and use font-display: swap with a fallback whose metrics are adjusted (size-adjust, ascent-override, descent-override) to match the web font. Then the swap from Georgia changes letter shapes but barely moves line breaks, so CLS stays near zero.

CLS: reserve space for everything

The editor knows every image's size at upload time, so the stored document keeps width and height for each image block, and the renderer writes them as attributes. Browsers turn those into an aspect-ratio, so the box is the right height before a single byte of the image arrives. Embeds get the same treatment: the server stores the provider's aspect ratio (16:9 for YouTube) or a measured height for a Gist, and the placeholder box uses it. Images below the first screen get loading="lazy" and decoding="async".

Decision How should third-party embeds like YouTube, Gist, and social posts load?

Embed the provider's iframe or script directly

Render the provider's iframe or script tag where the writer placed it.

Pros

  • Exactly what the provider intended.
  • No extra code on our side.

Cons

  • Each embed pulls in its own scripts, often hundreds of KB, competing with our page for bandwidth and main thread time.
  • Script-based embeds inject content of unknown height, which shifts the text.
  • Third-party cookies and trackers load for every reader, even those who never press play.

Native lazy-loaded iframes

Same iframes with loading="lazy", in boxes with a fixed aspect ratio.

Pros

  • One attribute; embeds far below the fold don't load until the reader gets close.
  • Fixed boxes stop most layout shift.

Cons

  • Embeds near the top still load in full.
  • Readers who scroll past an embed still pay for it.
  • Doesn't help script-based embeds like social posts.

Click-to-load facades

At publish time, fetch the provider's metadata (title, thumbnail, size) through oEmbed, the standard API embed providers offer for this, and store it.

Render a static preview: thumbnail, title, a play button, all from our CDN.

On click, swap in the real iframe, with autoplay for video so one click plays.

Pros

  • Zero third-party bytes until the reader asks.
  • The preview has a known size, so no shift.
  • Privacy: no third-party requests for readers who don't interact.

Cons

  • On some phones a video needs a second tap: the first loads the player, and the browser may block autoplay.
  • We must keep the stored metadata fresh, and handle deleted videos.
  • Some embeds, like interactive charts, lose their point as a static image.

What we pick: Use facades for video and social embeds, and render code Gists as static highlighted code from the stored source with a link to the original. Fall back to lazy iframes for embed types we don't support yet. Chrome's performance guidance recommends facades for exactly these embeds (Lighthouse flagged them with an audit until version 13), and on a reading page almost nobody plays every video.

For INP, the risk on a long page is hydration: if the whole 4,000-word article is a React tree, hydrating it (running the components again in the browser to attach event handlers to the server HTML) costs real main thread time on a cheap phone. Keep the body as static server HTML and hydrate only the islands, the small interactive parts: clap bar, follow button, highlight toolbar, responses drawer. The islands-and-partial-hydration lesson shows how.

Check your understanding · Exercise 3

A story has a hero image, eleven inline images, and three YouTube embeds. Its LCP is 3.8 s and CLS is 0.24 on phones. Which changes would help? Select all that apply.

Typography: the page people stay on

A reading platform succeeds only if people finish stories, so typography is a main feature here. Interviewers rarely ask about it, which is why naming the numbers stands out.

  • Measure (line length): 45 to 75 characters per line is the classic range, with about 66 often called ideal. Shorter lines break the reading rhythm; longer ones make the eye lose its place on the return sweep. WCAG 1.4.8 (level AAA) caps text blocks at 80 characters. Set the column in ch rather than pixels, so it tracks the font size. One ch is the width of the digit 0, which is wider than the average letter, so a column of N ch holds about 1.2 to 1.4 times N characters of real text: max-width: 55ch gives roughly 65 to 75 in common serifs. Check the count with the actual font.
  • Size: about 18 px body on phones and 20 to 21 px on desktop for a serif. Body text below 16 px on a phone forces zooming.
  • Line height: around 1.5 to 1.6 for body text, tighter (1.2) for headings. WCAG 1.4.8 asks for at least 1.5 within paragraphs.
  • Paragraph spacing: a clear gap between paragraphs, larger before headings than after, so a heading visually belongs to the text below it.
  • Dark mode: follow prefers-color-scheme by default and let readers override it with a stored choice. Use off-white on dark gray rather than pure white on pure black, which causes halation (a glow that blurs the letters) for many readers. Don't invert images.
  • Zoom: everything in rem and ch, so a reader who sets a larger default font size gets a larger, still well-measured column.
css
.article-body {
  max-width: 55ch; /* about 65 to 75 characters in a serif */
  margin-inline: auto;
  padding-inline: 1.25rem;
  font-family: "Body Serif", "Body Serif Fallback", Georgia, serif;
  font-size: clamp(1.125rem, 1rem + 0.5vw, 1.3125rem);
  line-height: 1.6;
}
.article-body p + p { margin-block-start: 1.5em; }
.article-body h2 { margin-block: 2.2em 0.6em; line-height: 1.2; }
.article-body pre { max-width: 100%; overflow-x: auto; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --text: #e6e6e6;
    --bg: #121212;
  }
}
The article column: width in characters, size scaled between phone and desktop.

Reading time

Medium's help center describes its estimate as word count divided by about 265 words per minute, plus an adjustment for images. Medium has described that adjustment as 12 seconds for the first image, one second less for each next image, and 3 seconds for every image from the tenth on. For Chinese, Japanese, and Korean, count characters, not spaces: Medium uses about 500 characters per minute.

Compute it once at publish time on the server, store it on the post, and render it into the cached HTML. Don't compute it in the browser: the number must match on the feed card, the article page, the email, and the link preview, and the paywall preview doesn't have the full text anyway.

ts
function readingMinutes(wordCount: number, imageCount: number) {
  let imageSeconds = 0;
  for (let i = 0; i < imageCount; i++) {
    imageSeconds += Math.max(12 - i, 3);
  }
  const seconds = (wordCount / 265) * 60 + imageSeconds;
  return Math.max(1, Math.round(seconds / 60));
}
// 1,500 words + 5 images: 340 s + 50 s = 390 s, shown as "6 min read"
Reading time at publish: words at 265 per minute, plus a shrinking allowance per image.

The progress indicator

A thin bar at the top that fills as you read helps readers see how far they are, and it's a classic place to waste main thread time. A scroll listener that sets a width on every event causes layout work on every frame.

Better: drive it with a CSS scroll-driven animation where supported, which the browser can run off the main thread. Use a named view timeline on the article element rather than scroll() on the page, so the responses section and footer don't count. Elsewhere, fall back to a passive listener that updates a transform: scaleX() once per animation frame. The bar is decorative, so hide it from assistive technology with aria-hidden.

Check your understanding · Exercise 4

Which typography choices make a long article easier to read, select all that apply? Select all that apply.

The paywall and the meter

Member-only stories show a preview to non-members: the title, the opening paragraphs, and a box asking them to sign in or subscribe. On Medium, writers in the Medium Partner Program choose which stories go behind the paywall, and they earn a share of membership fees based on how members engage with those stories. Medium also gives non-members a small number of free member-only stories each month. The exact number has changed over the years and isn't clearly documented, so treat it as a config value, FREE_READS_PER_MONTH, not a constant.

Google calls this pattern flexible sampling and describes two kinds: metering, a monthly quota of free articles, and lead-in, showing the opening of a story before the paywall. Medium uses both. This chapter answers three questions: where the meter is counted, how the locked text is kept from non-members, and how search engines still index the whole story.

The naive build is common: send the full article, count reads in localStorage, and when the count runs out, cover the text with a gradient and a modal. Then someone opens a private window and the meter is full again. Someone else turns off JavaScript and reads everything. Reader modes and "remove paywall" extensions do this automatically for millions of people.

Decision Where should the free-read meter be counted?

In the browser (cookie or localStorage)

Store a list of read story ids for this month on the device.

Client code decides whether to show the paywall.

Pros

  • No server work, works with a fully cached page.
  • Fine for anonymous readers, who have no account to count against.

Cons

  • Reset by clearing storage or opening a private window.
  • Different count on each device.
  • The decision runs in code the reader controls.

On the server, per account

Signed-in readers have a meter row: month, list of unlocked story ids.

The edge or origin checks and records the unlock before choosing what to send.

Pros

  • Can't be reset from the browser.
  • Same count on every device.
  • Idempotent: opening an already-unlocked story again doesn't use another read.

Cons

  • Requires sign-in to get free reads, which adds friction for new visitors.
  • Every member-only view by a free reader needs a fast meter lookup, at the edge or near it.

Hybrid: anonymous soft meter, account hard meter

Anonymous readers get a meter in a signed cookie, accepting that it can be reset.

Signed-in readers use the server meter.

When the cookie meter runs out, the prompt is "sign up to keep reading".

Pros

  • Low friction for first-time visitors from search.
  • The real limit sits behind an account, where it can be enforced.
  • The anonymous meter is a funnel into accounts, which is its actual job.

Cons

  • Two code paths to test.
  • Some anonymous readers will reset the cookie; the business has to accept that.

What we pick: Count on the server per account for signed-in readers. For anonymous readers, pick per business rule: either preview only, which pushes readers to create an account, or a soft cookie meter as a sign-up funnel. Either way, the decision about what text to send is made on the server, never in client code.

Decision How should the locked part of the story be kept from non-members?

Send everything, hide with CSS and JavaScript

The cached HTML has the full text.

Client code adds an overlay and clips the body when the meter says no.

Pros

  • One cached page for everyone.
  • Instant unlock after subscribing, no reload.

Cons

  • Bypassed by turning off JavaScript, reader mode, view-source, or an extension.
  • Google's own guidance says that if content must not reach the browser, the paywall must not send it.

Render per request on the origin

The origin checks entitlement and renders either the preview or the full story, with Cache-Control: private.

Pros

  • Nothing locked ever reaches a non-member.
  • Simple logic in one place.

Cons

  • Member-only stories lose edge caching entirely.
  • Origin load grows with reads again.

Two cached variants, chosen at the edge

Render each member-only story twice: a preview variant (opening paragraphs plus the paywall box) and a full variant.

Both are cached at the edge, keyed by URL plus variant, never by user.

Edge middleware picks the variant per request from a signed membership claim, a meter check, or a verified crawler.

Pros

  • The locked text is never in the HTML a non-member gets.
  • Both variants stay cacheable; only a tiny decision runs per request.
  • Members get the same speed as free stories.

Cons

  • The edge must verify a signed token and, for free readers, call a meter service.
  • Two variants to invalidate on every edit (one tag covers both).

What we pick: Use two cached variants chosen at the edge. Server-side truncation is the only lock that holds, and choosing between two cached copies keeps the edge-cache economics from the delivery chapter. Never add Vary: Cookie: every reader has a different cookie, so it turns one shared cache entry into one per reader. Compute the variant at the edge and make it part of the cache key.

Try both lock methods below. Pick a reader, open stories until the free reads run out, and watch which paragraphs are actually in the HTML, and which tricks would reveal them.

Reader
Lock

Pick a reader, then press Open story to count a view.

Paywall lab for story-1. Reader: free. Lock: edge variants. Paragraphs 1 to 3 are the free preview. Paragraphs 4 to 8 are sent and shown. meter: 3 of 3 left. entitled: nothing to bypass.HTML sent for story-1p1p2p3p4p5p6p7p8free previewsent and shownmeter: 3 of 3 leftstory-1: not unlockedentitled: nothingto bypasscache key: URL +variant fullisAccessibleForFree:false, on every variant
What the server sends is the paywall. A paragraph that was never sent can't be revealed by any trick in the browser.

The decision at the edge

Members carry a short-lived signed token (for example a JSON Web Token, or JWT, valid for 15 minutes, refreshed by the app) that says member: true. The edge checks the signature with a public key: no network call. Free signed-in readers need one call to a meter service in a fast key-value store near the edge: "unlock story X for user U this month if allowed". It's idempotent: if X is already in U's unlocked set, it returns yes without spending a read, so re-opening a story never costs twice.

ts
async function chooseVariant(req: Request, post: PostMeta) {
  if (!post.memberOnly) return "full";
  const claim = await verifyMemberToken(req); // signature only, no I/O
  if (claim?.member) return "full";
  if (await isVerifiedGooglebot(req)) return "full"; // DNS check, cached
  if (claim?.userId) {
    const r = await meter.unlock(claim.userId, post.id, monthKey());
    return r.allowed ? "full" : "preview";
  }
  return "preview";
}
// Cache key: URL + variant. Response to the browser: private, no-store,
// because the choice depended on who asked.
Edge middleware: pick a cached variant, never send locked text to a non-member.

Letting search engines index the full story

If Googlebot gets the preview, the story ranks on three paragraphs. If Googlebot gets the full text and readers get a preview, that can look like cloaking: showing crawlers different content than users, which Google's spam policies forbid. Google's answer is structured data for paywalled content.

The page marks itself isAccessibleForFree: false and lists the locked sections with hasPart, each a WebPageElement with isAccessibleForFree: false and a cssSelector that points at a class wrapping the locked text. Google's guidelines say to use only class selectors and not to nest the sections. This tells Google the difference is a paywall, not deception.

json
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "What I learned shipping a design system",
  "image": ["https://cdn.example.com/p/8f3a2c/hero-1200.jpg"],
  "datePublished": "2026-03-02T08:00:00Z",
  "dateModified": "2026-03-04T10:12:00Z",
  "author": { "@type": "Person", "name": "Ana Ruiz", "url": "https://example.com/@ana" },
  "isAccessibleForFree": false,
  "hasPart": {
    "@type": "WebPageElement",
    "isAccessibleForFree": false,
    "cssSelector": ".paywall"
  }
}
Paywall markup on a member-only story. The locked body sits inside an element with class paywall.

The crawler check must be real. Anyone can send a User-Agent header that says Googlebot, so a user-agent check alone is a free bypass.

Google documents two ways to verify. One is a reverse DNS lookup on the IP, then a forward lookup on the returned host name that must give back the same IP; for Googlebot the host name ends in googlebot.com. The other is matching the IP against Google's published IP ranges for its common crawlers.

Accept only the Googlebot answer for the full variant. Google's list also has googleusercontent.com hosts, but those are fetches that sites running on Google Cloud make for their own users, so anyone could route a request through one. Do the check at the edge and cache the result per IP for a few hours.

Sequence diagram of the meter for a signed-in free reader with one free read left. The reader sends GET story X to the edge. The edge asks the meter service to unlock X for this user. The meter answers ok, 0 left, and the edge returns the full variant. Later the reader sends GET story Y. The edge asks the meter to unlock Y, the meter answers denied, 0 left, and the edge returns the preview variant.ReaderEdgeMeterGET story Xunlock(U, X)ok, 0 leftfull variantGET story Yunlock(U, Y)denied, 0 leftpreview variant
The edge decides which cached copy to send, so the locked text of story Y never reaches this reader. unlock is idempotent: opening X again this month returns ok without spending a read.

One more door through the paywall is deliberate. Medium lets writers share a friend link that opens a member-only story for anyone. Build it as a signed token in the URL: an HMAC (a keyed hash that only our servers can compute) of the post id and a key version. The edge treats a valid token as entitlement for that one story, rel=canonical points at the clean URL so the token is never indexed, and bumping the key version revokes every friend link at once.

Check your understanding · Exercise 5

Which paywall designs let a free reader with no reads left get the full text of a member-only story, select all that apply? Select all that apply.

Highlights and notes: anchoring marks to text that changes

A reader selects a sentence, a small toolbar appears, they tap Highlight, and the sentence turns green. Next week they come back and it's still green. Then the writer fixes a typo in the first paragraph, adds a new section, and rewrites the highlighted sentence slightly.

Which words should be green now? This is the core problem of highlights: an anchor is a description of a piece of text that must find that text again, in a document that may have changed.

The W3C Web Annotation Data Model gives us vocabulary for this. A TextPositionSelector describes a span by character offsets: start is the first character, and end is the position just after the last one. A TextQuoteSelector describes it by content: the exact text, plus a few characters of prefix before it and suffix after it.

The model allows several selectors for one target, so a client can try one and fall back to another. Annotation tools like Hypothesis store both, with 32 characters of prefix and suffix, for exactly this reason.

Decision How should a highlight be stored so it survives edits to the article?

Character offsets only

Store start and end as offsets into the article's plain text.

Pros

  • Tiny and fast to resolve: one slice.
  • Works for repeated phrases, since the position is exact.

Cons

  • Any insert or delete before the highlight shifts it onto the wrong words, silently.
  • No way to tell that it's now wrong.

Quote only

Store the exact highlighted text and search for it when rendering.

Pros

  • Survives inserts and deletes elsewhere in the article.
  • Easy to detect failure: the quote isn't found.

Cons

  • Ambiguous when the phrase repeats ("for example" may appear six times).
  • Any edit inside the quote, even a fixed comma, orphans it.

Block id, offsets, and quote with context

Store the id of the paragraph block, offsets within that block, the exact quote, and 32 characters of prefix and suffix.

Resolve in steps: exact match at the stored offsets, then exact quote search in the block, then in the whole article, then a fuzzy match, scoring candidates by context and distance from the old position.

Pros

  • Inserts in other paragraphs don't affect it at all, because offsets are block-local.
  • Repeats are resolved by context.
  • Small edits inside the quote still re-attach through the fuzzy step.

Cons

  • More code and more stored data per highlight (around 150 bytes plus the quote).
  • Needs stable block ids from the editor, kept across edits.
  • Fuzzy matching needs a threshold, below which we must admit the anchor is lost.

What we pick: Store all of it: block id, block-local offsets, exact quote, prefix, and suffix. Positions are fast and precise when nothing changed; quotes with context recover after edits; block ids keep the search small. The rich-text-editor guide gives every block a stable id, which is what makes this cheap.

Try each anchor format below. Apply a writer's edit and see which anchors re-attach, which land on the wrong words, and which give up.

Writer's edit

Stored when the reader highlighted cache it at the edge: position 69 to 89; quote; and block b2, offsets 36 to 56, prefix product, so , suffix ..

Anchor lab after the edit: no edit. The article now reads: b1: Most readers arrive from search. b2: The article page is the product, so cache it at the edge. b3: Personal data loads later. Results: position: re-attached, b2: "cache it at the edge"; quote: re-attached, b2: "cache it at the edge"; block + quote: re-attached, b2: "cache it at the edge".b1 Most readers arrive from search.b2 The article page is the product, so cache it at the edge.b3 Personal data loads later.positionre-attachedb2: "cache it at the edge"quotere-attachedb2: "cache it at the edge"block + quotere-attachedb2: "cache it at the edge"
position: re-attached (slice at the stored offsets). quote: re-attached (first exact match). block + quote: re-attached (block offsets still match).
ts
type Anchor = { blockId: string; start: number; end: number;
  exact: string; prefix: string; suffix: string };

function resolve(a: Anchor, doc: Doc): Span | "orphaned" {
  const block = doc.blockText(a.blockId);
  if (block?.slice(a.start, a.end) === a.exact)
    return { blockId: a.blockId, start: a.start, end: a.end };
  const hits = [
    ...findAll(block ?? "", a.exact).map((i) => at(a.blockId, i)),
    ...doc.findEverywhere(a.exact),
  ];
  if (hits.length) return best(hits, a); // score prefix, suffix, distance
  const fuzzy = doc.fuzzyFind(a.exact, { maxErrors: Math.ceil(a.exact.length * 0.25) });
  return fuzzy ? fuzzy : "orphaned";
}
Re-anchoring: try the cheapest exact match first, fall back step by step, and admit failure.

Where should re-anchoring run? Resolving on the client at render time repeats work on every view. Better: when the writer publishes an edit, a background job re-anchors every highlight on that post, stores the new offsets, and flags orphans.

The client then only verifies cheaply (does the slice match the quote?). Orphans aren't deleted; they stay in the reader's highlight list with the original quote and a note that the passage changed.

Painting highlights without touching the article DOM

The old way to show a highlight is to wrap the text in a mark element. That mutates the server-rendered HTML, which can confuse hydration, splits text nodes, and gets messy when highlights overlap.

The CSS Custom Highlight API paints ranges without changing the DOM. Build a Range for each anchor, add them to a Highlight object, register it with CSS.highlights.set("mine", highlight), and style it with ::highlight(mine). It's supported in Chromium 105+, Safari 17.2+, and Firefox 140+; keep the mark approach as a fallback for older browsers.

Popular highlights ("Top highlight") use a second registry name with a lighter style. They come from a public, short-cached endpoint with the aggregated top passages per post.

The selection toolbar

Listen for selectionchange and wait until the selection settles (on pointerup or keyup, or about 150 ms after the last change on touch). If it's collapsed or outside the article body, hide the toolbar; otherwise place it from getRangeAt(0).getBoundingClientRect(): above the selection on desktop, below it on phones, where the system copy menu sits above.

Keyboard readers select with Shift and the arrow keys, so a shortcut (for example Alt plus H) moves focus into the toolbar, a polite live region (an element whose updates screen readers announce) announces it, and Escape closes it and returns focus.

Sequence diagram of saving a highlight, with a failure. The reader selects text, and the toolbar appears. The reader presses Highlight. The toolbar adds a pending highlight to the store, and the store paints it at once. The store sends POST highlight to the API, which fails with a 500 error. The store removes the paint and shows a toast with Retry.ReaderToolbarStoreAPIselect textshow toolbarHighlightadd, pendingpaint at oncePOST highlight500 errorunpaint, Retry
The highlight appears the moment the reader taps, because the anchor is built locally. If the save fails, the paint is removed and the reader gets a Retry, so the screen never claims something the server doesn't have.

Sharing a quote: text fragments

Share from the toolbar copies a link that scrolls to and marks the passage, using a text fragment: #:~:text= followed by an optional prefix, the start text, an optional end text, and an optional suffix, each percent-encoded. For example, #:~:text=the%20article%20page-,is%20the%20product marks "is the product" where it follows "the article page". The browser does the scrolling and marking; our code does nothing.

Chromium 80+, Safari 16.1+, and Firefox 131+ support it; other browsers ignore the directive and just open the page. The browser strips the directive from the URL that scripts see, so it never collides with our own routing. It's built from the same quote, prefix, and suffix we already store, so one anchor format serves both.

Check your understanding · Exercise 6

A reader highlighted "caching is the product" in paragraph 7. The writer then inserts two new paragraphs at the top and changes the highlighted sentence to "caching is the real product". What happens with each anchor? Select all that apply.

Claps: many taps, one honest number

Medium's clap is not a like. A reader can clap up to 50 times per story, and holding the button claps repeatedly. The public total is the sum of every reader's claps.

A happy reader may tap 30 times in five seconds, and each tap must feel instant: the number ticks up, the icon pops, and a small "+30" bubble floats. The reddit guide covers optimistic voting in general; claps add two twists, rapid repeated input and a per-user cap.

The naive build sends POST /claps with +1 on every tap. Thirty taps make thirty requests, which is wasteful but not the real problem.

The real problem is retries: if request 17 times out and the client retries it, but the first attempt actually reached the server, the reader now has 31 claps. Increments are not idempotent. And if requests arrive out of order, a server enforcing the cap of 50 may reject the wrong one.

Decision How should rapid clap taps reach the server?

One increment request per tap

Every tap sends +1; the server adds it and caps at 50.

Pros

  • Simplest code.
  • Each tap is recorded even if the tab closes right after.

Cons

  • Thirty taps, thirty requests.
  • Retries double count; increments can't be safely repeated.
  • Out-of-order arrival makes cap errors confusing.

Debounced increments

Count taps locally; 800 ms after the last tap, send +N.

Pros

  • One request per burst.

Cons

  • Still not idempotent: a retried +12 can count twice.
  • Two tabs each send their own deltas with no shared view of the cap.

Debounced absolute total per user

Keep the reader's own total locally (say 12).

800 ms after the last tap, send PUT /posts/8f3a2c/claps with { count: 12 }.

The server stores this reader's count, clamped to 0 to 50, and adjusts the post total by the difference from the old value.

Pros

  • Idempotent: sending 12 twice still means 12.
  • One request per burst.
  • Out-of-order requests can be fixed with a client sequence number: the server ignores a lower one.

Cons

  • The server must keep a per-reader count, not just a total (it needs that for the cap anyway).
  • The public total updates in steps, not tap by tap, for other readers.

What we pick: Send the reader's absolute total, debounced. It turns a stream of non-idempotent events into one idempotent fact ("my claps on this post are 12"), which makes retries and duplicate tabs safe. The debounce-throttle lesson covers the timing; here the important part is what the request means, not how often it's sent.

Sequence diagram of clapping, with a failure. The reader taps 12 times in 2 seconds and the clap button shows 12 at once. After 800 ms of quiet, the button sends PUT count 12, seq 1 to the API, which replies ok, 12, total 1,840. The reader taps 3 more times. The button sends PUT count 15, seq 2, which times out. The button retries PUT count 15, seq 2, and the API replies ok, 15, total 1,843.ReaderClap buttonAPItap x12 in 2 sshows 12 at oncePUT count 12, seq 1ok: 12, total 1,840tap x3PUT count 15, seq 2timeoutretry: count 15, seq 2ok: 15, total 1,843
The button counts locally and sends one fact per burst: my total is 12, then 15. When the second request times out, retrying it is harmless, because applying 15 twice is still 15.
ts
const MAX = 50;
let mine = initialMine; // from the viewer payload
let confirmed = initialMine;
let seq = 0;
const flush = debounce(async () => {
  const sent = mine;
  const mySeq = ++seq;
  try {
    // keepalive lets the request finish if the tab closes mid-flight
    const res = await api.put(`/posts/${id}/claps`, { count: sent, seq: mySeq }, { keepalive: true });
    if (mySeq === seq) { confirmed = res.count; render(res.count, res.total); }
  } catch {
    if (mySeq === seq) { mine = confirmed; render(confirmed); toast("Claps not saved"); }
  }
}, 800);

function onTap() {
  if (mine >= MAX) return pulseLimit();
  mine += 1; render(mine); flush();
}
addEventListener("pagehide", () => flush.flush()); // send before leaving
A clap button that batches taps and sends one idempotent total.

A few details make it solid. Flush pending claps on pagehide with fetch and keepalive: true, so the request survives the tab closing. Show the total as the cached public total plus the reader's change since load.

At 50, the button stops counting and gives a gentle cue, not an error. Holding the button repeats about 8 times a second and stops on pointerup, pointercancel, or pointerleave. Under prefers-reduced-motion, skip the bounce and floating bubble.

State machine of the clap button. Idle, synced, goes to counting on a tap. Counting resets its timer on each tap and goes to sending after 800 ms of quiet. Sending goes back to idle on 200 ok, back to counting on another tap, and to error on a network error. Error rolls back to the confirmed count and returns to idle after a toast. A note says at 50 claps taps are ignored and no request is sent.idlesyncedcountingtimer resetssendingPUT count, seqerrorroll backtap800 ms200 oktap againerrorafter toastat 50 claps:taps ignored,no request
Counting is where taps pile up, and sending is never a dead end: a new tap starts a new burst, and the sequence number makes the server ignore the older request if it lands late.

Check your understanding · Exercise 7

A reader taps clap 12 times, the debounced PUT with count 12 times out, and the client retries it. Then the reader taps 3 more times. What does the server end up with? Select all that apply.

Writing and publishing: the lifecycle around the editor

The editor itself (the document model, contenteditable, the toolbar, paste) is the rich-text-editor guide. Here we design what surrounds it: saving, previewing, scheduling, publishing to readers and inboxes, and editing afterwards. Writers are few compared to readers, but losing a writer's draft is the worst bug this product can have.

State machine of a story. A draft goes to scheduled when the writer sets a time, and a server job moves scheduled to published. A draft can also be published now, or submitted to a publication, where it is in review until editors approve it and it becomes published. A published story can be unpublished. A note says edits to a published story are a pending draft until the writer chooses save and publish.draftscheduledpublishedin reviewpublicationunpublishedset timeserver jobpublish nowsubmitapproveunpublishedits to a live storystay a pending draftuntil Save and publish
The server owns every move into published, whether it's the writer's button, a scheduled job, or an editor's approval, so each runs the same steps: snapshot, revalidate, notify, email.

Autosave and versions

Save the draft about 1.5 seconds after the writer stops typing, and at least every 10 seconds during long bursts. Each save is a PUT of the whole document (a 4,000-word story is roughly 30 to 60 KB of JSON) with the baseVersion it was built on; the server answers with the new version.

Also keep a copy in IndexedDB, the browser's built-in database, on every change, so a dead network or a closed laptop loses nothing; on reopen, offer to restore it if it's newer than the server's. The status line says exactly one of "Saving", "Saved", or "Offline, saved on this device". The server also snapshots the draft every few minutes of editing and at every publish, so writers can restore earlier versions.

Decision What happens when the same draft is open in two tabs, or on a laptop and a phone?

Last write wins

Each tab saves whatever it has; the server keeps the latest.

Pros

  • No conflict code at all.

Cons

  • The tab that saves last silently erases the other's paragraphs. A writer who edits on the phone, then opens a stale laptop tab, loses work.

Version check with a clear conflict prompt

Each save carries baseVersion. If the server is at a newer version, it answers 409 Conflict with the current document.

The editor stops autosaving and shows: "This story was changed in another window", with Reload, or Keep mine as a new version.

Same-browser tabs hear about saves through a BroadcastChannel (a browser API for messages between tabs of the same site) and refresh before they conflict.

Pros

  • No silent loss, ever.
  • Small amount of code; drafts are single-author almost all the time.

Cons

  • Real simultaneous editing from two places still needs a human choice.

Real-time collaborative editing

Sync edits as operations through operational transformation (OT) or conflict-free replicated data types (CRDTs), merging both tabs live.

Pros

  • Merges automatically, and enables co-authoring.

Cons

  • Large engineering cost for a product where 99 percent of drafts have one author (estimate).
  • Covered in depth by the google-docs guide.

What we pick: Use the version check. Drafts are almost always single-author, so conflicts are rare and the important property is that nothing is ever silently lost. A same-browser BroadcastChannel makes the common two-tabs case nearly conflict-free; the 409 path handles the cross-device case honestly.

Writers share drafts with editors and friends before publishing. The link must work without an account, must not be guessable, and must not be indexed.

Make it /p/8f3a2c?share= plus a random 128-bit token stored on the draft, or an HMAC of the post id and a token version. The page is rendered on demand, never cached publicly, and sends X-Robots-Tag: noindex. Revoking is a button that bumps the token version, and expiry is optional, for example 30 days.

Scheduling, publications, and email

Scheduling stores publishAt in UTC and shows it in the writer's time zone with the zone name ("Tue 9:00 AM, India Standard Time"). A server job publishes it, then runs the same steps as a manual publish: revalidate tags, update the sitemap's lastmod, notify followers. The client never decides when to publish, because the writer's laptop may be closed. Submitting to a publication adds a review state: the editors of the publication approve before it goes live.

Newsletter delivery, Substack's core feature, renders the same document a second time as email HTML. Mail clients run no JavaScript and support limited CSS, so the email renderer inlines styles, keeps a single narrow column (about 600 pixels), turns embeds into linked thumbnails, and uses absolute image URLs. Paid posts send free subscribers the preview plus a "subscribe to read" button, like the web paywall. A test email to the writer catches most rendering surprises.

Slugs, redirects, and canonical URLs

Writers change titles after publishing, and old links are already on X and in newsletters. So the URL holds both a readable slug and the stable id: /@ana/what-i-learned-shipping-a-design-system-8f3a2c. The router reads only the trailing id. If the slug part doesn't match the current one, respond with 301 to the current URL, so old links keep working and search engines move their ranking to the new URL.

Writers who first published on their own blog can set a canonical URL, written as link rel="canonical" pointing at the original. Search engines then credit the original and don't treat the copy as duplicate content.

Images are uploaded through the flow in the file-uploader guide, and cropping a hero to the card and share aspect ratios is the image-cropper guide. At upload, the server records width and height (needed for CLS) and generates widths like 400, 800, 1200, and 1600 in AVIF and WebP, behind an image CDN URL. The editor asks for alt text right in the image block, with a visible "Add alt text" prompt, and the publish dialog lists images still missing it.

Check your understanding · Exercise 8

Which publishing behaviors protect writers and readers, select all that apply? Select all that apply.

SEO and sharing: being found and looking good in a link

Most readers arrive from somewhere else, so how the story looks to Google, to Slack's link unfurler, and to an RSS reader matters as much as how it looks on the page. All of it is generated on the server at publish time and lives in the cached HTML.

  • Article structured data. A JSON-LD block (JSON in a script tag, the format Google prefers for structured data) of type BlogPosting with headline, image (several aspect ratios are welcome), datePublished, dateModified, and author with name and url. Member-only stories add the paywall properties from the paywall chapter.
  • Link previews. Open Graph tags (og:title, og:description, og:image, og:type set to article) and twitter:card set to summary_large_image. Most platforms crop around a 1.91:1 image, so generate 1200 by 630.
  • Generated share images. Not every story has a hero, and a bare hero lacks the title. At publish, render one from a template (title, author, avatar, publication logo) as a PNG; in Next.js, ImageResponse from next/og does this. Put the post version in its URL, so an edit gets a new image and no cache serves the old title.
  • Canonical. Every page declares its own clean canonical URL (no tracking or share parameters), or the original's URL for cross-posted stories.
  • Sitemaps. One sitemap file holds at most 50,000 URLs or 50 MB uncompressed, so tens of millions of stories need a sitemap index pointing at many files, split by month of publication, each with lastmod. Old months rarely change and can be cached for a long time.
  • RSS. Feeds per author, per publication, and per tag, with the latest 20 or so items. Member-only stories include only the preview in the feed, matching the paywall.

The one SEO mistake that's easy to make with the design so far: sending the hero image, title, and body text only after JavaScript runs. Everything search engines and unfurlers need is in the first HTML response here, because the article is server-rendered and cached. The per-viewer layer, which crawlers don't need, is the only part loaded by the client.

Check your understanding · Exercise 9

A writer changes a story's title. What should change automatically? Select all that apply.

Architecture, data model, and API

Put the pieces from the previous chapters together. There are two front ends sharing one design system: the reader (article pages, profiles, publications, the home feed) and the writer (editor, stats, settings). They have opposite profiles.

The reader is mostly static HTML with a few interactive islands, optimized for first view. The writer is a client-heavy app behind login, optimized for not losing work. Splitting them by route keeps the editor's large bundle (often several hundred KB with its document model and plugins) away from readers.

Architecture. Reader routes in the browser: static article HTML, three islands for claps, highlights, and responses, and a viewer client that holds one payload. Writer routes: the editor app, an IndexedDB draft copy, and an uploader. At the CDN edge: the CDN cache, middleware that picks the variant, and a meter store. At the origin: a publish service that purges the CDN, a renderer that fills it, a viewer API, a drafts API, and an image CDN. The viewer client sends GET viewer to the viewer API, the editor sends PUT draft to the drafts API, and the uploader sends images to the image CDN.Reader routesArticle HTMLClapsHighlightsResponsesViewer client: one payloadWriter routesEditor appIndexedDBUploaderCDN edgeCDN cacheMiddlewareMeterOriginPublish svcRendererViewer APIDrafts APIsave, publishImage CDNGET viewerPUT draftupload
Readers and writers share a design system but not a bundle. The reader side is mostly cached HTML plus islands; the writer side is a client app. The edge makes the only per-request decision, which cached copy to send.
The main pieces of the platform and where each runs.
PieceRuns whereResponsibility
Article rendererServer, at publish or first requestTurns the stored document into HTML, both preview and full variants, with structured data and share tags. Tagged by post and author.
Edge middlewareCDN edgePicks the variant (preview or full) from the membership token, the meter, a friend link, or a verified crawler. Redirects stale slugs.
Viewer clientBrowser, small islandFetches the viewer payload once; owns my claps, follow state, reading list, and highlight data.
Clap barBrowser islandOptimistic count, debounced absolute total, flush on page hide.
Highlight layerBrowser islandSelection toolbar, anchor creation and resolution, painting with the Custom Highlight API, text fragment share links.
Responses drawerBrowser, loaded on openThe comments-thread design, loaded only when opened.
Editor appBrowser, writer routesRich text editor, autosave with versions, IndexedDB draft copy, image upload, publish dialog.
Publish serviceServerValidates, snapshots a version, revalidates tags, purges the CDN, queues email and follower notifications, updates the sitemap.
Service workerBrowserCaches saved stories for offline reading and the app shell for the writer.

Each boundary has a reason. The renderer is separate from the edge decision because rendering is expensive and rare, while choosing a variant is cheap and runs on every request. The viewer client is one fetch for all per-viewer state rather than one per widget, because five widgets making five calls is five round trips on a phone.

The responses drawer is lazy because most readers never open it. And the publish service owns invalidation, because it's the only place that knows when content changed.

Check your understanding · Exercise 10

Why is the per-viewer state fetched as one payload after the page shows, rather than rendered into the HTML, select all that apply? Select all that apply.

Data model and API

The client-side data falls into three groups with different homes. Public content (the post, its author, the public clap total, top highlights) is baked into cached HTML or fetched from public, cacheable endpoints. Viewer state (my claps, my highlights, follow, saved, meter) lives in a small server-state cache in the browser, keyed by post id, fetched once per page and updated optimistically. Drafts live in the editor's store, mirrored to IndexedDB, and saved to the server with versions.

Data model in three layers. Cached HTML, public: a Post with id, slug, and version has many Blocks, each with an id, kind, and text. Viewer cache, private: a ViewerState with my claps and follow state has many Highlights, each with an anchor and note; a Highlight points at a Block by its blockId. Editor store and IndexedDB: a Draft with a base version holds a doc made of Blocks.Cached HTML: public, taggedPostid, slug, versionBlockid, kind, texthas manyViewer cache: privateViewerStatemyClaps, followHighlightanchor, notehas manyblockIdEditor store + IndexedDBDraftbaseVersion, docdoc: blockssaved on every change,PUT with baseVersion
Stable block ids tie the three layers together: highlights anchor to them, the editor keeps them across edits, and the renderer writes them into the HTML as data-block-id.
ts
type Post = {
  id: string; slug: string; authorId: string; publicationId?: string;
  title: string; subtitle?: string; heroImage?: Img;
  blocks: Block[]; // paragraphs, headings, images, code, embeds
  readingMinutes: number; memberOnly: boolean; previewBlockCount: number;
  publishedAt: string; updatedAt: string; version: number;
  canonicalUrl?: string; clapTotal: number; responseCount: number;
};
type Block = { id: string; kind: "p" | "h2" | "img" | "code" | "embed" | "quote"; text?: string;
  img?: Img; embed?: { provider: string; thumb: Img; aspect: number; url: string } };
type Img = { src: string; width: number; height: number; alt: string };

type ViewerState = {
  myClaps: number; following: boolean; saved: boolean;
  highlights: Highlight[];
  meter?: { remaining: number; resetsAt: string; unlocked: boolean };
};
type Highlight = { id: string; anchor: Anchor; note?: string;
  status: "ok" | "orphaned" };
type Draft = { postId: string; version: number; doc: Block[]; savedAt: string };
The main client entities. Blocks carry stable ids so highlights can anchor to them.

The API is REST, because almost everything is a document read or a small write, and REST responses cache cleanly at the CDN. Writes return the new state, so the client can reconcile its optimistic view with the server's.

http
GET  /@ana/what-i-learned-8f3a2c          HTML, cached at the edge (variant)
GET  /api/posts/8f3a2c/viewer             200 ViewerState, private, no-store
GET  /api/posts/8f3a2c/top-highlights     200 [{ blockId, start, end, count }], s-maxage=300
PUT  /api/posts/8f3a2c/claps              { count: 12, seq: 4 } -> { count: 12, total: 1840 }
POST /api/posts/8f3a2c/highlights         { anchor, note? } -> 201 Highlight
DELETE /api/highlights/h_91               204
PUT  /api/me/follows/41                   204   (idempotent; DELETE to unfollow)
PUT  /api/me/saved/8f3a2c                 204
PUT  /api/drafts/8f3a2c                   { baseVersion: 17, doc } -> { version: 18 }
                                          409 { code: "version_conflict", current }
POST /api/drafts/8f3a2c/publish           { publishAt?, publicationId?, email: true }
POST /api/drafts/8f3a2c/preview-link      -> { url, expiresAt }
Errors: { code: "meter_exhausted" | "rate_limited" | "version_conflict", message }
The reader and writer endpoints. Public reads are cacheable; viewer reads and writes are private.

Notice the verbs. Follow, save, and clap are PUT of a state, not POST of an action, so they're idempotent and safe to retry. Lists such as an author's stories and the home feed use cursor pagination as in the news-feed guide.

Check your understanding · Exercise 11

Which API choices make optimistic UI safe to retry on a flaky phone connection? Select all that apply.

Accessibility, offline reading, and the rest of the page

Accessibility on a reading page

A reading page is the easiest kind of page to make accessible, and the easiest to quietly break. The semantic-html and screen-readers lessons cover the basics; these are the parts specific to articles.

  • Landmarks and structure. The story is an article inside main, with the title as the only h1. The site header is a header with nav, the responses a separate section with its own heading. Screen reader users jump by landmark and by heading, so this is their table of contents.
  • Heading order from the editor. The editor offers two heading levels that map to h2 and h3, never h1, so writers can't break the outline. The renderer never skips a level.
  • Alt text. Prompt for it on every image block in the editor, and list missing ones in the publish dialog. Decorative images get an explicit empty alt. Generated captions can suggest a draft, but the writer confirms.
  • Interactive islands are real controls. The clap button is a button whose accessible name includes the count, and changes are announced through a polite live region, throttled so 30 taps don't produce 30 announcements.
  • The highlight toolbar is reachable from the keyboard, traps nothing, and returns focus on Escape, as described in the highlights chapter.
  • Motion. The clap bounce, floating bubbles, and smooth scrolling to a text fragment respect prefers-reduced-motion.
  • Paywall box. The preview's end is announced as a heading ("This story is for members"), not just a visual fade, so screen reader users know why the text stopped.

Saved stories, offline

Saving a story to the reading list is a PUT; reading it on a plane is a job for a service worker, the script that sits between the page and the network (see the service-worker-caching lesson). On save, the page messages the service worker, which fetches the story's HTML (the variant this reader is entitled to) and phone-sized images into a dedicated cache such as saved-v1.

Navigations to a saved story try the network first with a 3-second timeout, then fall back to the saved copy. Cap the cache (say, the 50 most recent saves), drop entries on unsave, and clear member-only copies on sign-out.

Following, the home feed, and responses

Following a writer is an optimistic PUT with the button flipping immediately. The home feed (followed writers, publications, and recommendations) is a ranked, cursor-paginated list; its design is the news-feed guide. Responses are threaded comments with their own article-like editor and their own claps; their design is the comments-thread guide. On the article page, the responses drawer loads only when opened, and its count is part of the cached HTML, refreshed when the post is revalidated.

Check your understanding · Exercise 12

Which of these are correct for the reading page? Select all that apply.

Running it in the interview

Forty-five minutes is not enough to cover everything in this guide, so choose on purpose. The reader page is where the interesting trade-offs are; the editor can easily take the whole interview, so name it, point to the rich-text-editor problem, and move on unless asked.

A time plan for a 45-minute interview on this problem.
MinutesWhat to doWhat to draw
0 to 5Scope: who reads, where from, paywall or not, which reader actions. State that reading dominates.Nothing yet, or a list of requirements.
5 to 15Delivery: static at the edge, revalidate on publish with tags, per-viewer payload on the side.The data flow: browser, CDN, origin, viewer endpoint.
15 to 25Pick the deep dive the interviewer leans toward: paywall variants and metering, or highlight anchoring.The edge decision, or an anchor with its fallback steps.
25 to 33Claps (absolute total, debounced) and Core Web Vitals on a long page.A small sequence diagram for the clap flow.
33 to 40Publishing: autosave with versions, conflicts, previews, slugs and redirects.The post lifecycle states.
40 to 45Accessibility and offline in one minute each, then questions.Nothing new.

What to say out loud: name the split between shared and per-viewer data in the first ten minutes; state that the paywall is enforced by what the server sends, not by what the client hides; and say why claps send a total, not increments. Those three sentences show the interviewer you understand caching, security, and idempotency, which is most of what this question tests.

Think first

Follow-up: a story with 2 million cached views gets edited 40 times in an hour by a nervous writer. What happens to your cache?

Think first

Follow-up: a member cancels. Their membership token is valid for 15 more minutes. Is that a problem?

Think first

Follow-up: how would you show that 1,200 readers highlighted the same sentence, when everyone highlights slightly different spans?

Check your understanding · Exercise 13

Which moves make a strong interview answer for this problem, select all that apply? Select all that apply.

Summary

  • The article page is the product. Serve it as static HTML from the edge, revalidated by tag when the writer publishes, and fetch everything per-viewer in one small private payload.
  • Keep a long page fast and still: hero image in the HTML with high priority, metric-matched fallback fonts, stored width and height for every image and embed, and click-to-load facades for third-party embeds.
  • Readability is measurable: 45 to 75 characters per line, line height near 1.6, body near 20 px on desktop, reading time computed once on the server.
  • A paywall is what the server sends, not what the client hides. Cache a preview and a full variant, choose at the edge from a signed token and a server-side meter, verify crawlers by DNS or IP, and mark locked sections with paywall structured data.
  • Anchor highlights with a block id, block-local offsets, and the quote with prefix and suffix, and re-anchor in steps from exact to fuzzy to orphaned. Paint them with the Custom Highlight API and share them with text fragments.
  • Claps send the reader's absolute total, debounced, with a sequence number. Idempotent writes make optimistic UI safe to retry.
  • Writers must never lose work: autosave with versions and an IndexedDB copy, a 409 on conflicts, signed preview links, server-side scheduling, and slug changes that redirect.

Check your answer

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

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next