How this is builtGroundworkHow this is built
18 / 26 written chapters

I12 · Part 2 · How it runs

Search without a search service

Twenty-one JSON files written at build time, fetched on the first keystroke and scanned in the browser, with a test that fails the build when one grows too large.

7 min read1 diagram5 sections
05On this page
  1. Two kinds of index
  2. What goes into a row
  3. What happens when you type
  4. The size budget
  5. When it outgrows this

Two kinds of index

Search here has no server, no API key and no ranking. The build writes two kinds of index as static files, and the chapter reader's search box scans them in memory, in the rail of a written chapter.

IndexRouteOne row perRow holds
Per topic, 20 of themapp/{topic}/search-index.json/route.tsChapter in that topic, outlines includedid, and text: the chapter's visible words, compacted
Global, oneapp/search-index.json/route.tsWritten chapter in every topic that has levelsid, topicId, topicName, href, num, short, and text: title, short name, subtitle and topic name

Each per-topic route is five lines long. It declares dynamic = "force-static" and returns searchIndexResponse(topicId) from components/reader/searchIndex.ts. force-static makes the build call the handler once and store the body. In production the file is served like any other static asset, and no code runs.

What goes into a row

searchableText() turns a chapter into the words a reader can actually see:

  • It removes every <script> and <style> element together with its contents. A plain tag-stripping regex keeps what is inside a script, and for a long time the source of every interactive demo was indexed as if it were prose.
  • It strips the remaining tags.
  • It decodes 14 named entities, so &gt; becomes > and a search for => finds the chapters that show an arrow function.
  • It lowercases the text and collapses whitespace.

compactWords() then removes the repetition. It takes each distinct word once, sorts the words longest first, and drops any word that already appears inside the text kept so far. loop is dropped when loops has been kept, because a substring search for loop finds it inside loops anyway. A query term never contains a space, so this loses nothing. The first test in tests/search-index.test.ts checks this for every chapter on the site: every kept word is an original word, and every original word can still be found in the compacted text.

Measured on today's content, for the JavaScript topic: stripping tags alone gives an index of 173,862 bytes gzipped. Removing scripts and decoding entities brings it to 158,729. Compacting brings it to 77,347. Most of the saving comes from dropping repetition, not markup.

What happens when you type

Keystroke query not blank {topic}/search-index.json fetched once per reader view Chapter metas in the page, used until then /search-index.json every topic, titles only Match split on spaces; every term must be a substring Rail matches marked Other topics up to 12 links
Dashed lines are the fallback that runs until the topic's index arrives. A reader who never types downloads neither file.

A written chapter runs this search through useChapterSearch (components/chapter/useChapterSearch.ts), a hook TopicReader calls. It owns the whole state: a query string, a lazily fetched per-topic index, a lazily fetched global index, and a cancelled flag in the fetch effect's cleanup so a late response from a chapter you have since left cannot overwrite the current one. A failed fetch is swallowed. Until the topic index arrives, or for good if it never does, matching runs over the titles, short names and subtitles the page already received as props. So search always does something, and it gets better once the full index lands.

Matching is deliberately naive. The query is lowercased and split on whitespace, and a row matches when every term is a substring of its text. There is no ranking, no stemming and no typo tolerance. TopicReader renders the hook's state as ChapterSearch (components/chapter/ChapterSearch.tsx), the input and the other-topics list, and passes it into ChapterRail as a search slot alongside the match set as a separate matchInfo prop; the rail marks each matching .railLink and states how many chapters match, without touching the article next to it. Both props default to empty, so ChapterRail renders exactly as before for the architecture and Git readers, which pass neither. The global index is filtered to rows from other topics with the same test and capped at 12 links, so a search inside React can point you to the Node chapter that answers it.

The indexes live in the hook's own state, recreated by whichever component calls it, and RouteFade remounts a page on every client navigation. So the next chapter you open starts without them, and its first search requests the files again. They are static files, so this is a cheap repeat request, but it is still a request. Keeping the loaded index in a module-level cache would remove it.

The size budget

The third test in tests/search-index.test.ts gzips every written topic's index with Node's default level and fails if any is 85 KB or more, except System Design, which has a budget of 140 KB because its chapters grew with sources, worked tables and more topics, and DSA, which has a budget of 300 KB because its 48 chapters hold about 400,000 words; sharding its index by level is the first step if it grows again. It runs with the unit tests, before the build step, so an index that crosses the line fails the check. Sizes from the last production build, read from the files it wrote:

IndexRowsRawgzipBrotliOf the 85 KB budget
/notes (JavaScript)41189 KB75.5 KB65.4 KB89%
/react57189 KB74.4 KB64.0 KB87%
/system-design33284 KB118.2 KB99.4 KB84% of its own 140 KB budget
/interview27149 KB62.7 KB54.5 KB74%
/dsa48525 KB216.1 KB181.8 KB72% of its own 300 KB budget
/search-index.json (global)17449 KB11.9 KB10.0 KBnot tested
An outline-only topic, such as /redis211.2 KB0.4 KB0.3 KBnot tested

All 21 files together come to 931 KB raw. The architecture index is being rewritten as this is written: it was 11.2 KB gzipped at the last build and measures 23.5 KB on the working tree, so its row will change.

The budget has already moved once. It was 75 KB when the compaction landed on 15 September 2026, after which the JavaScript index was 70 KB. On 24 September, dry-run tables and checklists reached every JavaScript and System Design chapter (DSA and React already had them), and the JavaScript index went past 75 KB from real content growth. The limit was raised to 85 KB, with a comment beside it pointing to the roadmap's options. The JavaScript index has 9.5 KB of headroom left.

When it outgrows this

The pressure comes from the biggest topics getting longer, not from more topics. A new topic gets its own file and adds nothing to the others. Three steps are available, in order of cost:

  • Shard by level. Split a topic's index into beginner, intermediate and advanced files, and fetch the level the reader is in first. It keeps the same code and the same guarantees, and each file holds only one level's chapters.
  • Invert it. Precompute a map from each term to chapter IDs. This gives smaller files and faster matching, but prefix matching gets harder, and someone has to decide about stemming.
  • Use a service such as Typesense, Meilisearch or Algolia. That brings ranking and typo tolerance, along with a monthly bill, an API key in the CSP, and a round trip per keystroke. It is only worth it if readers come to rely on search, rather than using it now and then.
What was given up

The reader's rail once showed a hit count beside each chapter. Counting occurrences needs the repetition that made up most of the index's weight, so it went when the index was compacted. The rail now shows which chapters match, not how often.