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.
npm install altor-vecCore 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:
- IndexedDB for storing
index.bin, metadata, and manifest info. - Service worker for install-time precaching and background update checks.
- altor-vec in WASM for local nearest-neighbor search.
- Optional local embedding model (worker) for fully offline semantic queries.
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
- Try loading index bytes from IndexedDB.
- If found, initialize
WasmSearchEngineimmediately. - In parallel, check remote manifest for newer version.
- Download and verify new artifacts in background.
- 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:
- A subtle “Offline mode” badge when network is down.
- Non-blocking toast: “New search index ready. Refresh to update.”
- Fallback lexical mode when embedding model is unavailable.
- Graceful empty states with query suggestions.
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
- Dimension checks: ensure query vector dimension equals index dimension before calling
search(). - Memory budget: monitor peak RAM on low-end devices; cap topK and payload sizes.
- Index size: split corpus by namespace/topic if single index becomes too large.
- Telemetry: log only aggregate metrics, not raw query strings, if privacy-sensitive.
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.