
IndexedDB as a Vector Store: Browser Embeddings That Persist
by Deep Parmar
CTO at Sunbots Innovations LLP | Director at Xwits Developers Pvt Ltd

Why IndexedDB for Vector Storage
When building Dhiya NPM's client-side RAG system, I needed persistent vector storage in the browser. The requirements were: store Float32Array embeddings (typically 384–768 dimensions), retrieve them efficiently for similarity search, persist across sessions, and handle collections up to ~50,000 chunks without hitting browser memory limits.
IndexedDB, the browser's built-in key-value database, meets these requirements. It's available in all modern browsers, supports binary data (including typed arrays), persists across sessions, and can handle the storage requirements of typical document collections without hitting browser storage quotas.
The Vector Store Architecture
Dhiya NPM's vector store has two IndexedDB object stores:
- chunks: Stores document chunks with their text content and metadata. Key: auto-incremented integer. Value:
{ id, documentId, content, metadata, timestamp } - embeddings: Stores the corresponding embeddings. Key: same integer as the corresponding chunk. Value:
{ id, vector: Float32Array }
Separating chunks and embeddings allows the chunk text to be retrieved and displayed without loading embedding vectors into memory when you don't need them.
Implementation: Opening and Writing
class BrowserVectorStore {
private db: IDBDatabase | null = null;
private readonly dbName: string;
private readonly version = 1;
constructor(storeName: string) {
this.dbName = `dhiya-${storeName}`;
}
async open(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('chunks')) {
db.createObjectStore('chunks', { keyPath: 'id', autoIncrement: true });
}
if (!db.objectStoreNames.contains('embeddings')) {
db.createObjectStore('embeddings', { keyPath: 'id' });
}
};
request.onsuccess = (e) => {
this.db = (e.target as IDBOpenDBRequest).result;
resolve();
};
request.onerror = () => reject(request.error);
});
}
async addChunk(content: string, vector: Float32Array, metadata: object): Promise<number> {
const id = await this.writeChunk({ content, metadata, timestamp: Date.now() });
await this.writeEmbedding({ id, vector });
return id;
}
private writeChunk(chunk: object): Promise<number> {
return new Promise((resolve, reject) => {
const tx = this.db!.transaction('chunks', 'readwrite');
const request = tx.objectStore('chunks').add(chunk);
request.onsuccess = () => resolve(request.result as number);
request.onerror = () => reject(request.error);
});
}
private writeEmbedding(embedding: object): Promise<void> {
return new Promise((resolve, reject) => {
const tx = this.db!.transaction('embeddings', 'readwrite');
tx.objectStore('embeddings').add(embedding);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
}
Implementation: Similarity Search
IndexedDB doesn't support vector operations natively — similarity search is implemented in JavaScript by loading all embeddings and computing cosine similarity:
async search(queryVector: Float32Array, topK: number = 5): Promise<SearchResult[]> {
// Load all embeddings from IndexedDB
const allEmbeddings = await this.getAllEmbeddings();
// Compute cosine similarity for each
const scores = allEmbeddings.map(({ id, vector }) => ({
id,
score: cosineSimilarity(queryVector, vector)
}));
// Sort by score descending, take top K
scores.sort((a, b) => b.score - a.score);
const topResults = scores.slice(0, topK);
// Fetch the corresponding chunks
const chunks = await Promise.all(
topResults.map(r => this.getChunk(r.id))
);
return topResults.map((r, i) => ({
content: chunks[i].content,
metadata: chunks[i].metadata,
score: r.score
}));
}
function cosineSimilarity(a: Float32Array, b: Float32Array): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
Performance: loading 1,000 embeddings (384-dim Float32Arrays) from IndexedDB takes approximately 8ms. Computing cosine similarity for 1,000 vectors takes approximately 3ms. Total retrieval time for a 1,000-chunk collection: ~11ms — well within the interactive response budget.
Storage Limits and Scaling Considerations
Browser storage quotas vary by browser and available disk space, but a practical limit is roughly 20% of available disk space. For most users, this is 5–50GB — far more than typical document collection embeddings require. A 384-dimension Float32Array is 1.5KB; 10,000 chunks would require 15MB of embedding storage, well within typical limits.
The binding constraint at scale is the brute-force similarity search: loading all embeddings into memory for search becomes slow at very large collections. Dhiya NPM includes warnings when collections approach 50,000 chunks and recommends segmenting large collections. For larger collections, a server-side vector database is the right tool.
Dhiya NPM abstracts this implementation behind a clean API. See the build tutorial → or read the introduction →
Frequently Asked Questions
Quick answers about this topic — also indexed by AI search engines via FAQPage schema.
Share this article:
