← System design

Search & Discovery

Design Google News

A news aggregator writes almost nothing of its own. Its value is in noticing that 140 articles from 90 outlets are the same story, deciding which story matters most right now, and choosing which version of it each reader should see first. The data volume is modest. The difficulty is doing all of that continuously, within minutes of a story breaking, without letting duplicates, spam or a single loud outlet take over the page.
Core: incremental story clustering + edition-then-personal ranking250K new articles a day35K feed loads a second at peakBreaking story on screen within 5 minutes

01

Scope and targets

02

Scope and targets

Functional
  • Discover new articles from tens of thousands of publishers through feeds, sitemaps and push notifications.
  • Group articles that cover the same event into one story, with a lead article and other coverage beneath it.
  • A front page of top stories per country and language, plus topic pages such as business, sport and technology.
  • A personal "for you" feed based on followed topics, sources and reading history.
  • Search across recent and archived articles.
  • Show multiple outlets per story, including different viewpoints where they exist.
  • Publishers can be blocked or demoted by policy teams; users can hide a source.
Non-Functional
  • A breaking story appears on the front page within 5 minutes of the first credible reports.
  • Feed load latency under 150 ms p95 from the edge.
  • Never show the same story twice on one page, and avoid near-identical wire copies side by side.
  • Crawling respects robots.txt and per-site rate limits, including for very small outlets.
  • The front page stays available even if clustering or personalisation is degraded.
Clarify before you design

Is this an aggregator that links out, or does it host full text? Do we summarise stories ourselves? How much does personalisation matter compared with a shared edition? Here we assume link-out with headlines and short snippets, a strong shared front page per region, and a personal layer on top. That last choice, a shared edition first, shapes the whole serving design.

02

Sizing it

03

Sizing it

Assume 150 million daily readers who open the app six times a day, 60,000 publishers, and five years of articles kept searchable.

QuantityWorkingResult
New articles per day60K publishers, heavily skewed to large outletsabout 250K
Article ingest rate250K / 86,400, peaks around 10x on big news3 / s average, 30 / s peak
Feed polls2K top feeds every 60 s plus 58K every 30 minabout 65 / s
Feed loads per day150M x 6900M
Peak feed loads900M / 86,400 x 3.5 for the morning peakabout 35K / s
Breaking news spike3x on top of peak for a few minutesabout 100K / s
Articles retained250K x 365 x 5about 460M
Text and metadata460M x about 6 KBabout 2.8 TB
Embeddings460M x 768 dims x 2 Babout 700 GB
Live clustering windowlast 72 hours of articlesabout 750K articles, fits in memory
Ingest is small, serving is large

Thirty articles a second at peak is a light pipeline. Clustering and ranking can be rich and still keep up. The pressure is on the read side, where a breaking story can triple traffic in minutes and every reader is asking for roughly the same front page.

03

API design

04

API design

GET/v1/editions/{region}/{lang}?section=top&cursor=Shared front page or section
// Same response for every reader in the region: cacheable at the edge for 30 s { "edition_version": 1759320180, "stories": [ { "story_id": "st_4qk19", "headline": "Central bank holds rates, signals cut in December", "lead": { "publisher": "Example Times", "url": "https://news.example.com/rates", "published_at": "2026-10-01T05:10:00Z" }, "coverage_count": 87, "also": [ /* three or four other outlets */ ] } ], "next_cursor": "c_20" }
GET/v1/me/feed?limit=20Personal feed
// Authenticated. Built from edition candidates plus personal candidates, then re-ranked { "stories": [ { "story_id": "st_8z2pa", "reason": "Because you follow Formula 1" } ], "served_from": "personal" // or "edition" when personalisation is degraded }
GET/v1/stories/{story_id}Full coverage of one story
{ "story_id": "st_4qk19", "timeline": [ /* articles in time order */ ], "perspectives": [ { "label": "Analysis", "articles": [ /* ... */ ] }, { "label": "Local coverage" } ] }
POST/v1/me/eventsReading signals, batched from the client
{ "events": [ { "type": "open", "story_id": "st_4qk19", "dwell_s": 48 }, { "type": "hide_source", "publisher_id": "pub_2210" } ] } // 202 Accepted: goes to the event stream, never blocks the client
GET/v1/search?q=rate+decision&since=7dArticle search
{ "results": [ { "article_id": "a_77fd", "story_id": "st_4qk19", "score": 12.4 } ] }

04

System overview

05

System overview

From publisher feeds to a reader's screenDiagram
flowchart LR
	PUB["Publisher feeds, sitemaps, WebSub"] --> SCH["Crawl scheduler"]
	SCH --> FET["Polite fetchers"]
	FET --> EXT["Extract, detect language, dedupe"]
	EXT --> ENR["Entities, topics, embeddings"]
	ENR --> CL["Story clusterer"]
	CL --> ST[("Story store")]
	ENR --> IDX[("Search index")]
	ST --> SC["Story scorer"]
	SC --> ED["Edition builder, every minute"]
	ED --> EC[("Edition cache")]
	EC --> FEED["Feed service"]
	UP[("User profiles")] --> FEED
	FEED --> CDN["Edge cache"]
	CDN --> R["Reader"]

Read the diagram as two halves joined by the story store. The left half is a pipeline that turns raw web pages into scored stories; it runs continuously and can lag by a minute without anyone noticing. The right half is the serving path; it must answer in milliseconds and keep working even if the left half stalls.

The crawl scheduler knows every publisher's feeds and how often each one tends to publish, and polls busy feeds every minute and quiet ones every half hour. Publishers that support WebSub push new items to us instead. Fetchers download the article pages, honouring per-host limits. Extraction strips navigation and ads, finds the headline, author, publish time and canonical URL, detects the language, and drops exact and near duplicates. Enrichment tags named entities, assigns topics and computes a text embedding.

The clusterer assigns each new article to an existing story or starts a new one. The scorer recomputes each active story's importance as coverage grows. Once a minute, the edition builder takes the top scored stories for every region, language and section, applies diversity rules, and writes a ready-made edition to a cache. The feed service either returns that edition as is, or mixes it with personal candidates and re-ranks for a signed-in reader.

Crawl scheduler and fetchers

Adaptive polling per feed, a token bucket per host, and conditional GETs with ETags so unchanged feeds cost almost nothing. Small sites get a hard cap regardless of how newsworthy they are today.

Extraction and dedupe

Canonical URL rules catch the same article under tracking parameters. SimHash over body shingles catches syndicated wire copy republished by hundreds of outlets.

Story clusterer

Keeps the last 72 hours of stories in memory with their centroids and key entities. Assignment is incremental; a slower batch job repairs merges and splits.

Story scorer

Combines how many distinct outlets cover a story, how fast that number is rising, the quality of those outlets and how old the story is.

Edition builder

Produces a few thousand editions a minute, one per region, language and section. Each is a small JSON document, so the whole set fits in a few gigabytes of cache.

Feed service

Stateless. Reads an edition, a user profile and a personal candidate list, re-ranks around 300 candidates with a light model, and returns twenty.

05

Deep dive: clustering, freshness and variety

06

Deep dive: clustering, freshness and variety

Three questions this section answers

How do you decide, within seconds of an article arriving, which story it belongs to? How do you rank stories so the page is current but not just the latest thing published? And how do you stop one topic or one outlet from filling the whole page?

Stage one: throw away copies

A large share of articles are near copies: wire stories republished with a new headline, or the same article reachable at three URLs. Handle URL variants with canonical tags and parameter stripping. For body text, compute a 64-bit SimHash over word shingles; two articles whose hashes differ in three bits or fewer are treated as the same text. Duplicates are not discarded. They are attached to the original as extra evidence that the story is spreading, which matters for scoring, but they never appear on the page as separate entries.

Stage two: incremental story assignment

Each active story keeps a centroid embedding, a set of weighted entities (people, organisations, places) and a time range. When an article arrives, the clusterer finds candidate stories with an approximate nearest neighbour search over centroids from the last 72 hours, limited to the same language or with cross-lingual embeddings if you support that. It then scores each candidate on embedding similarity, entity overlap and time gap. Above a threshold, the article joins the best story and the centroid updates as a running average. Below it, the article starts a new story.

Entities act as a guard rail. Two articles about different football matches on the same night have similar embeddings, but different team names. Two articles about the same earthquake in different languages look different lexically but share a place and a magnitude. Requiring some entity overlap reduces both false merges and missed matches.

Assigning one new articleDiagram
flowchart TD
	A["New article, deduplicated"] --> E["Embed and extract entities"]
	E --> N["ANN search over 72 hour story centroids"]
	N --> S{"Best score above threshold?"}
	S -->|yes| J["Join story, update centroid and entity set"]
	S -->|no| C["Create new story"]
	J --> R["Re-score story"]
	C --> R
	R --> B["Nightly repair job: merge, split, retire"]

Online assignment is greedy, so it makes mistakes that compound. Two clusters form for one event because the first articles used different terms; a cluster drifts as later articles shift focus and starts absorbing unrelated pieces. A repair job runs every 15 minutes over active stories and nightly over the full window, using a more careful algorithm such as agglomerative clustering on the full article set. It merges stories whose centroids converged, splits stories whose internal similarity dropped, and retires stories with no new coverage for two days. Story ids are kept stable through merges by redirecting the absorbed id to the survivor.

Ranking stories: importance times freshness

A story's score blends four signals. Breadth: how many distinct, reputable publishers cover it, counted after duplicates collapse. Velocity: how quickly that count is growing over the last hour, which is what makes a breaking story jump the queue. Source quality: a per-publisher authority score maintained from editorial standards, corrections history and expert review, not from clicks. Age: an exponential decay with a half-life of a few hours for news and longer for features.

One way to write it: score = (log(1 + quality_weighted_outlets) + 2 x velocity) x 0.5^(age_hours / 6). The exact formula is less important than its properties: a story covered by forty serious outlets beats one covered by four hundred copies of a press release, and a story that stops growing sinks within a day.

Diversity on the page

Pure score order produces a front page with six stories about the same election and the same outlet leading four of them. The edition builder re-ranks greedily: at each slot, pick the story that maximises score minus a penalty for similarity to stories already chosen, a version of maximal marginal relevance. Add hard rules on top: at most two lead articles from one publisher per screen, at least one story from each major section in the first twenty, and lead article rotation within a story so the same outlet does not always win.

Within a story, the lead article is chosen for quality and originality, preferring the outlet that broke the news or has first-hand reporting. Coverage beneath it deliberately includes analysis, local reporting and outlets with different editorial leanings, which is a product choice as much as a ranking one.

Personal layer

For signed-in readers, the feed service starts with about 200 stories from the shared edition and adds about 100 personal candidates: stories matching followed topics and sources, and stories close to the reader's interest vector, which is an average of embeddings of recently read stories with decay. A light ranking model scores all 300 using story score, topical match, recency of the reader's interest and whether they already saw the story elsewhere. Diversity rules run again on the result. If the profile or ranking service is slow, the feed falls back to the edition, which is always available.

06

Designs that look right and are not

07

Designs that look right and are not

Ranking by clicks alone

Engagement signals reward outrage and celebrity gossip and are easy to game. Use them for personal ordering, but keep source quality and breadth of coverage as the backbone of story importance.

Batch clustering every hour

Clean clusters, but a breaking story sits unclustered for up to an hour, which fails the five-minute target. Assign incrementally and repair in batch.

Building every reader's feed from scratch

Scoring thousands of stories per request for 100,000 requests a second wastes compute on work that is identical for most readers. Precompute the shared edition and personalise only the last step.

Crawling news sites like a search engine

Deep crawls of every page are slow to find new articles and heavy on publishers. Feeds, sitemaps and push give you new URLs directly; full crawls are only for discovery of new sources.

Treating duplicates as noise to delete

Syndicated copies are a strong signal that a story is spreading. Collapse them for display, but count them, discounted, when scoring.

One global cluster space

Comparing every article with every story worldwide is slow and produces strange merges across countries. Partition by language and coarse region, and link across partitions only for clearly global events.

07

Choices and their costs

08

Choices and their costs

Chosen
Incremental assignment with periodic repair

New articles join a story in seconds, which keeps breaking news fast. The cost is a second clustering job and the complexity of keeping story ids stable through merges and splits.

Fast and correctable
Rejected
Full re-clustering on a schedule

Simple to reason about and globally consistent, but slow, and story ids change between runs, which breaks links and analytics.

Too slow for news
Chosen
Shared edition, personal re-rank

One cached edition per region and section serves anonymous readers outright and provides most candidates for signed-in ones. Personal work is a small re-rank. Readers in the same region see a recognisably similar front page, which is what most of them want from news.

Cheap and resilient
Alternative
Fully personal candidate generation

Better for niche interests, worse for shared awareness of major events, and far more expensive. Useful as a separate "for you" tab, not as the front page.

Secondary surface
Chosen
Feeds and push first, adaptive polling

New URLs arrive within a minute for active publishers at a tiny fraction of crawl cost. Depends on publishers keeping feeds accurate, so sitemap checks fill the gaps.

Low cost, low lag
Rejected
Fixed-interval crawl of section pages

Works for sites without feeds but wastes requests on quiet sites and is still slow on busy ones.

Fallback only
Chosen
Embeddings plus entities for similarity

Catches paraphrases and different headlines for the same event, and entity overlap prevents merging similar but separate events. Costs an embedding model in the pipeline and a re-embedding job when the model changes.

Accurate grouping
Alternative
TF-IDF vectors and keyword overlap

Cheap and transparent, but misses stories reported with different vocabulary and struggles across languages.

Good first version

08

When things break

09

When things break

📰
A fabricated story spreads during a crisis

In the first hour of a disaster, low-quality sites publish a false claim faster than serious outlets confirm anything. Breadth and velocity both look strong, so the story rises.

Weight breadth by source quality, so a hundred obscure sites count for less than a few established ones. Hold new stories from unvetted sources below a threshold until a trusted outlet joins, and give policy staff a fast demote control.
🐢
Fetchers knock over a small local paper

A local story goes national and every feed and article URL on one small server gets polled hard. The site slows down and its operator blocks our crawler.

A per-host token bucket that ignores how important the story is, honour crawl-delay and 429 responses with backoff, and fetch each article once, then share it across every pipeline that needs it.
⏱️
Breaking news shows up 20 minutes late

The first articles come from outlets polled every 30 minutes, and the story needs a few outlets before it scores well.

Track end-to-end lag from publish time to first appearance. Promote publishers that frequently break news to one-minute polling, subscribe to push where possible, and raise a feed's polling rate temporarily when a related story is surging.
🧲
A mega-cluster swallows unrelated articles

A long-running topic like an election builds a cluster with a broad centroid. Every political article now matches it, and the front page shows one giant story instead of ten separate developments.

Cap story duration and spread, use time-weighted centroids so old articles stop dominating, and let the repair job split clusters whose internal similarity falls below a floor.
🌪️
Edition cache expires everywhere at once

Every edge node misses the same edition in the same second during a traffic spike, and thousands of identical requests hit the feed service.

Serve stale while revalidating, collapse identical requests at the edge, and push new editions into the cache when they are built instead of waiting for a miss.
🔤
Language detection fails on short snippets

A headline in a mixed-language market is tagged with the wrong language, so the article joins the wrong cluster partition and appears in the wrong edition.

Detect on body text rather than the headline, combine with the publisher's declared language, and route low-confidence results to both partitions for the clusterer to decide.
🕸️
A network of sites games source quality

Dozens of related domains cite each other and republish the same content to look like broad, independent coverage.

Detect shared ownership signals such as hosting, templates and identical text, count related domains as one outlet, and keep authority scores out of reach of automated signals that are easy to manufacture.

09

Interview notes

10

Interview notes

1

Say "story" early. Defining the unit as a cluster of articles rather than an article reshapes everything after it, and it is the insight most candidates miss.

2

Use the numbers to locate the hard part. A few dozen articles per second in, tens of thousands of reads per second out. That tells you to precompute editions and keep ingest rich.

3

Separate near-duplicate detection from clustering. SimHash finds copies of the same text; embeddings and entities find different articles about the same event. They solve different problems.

4

Have a scoring formula and defend its shape. Breadth, velocity, quality and decay, with reasons for each. Interviewers care about the properties, not the constants.

5

Talk about diversity explicitly. Without re-ranking, one topic or outlet floods the page. Naming maximal marginal relevance or simple caps shows product judgement.

6

Bring up trust and safety unprompted. Misinformation during breaking events is the failure that makes headlines. A quality-weighted score and a manual override are the minimum.

7

Be a good crawler. Mention robots.txt, per-host limits and push protocols. It is short to say and signals real-world experience.

8

Design the degraded mode. If personalisation fails, readers still get the edition. If clustering stalls, the last good editions keep serving. Say both.

10

Growing the design in stages

12

Growing the design in stages

Stage 1: a few hundred sources
Poll feeds, list newest first

A cron job reads RSS feeds into a database and the page shows the latest items. It works until the same story appears fifteen times in a row.

Stage 2: thousands of sources
Dedupe and keyword clusters

Add canonical URLs and SimHash to drop copies, group articles by keyword overlap within a time window, and rank clusters by how many sources they have.

Stage 3: national scale
Embeddings, source quality and editions

Move clustering to embeddings plus entities with incremental assignment, introduce publisher quality scores, and precompute cached editions per region and section.

Stage 4: personalisation
Profiles, events and a re-ranker

Collect reading events, build interest vectors, and re-rank edition and personal candidates per reader, with diversity rules applied after ranking.

Stage 5: many languages and richer stories
Cross-lingual clusters and story pages

Use multilingual embeddings to link coverage across languages, add timelines and perspective groupings, and consider machine-written story summaries with clear attribution and human review for sensitive topics.

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.