How to Build a Search Engine in JavaScript (Without a Backend)

Most tutorials on building a search engine assume you'll spin up Elasticsearch, configure a Vector DB, or run a Python service somewhere. But if you're building a documentation site, a product catalog, or an internal tool, you don't always need server-side infrastructure. You can build a surprisingly capable semantic search engine that runs entirely in the browser—using embeddings, vector similarity, and about 150 lines of JavaScript.

This is not a toy. Companies like Algolia proved that client-side search can be fast and relevant. With modern embedding models that run in WebAssembly and vector libraries optimized for JavaScript, you can now add semantic search to any static site or SPA without touching your backend.

Why Client-Side Search Actually Makes Sense Now

Three things changed in the last two years. First, embedding models got small enough to run in the browser. Xenova's transformers.js port of BERT and MiniLM models runs at acceptable speeds in WebAssembly. Second, vector math libraries like UMAP-js and purpose-built tools like altor-vec make cosine similarity searches fast even on datasets with 10,000+ items. Third, IndexedDB gives you persistent storage, so you can cache embeddings and avoid recalculating them on every page load.

The result: you can build a search experience that feels instant, works offline, doesn't leak user queries to your server, and costs you zero in hosting fees.

The Architecture

A client-side semantic search engine has four parts:

1. Embedding generation. Convert your documents (blog posts, product descriptions, FAQ entries) into numerical vectors. You can do this at build time or lazily in the browser.

2. Vector storage. Store those embeddings in IndexedDB or in-memory if your dataset is small.

3. Query embedding. When a user types a search query, convert it to a vector using the same model.

4. Similarity search. Compare the query vector to all document vectors using cosine similarity, rank by score, return the top results.

The trick is doing steps 3 and 4 fast enough that the user doesn't notice latency. On a 2020 MacBook, you can search 5,000 documents in under 50ms if you use an optimized vector library.

Step 1: Generate Embeddings at Build Time

Use transformers.js to generate embeddings during your static site build. Install it as a dev dependency:

npm install @xenova/transformers --save-dev

Create a script that reads your content and outputs an embeddings.json file:

import { pipeline } from '@xenova/transformers';

const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');

const documents = [
  { id: 1, title: 'Getting Started with React', text: '...' },
  { id: 2, title: 'State Management Patterns', text: '...' },
  // ... your content
];

const embeddings = await Promise.all(
  documents.map(async (doc) => {
    const output = await extractor(doc.text, { pooling: 'mean', normalize: true });
    return {
      id: doc.id,
      title: doc.title,
      vector: Array.from(output.data)
    };
  })
);

fs.writeFileSync('public/embeddings.json', JSON.stringify(embeddings));

This runs once at build time. The all-MiniLM-L6-v2 model produces 384-dimensional vectors. A dataset of 1,000 documents results in a JSON file around 1.5MB—small enough to load on page visit or cache in a service worker.

Step 2: Load and Store Embeddings in the Browser

Fetch the embeddings file and store it in IndexedDB for persistence:

async function loadEmbeddings() {
  const db = await openDB('search-db', 1, {
    upgrade(db) {
      db.createObjectStore('embeddings', { keyPath: 'id' });
    }
  });

  const cached = await db.getAll('embeddings');
  if (cached.length > 0) return cached;

  const response = await fetch('/embeddings.json');
  const embeddings = await response.json();

  const tx = db.transaction('embeddings', 'readwrite');
  embeddings.forEach(e => tx.store.put(e));
  await tx.done;

  return embeddings;
}

This only hits the network once per user. After that, embeddings load from IndexedDB in under 10ms.

Step 3: Embed the User's Query

When the user types a query, you need to convert it to a vector using the same model. Running transformers.js in the browser is possible but slow—expect 300-500ms for a query embedding on mid-range hardware. You can mitigate this with a Web Worker:

// search-worker.js
import { pipeline } from '@xenova/transformers';
let extractor;

self.onmessage = async (e) => {
  if (!extractor) {
    extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
  }
  const output = await extractor(e.data, { pooling: 'mean', normalize: true });
  self.postMessage(Array.from(output.data));
};

Call it from your main thread:

const worker = new Worker('search-worker.js', { type: 'module' });

function embedQuery(query) {
  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data);
    worker.postMessage(query);
  });
}

The first query will be slow as the model loads. Subsequent queries run in 200-300ms, which feels acceptable if you show a loading spinner.

Step 4: Search with Vector Similarity

Now the interesting part. You have a query vector and a list of document vectors. Compute cosine similarity and rank:

function cosineSimilarity(a, b) {
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;
  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

async function search(query, embeddings) {
  const queryVec = await embedQuery(query);
  const results = embeddings.map(doc => ({
    ...doc,
    score: cosineSimilarity(queryVec, doc.vector)
  }));
  return results.sort((a, b) => b.score - a.score).slice(0, 10);
}

This works. But if you have 10,000 documents, calculating 10,000 cosine similarities every keystroke gets slow. Enter altor-vec.

Making It Fast with altor-vec

altor-vec is a zero-dependency vector search library optimized for JavaScript. It uses approximate nearest neighbor algorithms (HNSW) to reduce search complexity from O(n) to O(log n). Install it:

npm install altor-vec

Build an index once when embeddings load:

import { VectorIndex } from 'altor-vec';

const embeddings = await loadEmbeddings();
const index = new VectorIndex({ dimensions: 384 });

embeddings.forEach(doc => {
  index.add(doc.id, doc.vector);
});

Search by passing the query vector:

async function search(query) {
  const queryVec = await embedQuery(query);
  const results = index.search(queryVec, 10); // top 10 results
  return results.map(r => ({
    id: r.id,
    score: r.score,
    ...embeddings.find(e => e.id === r.id)
  }));
}

On a dataset of 5,000 documents, this runs in 15-30ms. The user experience is instant.

Handling Edge Cases

You'll want to add keyword fallback for exact matches. If someone searches "React.useState" and you have a document with that exact string, it should rank first even if semantic similarity is lower. Combine BM25 scoring with vector similarity:

function hybridScore(semanticScore, keywordScore) {
  return 0.7 * semanticScore + 0.3 * keywordScore;
}

Use a lightweight library like js-search for keyword scoring, or write a simple term-frequency function.

Also consider debouncing. Don't run a search on every keystroke—wait 150ms after the user stops typing. This reduces unnecessary computation and battery drain on mobile.

Real-World Performance

I tested this on a static documentation site with 3,200 pages. The embeddings file was 4.2MB gzipped. Initial load took 800ms on a fast connection, then cached. Query latency averaged 220ms for embedding + 18ms for vector search. Queries like "how to deploy to production" returned relevant results that keyword search missed entirely—picking up pages about CI/CD, environment variables, and Docker configs even when those exact words weren't in the query.

The accuracy isn't GPT-4 level, but it's dramatically better than naive substring matching, and it works offline.

When Not to Do This

If your dataset has 100,000+ items, client-side search gets impractical. Embedding files become too large, and even optimized indexes slow down. If you need sub-50ms search on huge datasets, reach for Typesense or Meilisearch.

Also, if you need real-time updates from multiple users, you need a server. Client-side search works for static or semi-static content that updates on deploy, not live data.

Frequently Asked Questions

Can I use a different embedding model?

Yes. transformers.js supports dozens of models from Hugging Face. Smaller models like paraphrase-MiniLM-L3-v2 are faster but less accurate. Larger models like mpnet-base-v2 are more accurate but slower. Test on your hardware and dataset.

How do I update the embeddings when content changes?

Re-run your build script and regenerate embeddings.json. If you use a service worker, update the cache version to force clients to fetch the new file. For incremental updates, you can store a timestamp with each embedding and only regenerate changed documents.

Does this work with TypeScript?

Yes. Both transformers.js and altor-vec ship with TypeScript definitions. Your IDE will autocomplete vector methods and catch type errors at compile time.

What about mobile performance?

Older Android devices struggle with WebAssembly execution. On a 2019 mid-range phone, query embedding took 600ms. Consider showing a loading state or falling back to keyword search on slow devices. You can detect performance with a quick benchmark on page load.

Want to skip the setup and start building? npm install altor-vec — Full documentation and examples at github.com/Altor-lab/altor-vec