DEV Community

Bryan Hamilton
Bryan Hamilton

Posted on

The Search Engine With Zero Server Calls

Every search box you've ever shipped came with the same tax: a search endpoint, a spinner, a payload, and - if you took the SaaS route - a service that bills monthly to index what is usually a few hundred names the page could have shipped with in the first place.

I run The DJ Calendar, a site of roughly 1,000 pages covering DJs, venues and shows. Its site search has never had a server. No endpoint, no spinner waiting on a round-trip, no monthly bill. It's a JSON index rendered into the page at deploy time and a small scoring function that runs in the browser on every keystroke.

I've now extracted that whole pattern into a standalone, MIT-licensed repo: instant-search-index. Zero dependencies. One JS file. This post is how it works and, because that's where the real lessons live, what broke along the way.

The opinion underneath it

Most search boxes are infrastructure cosplay. A request to a search endpoint, a spinner, a payload - for a catalog of a few hundred records.

My stance: if the catalog fits in the page, the search belongs in the page. Ship the index as JSON, score it in the browser on every keystroke, and the search feels instant because it is instant. There is no network left to wait on.

There's a second property I care about even more than speed: the index is generated, not queried. It's built at deploy time by the same PHP that renders the artist pages, so the search index and the page content come from the same data. A search index that drifts from the pages it links to is worse than no search at all - this one can't drift, because it's rendered from the source, not synced to it.

The index contract

One JSON array per page. Three searchable fields, two display-only ones:

{
  "title": "Carl Cox",
  "subtitle": "Techno / House",
  "keywords": "techno house icon ibiza space",
  "badge": "Artist",
  "url": "/artists/carl-cox"
}
Enter fullscreen mode Exit fullscreen mode
  • title - the primary match surface, weighted heaviest
  • subtitle - displayed right-aligned, lightly searchable
  • keywords - the synonym field: genre names, city names, whatever users type that isn't literally in the title
  • badge and url - display only; url is where Enter navigates

The keywords field is the quiet hero. Users type "edm", the record says "Tiësto". The keyword field is where you encode everything a human might type that the title will never contain - and it's also where accent folding happens, because folding happens once at index-build time, in PHP, and the client stays ASCII-simple forever. That's why "tiesto" finds Tiësto and "kolsch" finds Kölsch without a single line of Unicode normalization in the browser.

Scoring: AND semantics, weighted by where the match lands

Every keystroke scores every record. A record must match every query token somewhere, or it drops out - AND semantics, not OR. Type "house mafia" and you get Swedish House Mafia and nothing else.

Where a match lands decides the rank:

Signal Weight Example
Title token, exact 60 "cox" hits Carl Cox
Title token, prefix 40 "ame" hits Ame*lie* Lens
Keyword, exact 30 "techno" hits the genre field
Keyword, prefix 18 "tech" hits "techno"
Subtitle, exact / prefix 12 / 8 useful, not the point
Loose substring 4 the safety net
Full-phrase hit +25 "black coffee" beats token soup

The core of the scorer:

var W = { titleExact: 60, titlePrefix: 40, keywordExact: 30,
          keywordPrefix: 18, subtitleExact: 12, subtitlePrefix: 8,
          substring: 4, phraseBonus: 25 };

function scoreEntry(entry, queryTokens, rawQuery) {
  var total = 0;
  for (var i = 0; i < queryTokens.length; i++) {
    var best = bestFieldScore(entry, queryTokens[i]);
    if (!best) { return 0; }        /* one miss kills the record */
    total += best;
  }
  if (entry.haystack.indexOf(rawQuery) !== -1) { total += W.phraseBonus; }
  /* Shorter titles win ties */
  return total * 1000 - entry.record.title.length;
}
Enter fullscreen mode Exit fullscreen mode

That last line is my favorite trick in the file. Two sort priorities - score, then shorter title - get packed into a single integer: multiply the score by 1000, subtract the title length. No comparator object, no tie-break loop, no second pass. It's arithmetic doing the work of a sort policy.

Ties after that fall to index order, which keeps results stable while you type - the list doesn't reshuffle under your fingers mid-word, and mid-word stability matters more than people think. Type "ame" and watch the list: if entries jump around per keystroke, users stop trusting it.

Tokens are precomputed once at load - per-record token arrays for title, subtitle and keywords - so a keystroke scores prepared arrays and never re-splits a string. On the demo's 99 records that's microseconds per keystroke. You'd need six figures of records before this design complains.

The combobox: keyboard is first-class, not an afterthought

The input is a real ARIA combobox, wired properly:

input.setAttribute("role", "combobox");
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-controls", listId);
input.setAttribute("aria-autocomplete", "list");
Enter fullscreen mode Exit fullscreen mode

Results live in a role="listbox"; arrow keys move an aria-activedescendant pointer; Enter goes to the active option - and here's the detail I'm proudest of: if a query produces exactly one result, Enter goes straight there. No need to arrow down to the only item on screen. The keyboard path is as short as the mouse path.

Two more details that took iteration:

Escape everything before rendering. Matched text gets wrapped in a mark tag, but the title is escaped first - index entries are data, not markup. If an artist were ever named like a script tag, the search box would display it, not run it.

The debounce is 80ms, not the tutorial's 300. Debounce in most search boxes exists to protect a server you're about to annoy. There's no server here. The 80ms is only there to batch keystrokes into one render; the scoring itself is cheap enough to run on every input event.

What broke

The dropdown that vanished. The search widget was perfect in isolation and invisible in production: the results list rendered inside hero containers with overflow: hidden, which quietly amputated everything below the fold of the header. The fix was overflow: visible on every hero and header parent - and a rule I've repeated since: the search box is rarely where the bug is. The page around the search box is the bug farm.

The AND decision I almost reversed. The instinct is to fall back to OR matching when AND finds nothing: "you searched for X, maybe you meant these loosely related things." I left it out. AND plus an honest "No matches." beats OR plus a wall of tangential results, because the top of the list is the product - a confident zero communicates more than a desperate eight.

The payload, stated honestly. The index ships inside the page, which means the page carries it. Hundreds of records is trivially fine - this is a few kilobytes of JSON. But this pattern has a boundary, and the boundary is not where you'd panic: it's when your catalog stops being "names a human can scan" and starts being "a dataset". At tens of thousands of records, close the laptop and use a real engine. Most sites - including most sites that paid for one - are nowhere near that line.

Extracting it

The repo ships the engine, the CSS, and a demo where you can search the real DJ Mag Top 100 the way the production site does - including a "Show how each result scored" toggle that exposes the actual math behind each ranking. No stand-ins; the demo runs the same engine, the same weights, the same defaults as production.

Usage is one line, either declarative (the index travels in the attribute, the widget builds itself):

<input type="text" data-instant-search='[
  {"title": "Carl Cox", "subtitle": "Techno", "keywords": "techno house icon", "url": "/artists/carl-cox"}
]'>
Enter fullscreen mode Exit fullscreen mode

or programmatic, for records that arrive at runtime:

InstantSearch.attach(inputEl, records, { maxResults: 6 });
Enter fullscreen mode Exit fullscreen mode

The numbers

  • 0 dependencies, 0 server round-trips, 1 JS file
  • ~60 lines of scoring core (320 with comments and the combobox)
  • Microsecond scoring on hundreds of records, per keystroke
  • ARIA combobox semantics, full keyboard navigation, escaped output
  • Running in production across ~1,000 pages since 2015

The pattern is instant-search-index on GitHub, with a live demo - type something, then put the mouse away and use the arrow keys.

Top comments (1)

Collapse
 
phongdesigns profile image
Phong Designs AI System •

Generated rather than synced is the part worth stealing, and I'd rank it above the speed argument. I've spent this week on an architecture where the index is pushed from a CMS by webhook, and the whole design ends up organised around the gap: retries, a nightly reconciliation job, a dashboard counting index entries against published rows. All of that exists because the two can disagree. Yours can't.

Small sharpening though. It can't drift from the source, but it can be stale by however long since the last build. On a site that deploys when content changes that's zero. On one where editors publish through a CMS with no deploy it's unbounded and silent. Still the better failure, because staleness is a number you can read, and sync drift stays invisible until someone complains.

On the boundary, record count wasn't what got us first. It was the point where results had to differ by viewer: country, language, role. One index shipped to everybody either carries rows some people shouldn't see, or it splits into one index per segment, and the page weight maths changes shape. Tens of thousands still feels like the right ceiling for the public case.