How to Run Client-Side Search in a Web Worker

This guide applies Offloading Work to Web Workers with Comlink to one of the most common heavy client-side workloads, within Core Web Vitals & Measurement: full-text or fuzzy search over a dataset shipped to the browser — documentation sites, product catalogues, command palettes, contact lists.

Client-side search has two expensive phases. Building the index (tokenising thousands of documents, computing term frequencies) can take hundreds of milliseconds and usually runs at startup, inflating Total Blocking Time and blocking early interactions. Querying (fuzzy matching against the index, scoring, sorting) runs on every keystroke and directly determines INP for typing. Both are pure computation with small inputs and outputs — the ideal shape for a worker.

Search with the index in a worker Sequence of the main thread posting the document set once to a worker to build the index, then sending queries and receiving lists of result IDs. Search with the index in a worker Main thread Worker Network fetch docs.json 3.2MB JSON query "cach" ids (top 20) The worker fetches and indexes the documents itself; the main thread only sends query strings and receives small arrays of IDs.

Rapid Diagnosis

  • Profile startup. In a throttled trace, look for a long task building the search index (calls into Lunr, FlexSearch, MiniSearch, Fuse.js or your own code) during or shortly after load.
  • Profile typing. Type a query; each keystroke's processing time should be dominated by the search call if this guide applies.
  • Check dataset size. Documents × average length. Above a few thousand documents, main-thread indexing becomes a noticeable long task on mobile.
  • Check result rendering. If rendering results costs more than searching, fix rendering (cap results, virtualise) first; a worker will not help that part.

Root Cause Analysis

1. Index built on the main thread at startup. Libraries are typically initialised in application code with the full document set, producing one long task.

2. Fuzzy matching per keystroke. Fuzzy algorithms are expensive; scoring every document on every keystroke scales linearly with the dataset.

3. Large data cloned to the worker. Teams move search to a worker but post the full document array from the main thread, paying a large structured-clone cost there instead.

4. Stale query results. Responses for earlier queries arrive after later ones and overwrite fresher results, causing flicker and wasted rendering.

Main-thread cost: in-page search vs worker search Bar chart comparing main-thread time for index build and per-keystroke query when search runs on the main thread versus in a worker. Main-thread cost: in-page search vs worker search Index build (main thread) 420ms Index build (worker) 0.5ms Query per key (main thread) 85ms Query per key (worker) 1.5ms 50ms

Step-by-Step Resolution

1. Fetch and index inside the worker

javascript
// search-worker.js
import MiniSearch from 'minisearch';
import * as Comlink from 'comlink';

let index;
const ready = (async () => {
  const docs = await (await fetch('/search/docs.json')).json();   // never touches main thread
  index = new MiniSearch({ fields: ['title', 'body'], storeFields: ['id'], searchOptions: { prefix: true, fuzzy: 0.2 } });
  index.addAll(docs);
})();

Comlink.expose({
  async search(q, limit = 20) {
    await ready;
    return index.search(q).slice(0, limit).map((r) => r.id);      // small result
  },
});
// trade-off: building in the worker still costs CPU and battery, just not main-
// thread time. For very large datasets, prebuild the index at deploy time and
// load the serialised index instead (MiniSearch.loadJSON) to skip indexing.

Expected outcome: no index-building long task on the main thread; startup TBT drops by the full indexing time.

2. Query from the main thread with stale-result protection

javascript
// search-client.js
import * as Comlink from 'comlink';
const api = Comlink.wrap(new Worker(new URL('./search-worker.js', import.meta.url), { type: 'module' }));
let seq = 0;
export async function search(q) {
  const mine = ++seq;
  const ids = await api.search(q);
  return mine === seq ? ids : null;              // drop responses for superseded queries
}
// trade-off: dropping stale responses means a very fast typist sees results
// only when they pause. That is usually what users want; if not, render stale
// results with a subdued style until the fresh ones arrive.

Expected outcome: each keystroke costs the main thread only a message post and, later, rendering a small result list.

3. Return IDs, render from main-thread data

Return IDs (or minimal display fields) and look up display data the main thread already holds — or a compact summary map — rather than returning full documents.

Expected outcome: responses stay a few hundred bytes; no deserialisation cost on arrival.

4. Prebuild the index for large corpora

For corpora above tens of thousands of documents, build the index at deploy time and ship it serialised. The worker loads it with a single JSON.parse (still in the worker) instead of indexing.

javascript
// build-index.mjs (run at build time)
import MiniSearch from 'minisearch';
import { readFileSync, writeFileSync } from 'node:fs';
const docs = JSON.parse(readFileSync('docs.json', 'utf8'));
const ms = new MiniSearch({ fields: ['title', 'body'], storeFields: ['id'] });
ms.addAll(docs);
writeFileSync('public/search/index.json', JSON.stringify(ms));
// trade-off: a serialised index is often larger than the raw documents. Check
// the compressed size; for some corpora, indexing in the worker is the better
// trade between bytes and CPU.

Expected outcome: search ready within a few hundred milliseconds of the worker starting, even on large sites.

Moving search off the main thread Four steps to relocate client-side search into a worker, from fetching inside the worker to prebuilding the index. Moving search off the main thread Fetch and index in the worker The document set never crosses the main thread Query with sequence numbers Drop responses for superseded queries Return IDs only Render from data the main thread already holds Prebuild large indexes at deploy time Load serialised JSON instead of indexing 1 2 3 4

Verification

In a throttled trace, startup should show no indexing task on the main thread, and typing should produce interactions well under 100ms, dominated by rendering. Use the Performance panel's thread selector to confirm the work now appears on the worker's thread. In RUM, compare INP for keyboard interactions on the search input before and after.

Lifecycle: When to Start the Worker

Starting a worker costs a script download, evaluation and (here) a data fetch. For a search box in the header of every page, start it on idle after load so it is ready when the user first focuses the field; for a command palette opened with a keyboard shortcut, start it on the first keydown of the modifier key, or on focus of the trigger. Do not start it during the critical rendering path — workers run on other threads, but their script and data downloads compete for bandwidth with LCP resources. A shared search worker across tabs is possible with a SharedWorker, covered in sharing one worker across tabs with SharedWorker, though for most sites a per-tab worker is simpler and fast enough.

Keeping the Index Fresh

Client-side indexes go stale when content changes. Version the document set (a hash in its URL, emitted by your build) and let the worker fetch the current version on startup; with immutable caching, unchanged versions cost nothing on repeat visits. For data that changes during a session — a user's own records in a CRM — apply incremental updates in the worker (index.add, index.discard) from the same messages that update the UI, rather than rebuilding. Rebuilds are the expensive path; reserve them for structural changes such as a new field becoming searchable.

FAQ

Which search library works best in a worker?

Any library without DOM dependencies works. MiniSearch and FlexSearch are compact and fast to query; Fuse.js is simple but slower for large datasets because it scans rather than indexes. Choose based on query features (prefix, fuzzy, field boosting) and serialised index size, then measure query time on a throttled device.

Should highlighting matches happen in the worker?

Computing match positions can be done in the worker and returned with the IDs; applying highlights is DOM work and belongs on the main thread. Keep the returned data small — positions for the visible results only.

Is a worker worth it for a few hundred documents?

Usually not. Querying a few hundred short documents takes a millisecond or two on the main thread. Workers add complexity and startup cost; reserve them for datasets where indexing or querying measurably exceeds the long-task threshold on your target devices.

What happens if the user searches before the index is ready?

The worker's search awaits the ready promise, so the first query simply resolves later. Show a "Loading search…" state in the results area rather than an empty list, so users do not conclude there are no matches. If early searches are common, start the worker earlier or ship a prebuilt index that loads faster than building one.