Introduction: The Allure of Client‑Side Databases

Many modern web applications reach for a client‑side SQL layer—often a WebAssembly‑compiled SQLite engine wrapped around the browser’s IndexedDB store—because it promises “offline first”, “instant query response”, and “single‑source‑of‑truth” semantics. While this sounds convenient, the reality beneath the surface is fraught with performance cliffs, data‑loss scenarios, and security blind spots that can undermine any mission‑critical product.

What “Browser‑Based SQLite” Actually Is

The typical stack looks like this:

/* High‑level view */
+-------------------+        +-------------------+
|   SQLite.wasm     |  --->  |   IndexedDB API   |
+-------------------+        +-------------------+

/* The wasm module translates SQL calls into IndexedDB
   transactions, persisting the .sqlite file in the
   browser’s sandboxed storage area. */

The wasm binary is loaded once per page load, and every SQL statement becomes a series of asynchronous IndexedDB reads/writes. Because the underlying storage is sandboxed per‑origin, developers often assume the data is safe, immutable, and automatically synchronized across devices. Those assumptions are wrong.

Hidden Internals That Bite You

1. Transactional Guarantees Are Not What You Expect
IndexedDB provides atomicity only at the object‑store level. When SQLite attempts to batch multiple page writes into a single transaction, the wasm shim must serialize those writes into separate IndexedDB transactions. If the browser is terminated mid‑batch (e.g., a crash or forced tab close), the SQLite file can become partially written, leaving the database in a corrupted state that is hard to detect.

// Example of a multi‑statement transaction
await db.exec(`
  BEGIN;
  INSERT INTO orders VALUES (1, 'widget', 10);
  UPDATE inventory SET qty = qty - 10 WHERE sku = 'widget';
  COMMIT;
`);
/* If the tab crashes after the first INSERT, the second UPDATE never
   runs, but the COMMIT still reports success to the caller. */

2. Size Limits Vary by Browser and Device
Chrome caps IndexedDB at roughly 6 GB per origin, Firefox at 2 GB, Safari at 500 MB on iOS. SQLite files grow linearly with data, so a “big‑data” app can silently hit the quota and start failing writes. The failure is surfaced as a generic “QuotaExceededError”, which is easy to miss in production logs.

3. No Built‑in Conflict Resolution
Offline edits made on two devices generate divergent SQLite file versions. When the user reconnects, the wasm shim simply overwrites the older version unless you implement a custom merge layer. This “last‑write‑wins” behavior can silently discard user data.

// Naïve sync that overwrites
async function syncToServer() {
  const blob = await db.export(); // SQLite file as Blob
  await fetch('/api/sync', { method: 'POST', body: blob });
}

/* A robust solution would diff the transaction log,
   resolve conflicts, and only push deltas. */

4. Security Misconceptions
Data stored in IndexedDB is readable by any script running on the same origin. If a third‑party script is injected (via a compromised CDN or a malicious extension), it can open the SQLite file, dump tables, and exfiltrate sensitive records. Unlike server‑side databases, there is no role‑based access control at the storage layer.

When the Approach Breaks: Real‑World Failure Cases

1. Mobile Banking App* –* A banking startup shipped an offline transaction manager using SQLite.wasm. After a Chrome update, the IndexedDB quota on Android dropped from 6 GB to 2 GB, causing “QuotaExceededError” on users with large transaction histories. The app displayed a generic “Sync failed” message, and customers lost weeks of transaction data.

2. *Collaborative Editing Tool* –* Two users edited the same document offline on separate devices. Upon reconnection, the merge algorithm simply chose the later‑modified SQLite file, wiping out the other user’s edits. The lack of a change‑set log made it impossible to reconstruct the lost changes.

3. *Healthcare Portal* –* An injected script from a third‑party analytics provider accessed the IndexedDB store, read the patient table, and posted it to an external endpoint. Because the data was stored in plain SQLite format, the breach was discovered only after a forensic audit.

Safer Alternatives for Critical Persistence

Server‑Side API with Optimistic Concurrency
Keep the authoritative copy on a backend service. Use a lightweight client cache (e.g., localForage) for UI responsiveness, and sync changes via PATCH requests that include a version token.

// Example optimistic PATCH
async function updateOrder(orderId, changes) {
  const response = await fetch(`/api/orders/${orderId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json', 'If-Match': etag },
    body: JSON.stringify(changes)
  });
  if (response.status === 412) {
    // Conflict – fetch fresh data and retry
    const fresh = await response.json();
    // merge UI state, then retry
  }
}

Structured Sync Libraries
Libraries like RxDB or PouchDB implement conflict‑free replicated data types (CRDTs) on top of IndexedDB and provide automatic sync to CouchDB‑compatible servers. They handle version vectors, merge conflicts, and quota monitoring out of the box.

// Simple RxDB setup
import { createRxDatabase, addRxPlugin } from 'rxdb';
import { RxDBReplicationCouchDBPlugin } from 'rxdb/plugins/replication-couchdb';

addRxPlugin(RxDBReplicationCouchDBPlugin);

const db = await createRxDatabase({
  name: 'mydb',
  adapter: 'indexeddb',
  password: 'super‑secret' // optional encryption
});

const orders = await db.collection({
  name: 'orders',
  schema: {
    version: 0,
    type: 'object',
    properties: {
      id: { type: 'string', primary: true },
      sku: { type: 'string' },
      qty: { type: 'integer' }
    }
  }
});

orders.syncCouchDB({
  url: 'https://mycouchdb.example.com/orders',
  waitForLeadership: true,
  direction: { pull: true, push: true }
});

Encrypt Sensitive Records Before Storing
If you must keep data client‑side, encrypt it with a key derived from a user‑provided passphrase (never store the key in localStorage). Use the Web Crypto API to encrypt individual fields, reducing the impact of a script injection.

// Encrypt a record before IndexedDB write
async function encryptRecord(record, password) {
  const enc = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    enc.encode(password),