offline first search javascript

Building an Offline-First Search Experience

Offline-first search is not only about airplane mode. It is about predictable UX under unstable connectivity, constrained corporate networks, and high-latency mobile environments. Users should still be able to discover content even if your API is temporarily unavailable. With JavaScript, IndexedDB, and browser-native HNSW retrieval, you can ship search that behaves like a local app while remaining web-deployable. This guide shows a robust implementation pattern using altor-vec.

Install altor-vec: npm install altor-vec

Core architecture

Design the system around local state as the source of truth for retrieval. The network becomes a sync mechanism, not a hard runtime dependency. The minimal stack includes:

Data model in IndexedDB

Keep schema explicit and versioned. Avoid putting everything in one object store; separate binary assets and JSON metadata for easier migration.

// db.ts
import { openDB } from 'idb';

export const dbPromise = openDB('search-cache', 1, {
  upgrade(db) {
    db.createObjectStore('assets');     // key: 'index.bin', value: ArrayBuffer
    db.createObjectStore('meta');       // key: 'metadata', value: object[]
    db.createObjectStore('manifest');   // key: 'current', value: {version, checksum}
  },
});

export async function putIndex(bytes, metadata, manifest) {
  const db = await dbPromise;
  const tx = db.transaction(['assets', 'meta', 'manifest'], 'readwrite');
  await tx.objectStore('assets').put(bytes, 'index.bin');
  await tx.objectStore('meta').put(metadata, 'metadata');
  await tx.objectStore('manifest').put(manifest, 'current');
  await tx.done;
}

This structure supports atomic version swaps. You can download new artifacts in the background, validate checksums, then replace all stores in one transaction.

Service worker integration

Your service worker should precache shell files and fetch index artifacts using a stale-while-revalidate strategy. At startup, the app uses existing local assets immediately, while the worker checks for updates asynchronously.

// sw.js
self.addEventListener('install', (event) => {
  event.waitUntil(caches.open('app-shell-v1').then((c) => c.addAll([
    '/', '/blog/', '/offline.html'
  ])));
});

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.pathname.endsWith('/index.bin') || url.pathname.endsWith('/metadata.json')) {
    event.respondWith((async () => {
      const cache = await caches.open('search-assets-v1');
      const cached = await cache.match(event.request);
      const net = fetch(event.request).then((res) => {
        cache.put(event.request, res.clone());
        return res;
      }).catch(() => cached);
      return cached || net;
    })());
  }
});

The key UX win: search remains available even if remote update fetches fail. Users see stable behavior instead of error states.

Boot sequence for fast first query

  1. Try loading index bytes from IndexedDB.
  2. If found, initialize WasmSearchEngine immediately.
  3. In parallel, check remote manifest for newer version.
  4. Download and verify new artifacts in background.
  5. Prompt or hot-swap when safe.
import init, { WasmSearchEngine } from 'altor-vec';
import { dbPromise } from './db';

export async function loadEngineOfflineFirst() {
  await init();
  const db = await dbPromise;
  const bytes = await db.get('assets', 'index.bin');
  if (!bytes) throw new Error('No local index yet');
  return new WasmSearchEngine(new Uint8Array(bytes));
}

Offline search UX patterns

Technical correctness is not enough; UX messaging matters. Show whether results are from local cache and whether updates are pending. Helpful patterns include:

Users should never face a dead search box. Even degraded mode is better than disabled mode.

Embedding strategy for offline PWAs

If your app needs arbitrary query semantics while offline, you need local embedding generation. Keep this model in a web worker and lazy-load it on first search intent. If bundle budget is strict, consider a compromise: precompute vectors for frequent intents and fallback to lexical for long-tail queries when offline.

// worker message pattern
worker.postMessage({ type: 'init-model' });
worker.postMessage({ type: 'embed', text: query });

worker.onmessage = (e) => {
  if (e.data.type === 'embedding') {
    const hits = JSON.parse(engine.search(new Float32Array(e.data.vector), 6));
    render(hits);
  }
};

PWA search update pattern

Use a manifest endpoint (/search-manifest.json) containing version, file hashes, and dimensions. This prevents dimension mismatch bugs and partial updates.

{
  "version": "2026-03-20",
  "dim": 384,
  "index": { "path": "/search/index.2026-03-20.bin", "sha256": "..." },
  "metadata": { "path": "/search/meta.2026-03-20.json", "sha256": "..." }
}

Always verify hash before swap. If checksum fails, keep existing local assets and retry later.

Operational guardrails

Conclusion

An offline-first search experience is a reliability feature, not just a technical demo. By combining IndexedDB persistence, service-worker-driven updates, and local HNSW retrieval, you can deliver consistent search behavior regardless of network state. altor-vec keeps the retrieval core lightweight, while your app controls synchronization policy and UX. If your users expect speed and resilience, this architecture is often the best baseline.

CTA: npm install altor-vec · Star on GitHub