Google Search: crawling, indexing and answering in a fraction of a second
In March 2025 Google said it sees more than 5 trillion searches a year, which is on the order of 150,000 every second, each answered from an index of hundreds of billions of pages. This lesson takes the system apart: how pages are fetched, how the index is built and split across thousands of machines, and how one query is answered by many servers at once without waiting for the slowest one.
By the end you can
- Explain an inverted index and why Google splits it by document
- Trace a query from the search box to the merged top ten, and say where the time goes
- Explain tail latency at fan-out, and how a hedged request cuts it for about 2% extra load
- Name the infrastructure underneath (GFS and Colossus, MapReduce, Bigtable, Spanner, Borg) and what each taught
- Build a tiny sharded search engine, the same with Postgres full-text search, and a hedged-request helper
The everyday picture
Imagine a grocery chain with 320 stores and one question from head office: "Which stores still have basmati rice, and how much?" One way is to phone each store in turn. At two minutes a call, the answer arrives in about a day and a half. The other way is to send the question to all 320 store managers at the same moment and have each one check only their own back room. Each answer takes two minutes, and they all arrive in about two minutes.
Two problems appear at once. First, each manager needs a fast way to check their own stock: a notebook sorted by product, so nobody walks every shelf. That notebook is the index. Second, head office now waits for the slowest manager. If 4 of the 320 are at lunch, everyone waits. Google Search is this picture at planet size: a sorted notebook on every one of thousands of machines, and a set of tricks so the reply never waits for the manager at lunch.
What Search must do
Functional: find pages on the public web and notice when they change; understand each page (words, language, links, whether it copies another page); for any query, return the best few results with a title and a snippet showing the matching words.
Non-functional, which drive the whole design:
- Fast. In "The Tail at Scale" (2013), Jeff Dean and Luiz Barroso wrote that answers within about 100 ms feel fluid. The target is the p99, not the average (p99 and SLOs).
- Read-only at query time. Queries never change the index, so caching and copying it are safe.
- Fresh, always up, cheap per query. At trillions of queries, a tenth of a cent each is billions of dollars.
- Polite. The crawler must not overload a small shop's website.
Scale in numbers
Google publishes few numbers, and many are old, so each has its year.
| Metric | Number | Source, year |
|---|---|---|
| Searches per year | more than 5 trillion | Google blog, 2025 |
| Pages in the index | hundreds of billions | "How Search works" page, current |
| Size of the index | well over 100,000,000 GB (100 PB) | "How Search works" page, current |
| Pages processed per second by the indexing system | hundreds of thousands | Caffeine announcement, 2010 |
| Machines serving search | more than 15,000 commodity PCs | IEEE Micro paper, 2003 (very old) |
| Work per query | hundreds of MB read, tens of billions of CPU cycles | IEEE Micro paper, 2003 |
| Data centre locations | 30 active locations in 11 countries | Google data centres site, current |
| Network edge locations (PoPs) | over 200 metro areas | Google Cloud docs, updated 2026 |
| The whole web index in 1998 | 24 million pages, 147.8 GB of raw HTML | Brin and Page paper, 1998 |
With the method from the Estimation lesson: 5 trillion a year over about 31.5 million seconds is roughly 160,000 queries per second on average, with higher peaks. In 1998 a query took "1 to 10 seconds". This lesson is how they got from there to here.
The inverted index, and why it is split by document
A normal table answers "what words are in page 7?". Search needs the opposite: "which pages contain the word basmati?". An inverted index stores exactly that. For each word it keeps a posting list: the sorted ids of every document containing the word, often with how many times and where. Google's own explainer compares it to the index at the back of a book.
To answer "basmati rice biryani", you fetch three posting lists and intersect them. Start with the shortest list (the rarest word) so there is less to check. Then score each surviving page. A rare word like "basmati" says more about a page than a common word like "rice", so it gets more weight. That idea is called tf-idf. Real ranking adds hundreds of other signals, such as location, language and device.
No machine can hold a 100 PB index, so it must be split into shards (Sharding). There are two ways to split it:
- By word: shard 1 holds every posting list for words starting a to c, and so on. A three-word query touches at most three shards, but each shard must send long lists over the network to be intersected.
- By document: shard 1 holds a full little index for documents 1 to 1 million, shard 2 for the next million. Every query goes to every shard, but each shard does the whole job locally and sends back only its best few results.
In a 2009 keynote, Jeff Dean showed that Google chose by document: far less network traffic, and per-document data stays in one place. The price is that every query touches every shard, and much of the rest of the design exists to pay that price.
Quick check
A query has three words. Their posting lists hold 9 million, 40,000 and 2 million document ids. Which list should the intersection start from?
The answer can never be bigger than the shortest list, so starting there means checking at most 40,000 candidates against the other two lists. The final answer is indeed the same either way, which makes the third option tempting, but the amount of work is very different.
The request path, step by step
Google's 2003 paper "Web Search for a Planet" and Dean's 2009 keynote describe the serving path. Today's details are not public, but the shape has stayed the same in talks for twenty years:
- Pick a site. DNS sends you to a nearby cluster with spare capacity (2003). Your connection enters Google's network at an edge location close to you; the Cloud docs list over 200 metro areas. If your city has none, you enter at the nearest one on the list.
- Web server. It checks a cache of recent answers, then asks the index. In 1999 cache servers answered roughly 30% to 60% of queries, because many people search for the same thing.
- Fan-out. A root server sends the query to every index shard. Since about 2004 this is a tree (root, parents, leaves), so no machine talks to thousands of others directly.
- Leaf work. Each leaf holds its shard in memory; Google moved the whole index from disk to RAM in early 2001. Each shard is replicated, so the root can pick any healthy copy.
- Merge, then snippets. Leaves return their top results, parents and root merge them, and document servers (sharded the same way) cut a snippet around your words for each winner.
A 2003 rack held 40 to 80 ordinary x86 servers. The paper's point: "price/performance beats peak performance". Replication makes cheap hardware safe. If a machine dies, the root asks another copy, the same idea as stateless servers behind a load balancer (Scaling up vs out, Load balancers).
The tail at scale: why fan-out is dangerous
"The Tail at Scale" (Dean and Barroso, 2013) gives the arithmetic. Say one server answers in 10 ms, but 1 request in 100 takes a full second (a garbage-collection pause, a busy disk, a noisy neighbour). Now fan out to 100 such servers and wait for all of them: the chance that at least one is slow is 1 − 0.99100, about 63%. The rare slow server becomes the common case.
They measured a real Google service: one leaf alone had a p99 of 10 ms, waiting for all leaves gave 140 ms, and waiting for 95% of them gave 70 ms. The slowest 5% of leaves caused half of the p99 (p99 and SLOs).
The paper's fixes, each one usable by your team:
- Hedged requests. Send to one replica. If no answer by about the 95th-percentile time, send the same request to a second replica and use whichever answers first. In their Bigtable test (1,000 keys over 100 servers), hedging after 10 ms cut the p99.9 from 1,800 ms to 74 ms for only 2% more requests.
- Tied requests. Send to two replicas at once; whichever starts first tells the other to cancel. Nearly 40% off the p99.9 for less than 1% extra disk work.
- Micro-partitions. About 20 small shards per machine instead of one big one, so load moves in roughly 5% steps.
- Good-enough results. If a few leaves miss the deadline, answer from the rest.
- Canary requests. Send an unusual query to one or two leaves first, so a query that crashes servers crashes only those.
Quick check
A screen calls 10 services in parallel and waits for all of them. Each service is slow on 1% of requests. Roughly how often is the screen slow?
The screen is slow if any one call is slow: 1 − 0.9910 is about 0.096. Running the calls in parallel stops their times from adding up, but you still wait for the slowest one, so the rare slow case shows up about ten times as often.
Think first: why hedge after the p95 time, and not send two requests every time?
Two copies of every request double the load, and the extra load makes queues longer and the tail worse. Waiting until the p95 time means 95% of requests never need a backup, so the extra load is about 5% at most (2% in Google's test). Hedging is also only safe for reads, or writes with an idempotency key (APIs done right), because the work may run twice.
Building the index: crawl, process, publish
Serving is half the system. The other half runs all the time and turns the web into posting lists.
Crawl. Googlebot starts from known URLs, sitemaps and links on pages it already has. Google's docs (2025) say it decides how often and how much to fetch per site, renders pages with a recent Chrome so JavaScript content is seen, and slows down when a site returns errors such as HTTP 500: backpressure (Rate limiting) applied politely to other people's servers. In 1998 the whole crawler was about three programs with roughly 300 open connections each, fetching about 100 pages a second.
Process. Each page is parsed into words and positions, its links are extracted (link text describes the page it points to, a key idea of the 1998 paper), and duplicates are grouped under one canonical page. The 2006 Bigtable paper described the "Webtable": one row per page, keyed by the URL with the host reversed (com.cnn.www) so a site's pages sit together, with columns for contents and incoming link text. The crawl table alone was 800 TB.
Invert and publish. Turning "page to words" into "word to pages" is a giant sort. The 2004 MapReduce paper uses it as its example: map emits (word, document id) pairs, reduce sorts all ids for each word. Production indexing then ran as five to ten MapReduce jobs over more than 20 TB. Finished shards are copied to the serving leaves.
From batches to a stream. Batches are slow to show new pages. In 2010 Google said the main layer of its old index updated "every couple of weeks". Its replacement, Caffeine, processed pages continuously and gave 50% fresher results. The Percolator paper (2010) describes the engine: small updates applied with transactions and notifications instead of rewriting everything. It is the move from a nightly report job to an event-driven pipeline (Queues and jobs, Outbox and sagas).
Quick check
Why does Google build index shards in a separate pipeline and copy them to serving replicas, instead of doing that work on the machines that answer queries?
Keeping the offline and online worlds apart keeps query latency steady while the index changes all the time. Caffeine and Percolator show that the index can be updated in small steps, so the third option is wrong; the design choice is about where that work runs.
The data layer, a DBA's view
Search sits on storage systems Google described in papers. Read them as a DBA would: what is the unit, how is it split and copied, and what broke.
| System | What it is | The idea to remember |
|---|---|---|
| GFS (2003) | A file system across many machines: "hundreds of terabytes" on over a thousand machines | One master holds all the metadata; files are split into 64 MB chunks, each stored on three machines by default. Clients ask the master where a chunk is, then read it straight from a chunk server. Failures are normal. |
| Colossus (described 2021) | The successor to GFS | The single master was the limit, partly because of Search's metadata. Colossus keeps metadata in Bigtable and scales over 100 times past the largest GFS clusters. |
| MapReduce (2004) | Batch jobs over thousands of machines | You write map and reduce; the system handles splitting and retries. Without backup copies of the last slow tasks, one test job ran 44% longer: hedging again. |
| Bigtable (2006) | A sorted, sparse map from (row, column, time) to bytes | Rows are split into tablets of about 100 to 200 MB, the unit of moving load. Writes go to memory and are flushed into immutable sorted files. Over sixty products used it in 2006. |
| Spanner (2012) | A globally distributed SQL database with transactions | TrueTime gives the time as an interval with known error (about 1 to 7 ms). A commit waits out the error, so transactions are ordered correctly across continents. Its first big user replaced hand-sharded MySQL for ads. |
Three DBA lessons. First, keep the unit of splitting small and movable: moving a 200 MB tablet is easy, moving a 2 TB shard is a project. Instagram did the same with logical shards on Postgres (Instagram). Second, the metadata server is the hidden bottleneck: GFS had plenty of disk but one master. Third, write to memory and a log, flush sorted files later. Postgres also logs first (WAL, Replication), but it updates pages in place, while Bigtable never edits a file once written.
Servers and infrastructure
Everything above runs on Borg. The 2015 Borg paper describes a cluster manager running hundreds of thousands of jobs across clusters of up to tens of thousands of machines each. It packs latency-sensitive serving jobs (the paper names web search as one) and batch jobs on the same machines: 98% of machines in shared cells ran both. Resources that serving jobs reserve but do not use are lent to batch work. The paper estimates that keeping the two apart would need 20% to 30% more machines in a typical cell. That sharing is a big part of why one query is cheap.
Kubernetes was written by engineers who had worked on Borg. A 2015 Kubernetes post lists what came across: pods, services for naming and load balancing, labels, and one IP address per pod, which fixed Borg's shared-IP port planning. If your team runs Kubernetes (Shipping safely), it uses Google's lessons from running Search.
Physically, Google lists 30 active data centre locations in 11 countries. The SRE book (2017) explains the front door: DNS makes a rough first choice of site, then network load balancers spread packets over servers behind one virtual IP, using consistent hashing (Sharding) so adding a server does not break most open connections. Search goes to the nearest site because latency matters most; a big video upload may go to a less busy site because throughput matters more.
Famous decisions and what they taught
- Many cheap machines, with copies (2003). Software that expects failure made the system more reliable than its hardware. Lesson: design for failure first (Timeouts and retries).
- Split by document, index in RAM (from 2001). Each shard is fast and independent, but tail latency became the central problem. Lesson: a design choice moves the problem; know where it moved.
- From batch to incremental (2010). When freshness became a requirement, the batch job became a stream, which needs transactional, idempotent updates (Outbox and sagas).
When it broke: one character flagged the whole web
On 31 January 2009, between about 6:27 and 7:25 a.m. Pacific time, every Google search result carried the warning "This site may harm your computer". Marissa Mayer's post the same day explained it. Google keeps a list of sites known to host malware, updated from an outside partner. In that day's update, someone added the value / to the list by mistake. Since every URL contains /, every site matched. Each user saw it for roughly 40 minutes before the file was reverted. A staggered rollout limited the damage, and Google promised stronger checks on the file.
The lesson has nothing to do with search. A small data file reached the serving fleet with less checking than code gets. Treat config and data pushes like code: validate them ("this list may not match more than 0.1% of URLs"), roll out in stages, and watch a metric that would show the problem (Shipping safely). Facebook learned the same lesson (Facebook).
Build it
Build a tiny document-sharded search engine in TypeScript, the same search in Postgres, and a hedged-request helper you can use for real reads. All of it was run with Node 22, TypeScript and PostgreSQL 16 or later. You can also try the two core ideas in your browser: the Practice section has an auto-checked inverted index and a hedged-request exercise.
-
One shard: an inverted index with intersection and scoring
Each shard maps words to posting lists. A search intersects the lists, starting from the shortest, and scores each match so rare words count more.
TypeScript// shard.ts: one index shard, an inverted index over some of the documents export type Hit = { doc: number; score: number }; type Posting = { doc: number; tf: number }; // tf: times the word appears in doc export const tokenize = (text: string): string[] => text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); export class Shard { private postings = new Map<string, Posting[]>(); // word -> posting list private docLen = new Map<number, number>(); // doc -> number of words add(doc: number, text: string): void { const words = tokenize(text); this.docLen.set(doc, words.length); const counts = new Map<string, number>(); for (const w of words) counts.set(w, (counts.get(w) ?? 0) + 1); for (const [w, tf] of counts) { const list = this.postings.get(w) ?? []; list.push({ doc, tf }); this.postings.set(w, list); } } // Documents that contain ALL query words, best first search(query: string, k: number): Hit[] { const lists = [...new Set(tokenize(query))].map((w) => this.postings.get(w) ?? []); if (lists.length === 0 || lists.some((l) => l.length === 0)) return []; lists.sort((a, b) => a.length - b.length); // rarest word first: smallest list let scores = new Map(lists[0].map((p) => [p.doc, 0])); for (const list of lists) { const idf = Math.log(1 + this.docLen.size / list.length); // rare words weigh more const next = new Map<number, number>(); for (const p of list) { const sofar = scores.get(p.doc); if (sofar !== undefined) next.set(p.doc, sofar + (p.tf / this.docLen.get(p.doc)!) * idf); } scores = next; // keep only docs in every list } return [...scores].map(([doc, score]) => ({ doc, score })) .sort((a, b) => b.score - a.score).slice(0, k); } } -
Split documents across shards, fan out, merge
Documents go to shards by id, split by document like Google's. A query goes to all shards in parallel and each returns only its top k, so the merge is small. Each shard scores with its own document count, a simplification real engines also make.
TypeScript// search.ts: split documents across shards, fan a query out, merge the answers import { Shard, Hit } from './shard'; class MiniSearch { private shards: Shard[]; constructor(n: number) { this.shards = Array.from({ length: n }, () => new Shard()); } add(doc: number, text: string): void { this.shards[doc % this.shards.length].add(doc, text); // document-partitioned } async search(query: string, k = 3): Promise<Hit[]> { // Every shard answers in parallel with its own top k ... const parts = await Promise.all(this.shards.map(async (s) => s.search(query, k))); // ... and the root keeps the best k of all of them return parts.flat().sort((a, b) => b.score - a.score).slice(0, k); } } const docs = [ 'Jasmine rice 5 kg bag, best price in town', 'Soybean oil 5 litre bottle, fortified', 'Basmati rice 1 kg, aromatic, for biryani', 'Rice cooker 1.8 litre, non-stick pot', 'Red lentils 1 kg, split', 'Brown rice 5 kg, long grain, wholegrain', ]; const engine = new MiniSearch(3); docs.forEach((text, i) => engine.add(i + 1, text)); (async () => { for (const q of ['rice 5 kg', 'litre', 'rice biryani', 'sugar']) { const hits = await engine.search(q); const shown = hits.map((h) => `doc ${h.doc} (${h.score.toFixed(2)})`).join(', '); console.log(q.padEnd(12), shown || 'no match'); } })();Terminalnpm init -y && npm i -D typescript @types/node npx tsc --target es2022 --module commonjs --strict --types node --outDir dist shard.ts search.ts node dist/search.jsOutputrice 5 kg doc 6 (0.35), doc 1 (0.32) litre doc 2 (0.18), doc 4 (0.14) rice biryani doc 3 (0.26) sugar no matchDoc 3 (basmati, 1 kg) does not match "rice 5 kg" because every word must be present. Doc 4, the rice cooker, matches "litre" but scores lower than the oil, because "litre" is a smaller share of its words.
-
The same search in Postgres
A
tsvectorcolumn is the per-row word list, and a GIN index on it is the inverted index: word to rows. Weight the title above the body. Create the database with UTF-8 encoding if you will store names in other scripts, such as Hindi, Arabic or Bengali.SQLCREATE TABLE page ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, url text NOT NULL UNIQUE, title text NOT NULL, body text NOT NULL, -- normalised words with positions; a title match counts more than a body match doc tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', title), 'A') || setweight(to_tsvector('english', body), 'B')) STORED ); CREATE INDEX page_doc_gin ON page USING gin (doc); -- word -> list of rows INSERT INTO page (url, title, body) VALUES ('/rice/jasmine', 'Jasmine rice 5 kg', 'Everyday rice for busy kitchens. 5 kg bag.'), ('/rice/basmati', 'Basmati rice 1 kg', 'Long aromatic grains, the classic choice for biryani.'), ('/oil/soybean', 'Soybean oil 5 litre','Fortified with vitamin A. Cooking oil for frying.'), ('/kitchen/cooker', 'Rice cooker', 'Cooks 1.8 litre of rice. Non-stick pot.'), ('/blog/biryani', 'How to cook biryani','Use basmati rice, ghee, and whole spices.'); -- Search: intersect, rank, and cut a snippet around the matching words SELECT url, round(ts_rank(doc, q)::numeric, 3) AS rank, ts_headline('english', title || '. ' || body, q, 'MaxWords=14, MinWords=5') AS snippet FROM page, websearch_to_tsquery('english', 'basmati rice biryani') AS q WHERE doc @@ q ORDER BY rank DESC LIMIT 10;Outputurl | rank | snippet -----------------+-------+----------------------------------------------------------- /rice/basmati | 0.995 | <b>Basmati</b> <b>rice</b> 1 kg. Long aromatic grains, the classic choice for <b>biryani</b> /blog/biryani | 0.913 | <b>biryani</b>. Use <b>basmati</b> <b>rice</b>, ghee (2 rows)websearch_to_tsqueryturns the search box text into'basmati' & 'rice' & 'biryani': an intersection, as in step 1. It also accepts quotes for phrases ("5 kg" rice) andor. Check that the index is used; on a five-row table you must turn off sequential scans to see it, on a real table the planner picks it alone (Query plans).SQLSET enable_seqscan = off; -- only for this demo on a tiny table EXPLAIN (COSTS OFF) SELECT url FROM page WHERE doc @@ websearch_to_tsquery('english', 'rice biryani');OutputBitmap Heap Scan on page Recheck Cond: (doc @@ '''rice'' & ''biryani'''::tsquery) -> Bitmap Index Scan on page_doc_gin Index Cond: (doc @@ '''rice'' & ''biryani'''::tsquery) -
A hedged-request helper
This sends the call to the first replica. If it is still running after
hedgeAfterMs, or if it fails, it sends one backup to the second replica. The first answer wins and the other call is cancelled through itsAbortSignal. Use it only for reads or idempotent calls. The Practice section asks you to write a smaller version of this logic yourself.TypeScript// hedge.ts: ask one replica; if it is slow, also ask a second and take the first answer export type Call<T> = (replica: string, signal: AbortSignal) => Promise<T>; export async function hedged<T>(replicas: string[], call: Call<T>, hedgeAfterMs: number): Promise<T> { const controllers: AbortController[] = []; const send = (replica: string) => { const c = new AbortController(); controllers.push(c); return call(replica, c.signal); }; let backup: Promise<T> | undefined; const sendBackup = () => (backup ??= send(replicas[1])); // never more than one backup let timer: ReturnType<typeof setTimeout> | undefined; const first = send(replicas[0]); const racers: Promise<T>[] = [first]; if (replicas.length > 1) { racers.push(new Promise<T>((resolve, reject) => { // slow: hedge after the delay timer = setTimeout(() => sendBackup().then(resolve, reject), hedgeAfterMs); })); racers.push(first.catch(() => sendBackup())); // failed fast: back up now } try { return await Promise.any(racers); // whoever answers first wins } catch (e) { throw e instanceof AggregateError ? e.errors[0] : e; // all failed: show the first error } finally { clearTimeout(timer); controllers.forEach((c) => c.abort()); // cancel the loser } }With a real HTTP read, pass the signal to
fetchso the losing request is really cancelled:TypeScriptimport { hedged } from './hedge'; type SearchResult = { hits: { id: number; name: string }[] }; export function searchArticles(q: string): Promise<SearchResult> { const replicas = ['http://search-a.internal:4100', 'http://search-b.internal:4100']; return hedged(replicas, async (base, signal) => { const res = await fetch(`${base}/search?q=${encodeURIComponent(q)}`, { signal }); if (!res.ok) throw new Error(`${base} answered ${res.status}`); // 5xx: let the backup win return (await res.json()) as SearchResult; }, 50); // about your p95 for this call, in ms } -
Prove it with a fan-out simulation
Each query fans out to 20 shards and waits for all of them. About 1 call in 50 is slow (200 to 300 ms). Run 400 queries plain, then hedged after 20 ms.
TypeScript// tail.ts: 400 queries, each fanning out to 20 shards. About 1 call in 50 is very slow. import { hedged, Call } from './hedge'; let calls = 0; const fakeLeaf: Call<string> = (replica, signal) => { calls++; const slow = Math.random() < 0.02; // a GC pause, a busy disk ... const ms = slow ? 200 + Math.random() * 100 : 5 + Math.random() * 10; return new Promise((resolve, reject) => { const t = setTimeout(() => resolve(replica), ms); signal.addEventListener('abort', () => { clearTimeout(t); reject(new Error('aborted')); }); }); }; async function query(shards: number, hedge: boolean): Promise<number> { const start = performance.now(); await Promise.all(Array.from({ length: shards }, (_, i) => { const replicas = [`shard${i}-a`, `shard${i}-b`]; return hedge ? hedged(replicas, fakeLeaf, 20) : fakeLeaf(replicas[0], new AbortController().signal); })); return performance.now() - start; // a query waits for its slowest shard } const pct = (xs: number[], p: number) => xs.sort((a, b) => a - b)[Math.ceil((p / 100) * xs.length) - 1]; (async () => { for (const hedge of [false, true]) { calls = 0; const times: number[] = []; for (let batch = 0; batch < 40; batch++) { // 40 batches of 10 queries times.push(...await Promise.all(Array.from({ length: 10 }, () => query(20, hedge)))); } const extra = ((calls / 8000 - 1) * 100).toFixed(1); // 400 queries x 20 shards = 8000 console.log(`${hedge ? 'hedged' : 'plain '} p50 ${pct(times, 50).toFixed(0)} ms` + ` p99 ${pct(times, 99).toFixed(0)} ms calls ${calls} (+${extra}%)`); } })();Terminalnpx tsc --target es2022 --module commonjs --strict --types node --outDir dist hedge.ts tail.ts node dist/tail.jsOutputplain p50 14 ms p99 296 ms calls 8000 (+0.0%) hedged p50 15 ms p99 35 ms calls 8149 (+1.9%)Numbers change a little per run; the shape does not. With 20 shards and a 2% slow rate, about one plain query in three hits a slow shard (1 − 0.9820 is about 33%), so the plain p99 is the slow time. Hedging adds about 2% more calls and brings the p99 near the normal time, the same trade Google measured. Running far more queries at once in one Node process makes timers fire late and the numbers noisy; that is a limit of the simulation.
Bring it to your team
Use these when a design calls many services or shards for one screen, or when someone proposes a search feature.
- How many backend calls does this screen wait for, and what is the p99 of the slowest one?
- If one of those calls is slow, do we wait, time out and show partial results, or hedge to a replica?
- Is this read safe to send twice? If it writes anything, where is the idempotency key?
- For product search, have we measured Postgres full-text search with a GIN index before adding a search engine?
- Is the search index built from the live tables, or from a copy, so that heavy indexing never slows checkout?
- How are config and data files (price lists, block lists, flags) validated and rolled out in stages before every server reads them?
Red flags
- A dashboard or home screen that waits for every one of many calls with no timeout.
- Retries or hedges on a POST with no idempotency key.
- Hedging every request immediately, which doubles load and makes the tail worse.
LIKE '%rice%'on a large table as the product search.- A config push that goes to all servers at once with no validation step.
Practice
Build a tiny inverted index
Write buildIndex(docs), which turns a list of { id, text } documents into a Map from each word to the sorted ids of the documents that contain it, and search(index, query), which returns the ids of the documents that contain every word of the query. Lowercase all text and split it on anything that is not a letter or a digit. A word that appears twice in one document still appears once in its posting list. This is step 1 of the Build-it without the scoring.
// Build an inverted index: word -> sorted list of doc ids, with no repeats.
// Split text into lowercase words on anything that is not a-z or 0-9.
function buildIndex(docs) {
const index = new Map();
return index;
}
// Ids of the docs that contain EVERY word of the query, smallest id first.
// Start the intersection from the shortest posting list.
function search(index, query) {
return [];
}
const docs = [
{ id: 1, text: 'Jasmine rice 5 kg bag' },
{ id: 2, text: 'Soybean oil 5 litre' },
{ id: 3, text: 'Basmati rice 1 kg, for biryani' },
{ id: 4, text: 'Brown rice 5 kg, wholegrain rice' },
];
test('the posting list for rice is sorted and has no repeats', () => {
expect(buildIndex(docs).get('rice')).toEqual([1, 3, 4]);
});
test('words are split on spaces and punctuation', () => {
expect(buildIndex(docs).get('kg')).toEqual([1, 3, 4]);
});
test('search keeps only docs that contain every word', () => {
expect(search(buildIndex(docs), 'rice 5 kg')).toEqual([1, 4]);
});
test('search ignores upper and lower case', () => {
expect(search(buildIndex(docs), 'BASMATI Rice')).toEqual([3]);
});
test('a word that no doc contains gives no results', () => {
expect(search(buildIndex(docs), 'rice sugar')).toEqual([]);
});
Show a solution
const words = (text) => text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
function buildIndex(docs) {
const index = new Map();
for (const { id, text } of docs) {
for (const w of new Set(words(text))) { // a Set drops repeats inside one doc
if (!index.has(w)) index.set(w, []);
index.get(w).push(id);
}
}
for (const list of index.values()) list.sort((a, b) => a - b);
return index;
}
function search(index, query) {
const lists = [...new Set(words(query))].map((w) => index.get(w) ?? []);
if (lists.length === 0) return [];
lists.sort((a, b) => a.length - b.length); // rarest word first
let result = lists[0];
for (const list of lists.slice(1)) {
const inList = new Set(list);
result = result.filter((id) => inList.has(id)); // keep ids found in both
}
return result;
}Write a hedged request
Write hedged(replicas, call, hedgeAfterMs). It asks the first replica. If there is no answer after hedgeAfterMs, it also asks the second replica, and the first successful answer wins. If the first replica fails before the timer fires, it asks the second one at once instead of waiting. It must never send more than one backup. Promise.any resolves with the first promise that succeeds, which is useful here. Cancelling the loser is left out to keep the task short.
// Ask replicas[0]. If it has not answered after hedgeAfterMs, ALSO ask replicas[1].
// If replicas[0] fails before that, ask replicas[1] at once.
// Return the first successful answer. Never send more than one backup.
// call(replica) returns a Promise.
async function hedged(replicas, call, hedgeAfterMs) {
return call(replicas[0]);
}
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
test('a fast first replica wins and no backup is sent', async () => {
const asked = [];
const call = (r) => { asked.push(r); return wait(5, r); };
expect(await hedged(['a', 'b'], call, 50)).toBe('a');
expect(asked).toEqual(['a']);
});
test('a slow first replica gets a backup after the delay', async () => {
const call = (r) => (r === 'a' ? wait(400, 'a') : wait(5, 'b'));
const start = Date.now();
expect(await hedged(['a', 'b'], call, 30)).toBe('b');
expect(Date.now() - start).toBeLessThan(200);
});
test('a fast failure sends the backup at once, without waiting for the timer', async () => {
const call = (r) => (r === 'a' ? Promise.reject(new Error('500')) : wait(5, 'b'));
const start = Date.now();
expect(await hedged(['a', 'b'], call, 300)).toBe('b');
expect(Date.now() - start).toBeLessThan(150);
});
test('never more than one backup, even when the failure and the timer both happen', async () => {
const asked = [];
const call = (r) => { asked.push(r); return r === 'a' ? Promise.reject(new Error('500')) : wait(60, 'b'); };
await hedged(['a', 'b'], call, 20);
expect(asked).toEqual(['a', 'b']);
});
Show a solution
async function hedged(replicas, call, hedgeAfterMs) {
let backup = null;
const sendBackup = () => (backup ??= call(replicas[1])); // at most one backup
let timer;
const first = call(replicas[0]);
const racers = [
first,
new Promise((resolve, reject) => { // slow: hedge after the delay
timer = setTimeout(() => sendBackup().then(resolve, reject), hedgeAfterMs);
}),
first.catch(() => sendBackup()), // failed fast: back up now
];
try {
return await Promise.any(racers); // first success wins
} finally {
clearTimeout(timer);
}
}Check yourself
A query fans out to 100 leaf servers and waits for all of them. Each leaf is slow on 1% of requests. Roughly what share of queries is slow?
The query is slow if at least one leaf is slow: 1 − 0.99100 is about 0.63. The tempting answer, 1%, forgets that you wait for the slowest of 100. It is not 100% because most of the time every leaf happens to be fast.
Why did Google split its index by document rather than by word?
Splitting by document lets each shard intersect its own posting lists and return a short top list. Splitting by word means fewer shards per query (that is the trap in option 2) but long posting lists must cross the network to be intersected. The choice does make every query touch every shard, which is why tail latency became the central problem.
Your product search calls two replicas of a search service. Which hedging setup is the best default?
Hedging after the p95 time adds only a few percent of extra load and cuts the slow tail. Always sending two doubles the load. Retrying the same slow replica, or waiting long after an error, keeps the user waiting.
Flashcards
Key takeaways
- Search is an inverted index (word to sorted document ids), split by document across thousands of machines, held in memory and replicated.
- Every query fans out to every shard, so the slowest shard sets the speed. At fan-out, rare slowness becomes common.
- Hedged requests, partial results and small movable shards tame the tail for a few percent extra load.
- Indexing is a separate, always-running pipeline. Queries read finished copies, so heavy work never slows a search.
- The worst outages often come from a small config or data push. Validate it and roll it out in stages, like code.
Sources
- AI, personalization and the future of shopping, Google blog, 2025 (more than 5 trillion searches a year)
- How Search organizes information, Google, current page (index size)
- In-depth guide to how Google Search works, Google Search Central, updated 2025
- The Tail at Scale, Jeffrey Dean and Luiz André Barroso, Communications of the ACM, 2013
- Web Search for a Planet: The Google Cluster Architecture, Barroso, Dean, Hölzle, IEEE Micro, 2003
- Challenges in Building Large-Scale Information Retrieval Systems, Jeff Dean, WSDM keynote slides, 2009
- The Anatomy of a Large-Scale Hypertextual Web Search Engine, Sergey Brin and Lawrence Page, 1998
- Our new search index: Caffeine, Google Webmaster Central blog, 2010
- Large-scale Incremental Processing Using Distributed Transactions and Notifications (Percolator), Peng and Dabek, OSDI 2010
- MapReduce: Simplified Data Processing on Large Clusters, Dean and Ghemawat, OSDI 2004
- Bigtable: A Distributed Storage System for Structured Data, Chang et al., OSDI 2006
- The Google File System, Ghemawat, Gobioff, Leung, SOSP 2003
- A peek behind Colossus, Google's file system, Google Cloud blog, 2021
- Spanner: Google's Globally-Distributed Database, Corbett et al., OSDI 2012
- Large-scale cluster management at Google with Borg, Verma et al., EuroSys 2015
- Borg: The Predecessor to Kubernetes, Kubernetes blog, 2015
- Load Balancing at the Frontend, Site Reliability Engineering book, Google, 2017
- Discover our data center locations, Google, current page
- Network edge locations, Google Cloud documentation, updated 2026
- "This site may harm your computer" on every search result?!?!, Marissa Mayer, Official Google Blog, 2009