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.
05On this page
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.
| Index | Route | One row per | Row holds |
|---|---|---|---|
| Per topic, 20 of them | app/{topic}/search-index.json/route.ts | Chapter in that topic, outlines included | id, and text: the chapter's visible words, compacted |
| Global, one | app/search-index.json/route.ts | Written chapter in every topic that has levels | id, 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
>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
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:
| Index | Rows | Raw | gzip | Brotli | Of the 85 KB budget |
|---|---|---|---|---|---|
/notes (JavaScript) | 41 | 189 KB | 75.5 KB | 65.4 KB | 89% |
/react | 57 | 189 KB | 74.4 KB | 64.0 KB | 87% |
/system-design | 33 | 284 KB | 118.2 KB | 99.4 KB | 84% of its own 140 KB budget |
/interview | 27 | 149 KB | 62.7 KB | 54.5 KB | 74% |
/dsa | 48 | 525 KB | 216.1 KB | 181.8 KB | 72% of its own 300 KB budget |
/search-index.json (global) | 174 | 49 KB | 11.9 KB | 10.0 KB | not tested |
An outline-only topic, such as /redis | 21 | 1.2 KB | 0.4 KB | 0.3 KB | not 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.
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.
Finished reading?
0 of 26 chapters read in this series