By the end of this tutorial, you'll have a notes app that runs an actual SQL database inside the browser tab, keeps working with the network switched off entirely, and reconciles every offline write against a server the moment connectivity comes back. The non-trivial part isn't loading a .wasm file — it's that the persistence layer sqlite-wasm depends on, the Origin Private File System, only exposes its fast synchronous handles inside a Web Worker, which forces a specific architecture: a worker-owned database, a promise-based bridge to the main thread, and a durable mutation queue that survives a tab close mid-sync.

Prerequisites

  • Node.js 20+ and npm 10+
  • A Vite 5+ project (any framework or vanilla JS — this tutorial uses vanilla JS for clarity)
  • A Chromium-based browser (or Firefox 111+) with Origin Private File System support
  • Basic familiarity with Web Workers, Promises, and the Fetch API
  • The official @sqlite.org/sqlite-wasm package — the ES module wrapper published directly by the SQLite project
  • A mock or real REST endpoint to sync against (this tutorial stubs one with a two-route Express server)

Step 1: Scaffold the Project and Install sqlite-wasm

Create the project and pull in the official package rather than a third-party wrapper — it tracks upstream SQLite releases directly and ships both the opfs and opfs-sahpool virtual file systems.

npm create vite@latest offline-notes -- --template vanilla
cd offline-notes
npm install @sqlite.org/sqlite-wasm

Expected output: @sqlite.org/sqlite-wasm appears under dependencies in package.json, and node_modules/@sqlite.org/sqlite-wasm contains a sqlite-wasm/jswasm directory with the compiled .wasm binary.

Because Vite pre-bundles dependencies by default, and sqlite-wasm ships its own worker and wasm assets that shouldn't be re-bundled, exclude it from dependency optimization:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    exclude: ['@sqlite.org/sqlite-wasm'],
  },
});

Step 2: Why the Database Has to Live in a Worker

OPFS ships two relevant virtual file systems: opfs, which uses Atomics.wait and therefore requires cross-origin isolation via Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy response headers, and opfs-sahpool, which pre-allocates a pool of FileSystemSyncAccessHandle objects and works without those headers at the cost of losing multi-tab concurrency — only one tab can hold the pool at a time, per SQLite's own persistence documentation. Both VFS options are worker-only; calling either from the main UI thread throws, because the synchronous file handles they rely on — part of the Origin Private File System surface of the File System API — aren't available outside a worker context. For a single-user notes app where two tabs writing simultaneously isn't a real scenario, opfs-sahpool is the simpler choice — no header configuration, no reverse proxy changes.

Step 3: Initialize SQLite with the opfs-sahpool VFS

Create src/db-worker.js. This file never touches the DOM — its only job is to own the database and answer messages from the main thread.

// src/db-worker.js
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';

let db;

async function initDb() {
  const sqlite3 = await sqlite3InitModule({ print: console.log, printErr: console.error });
  const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ name: 'notes-pool' });
  db = new poolUtil.OpfsSAHPoolDb('/notes.sqlite3');
  db.exec(`
    CREATE TABLE IF NOT EXISTS notes (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      body TEXT NOT NULL,
      updated_at INTEGER NOT NULL
    );
    CREATE TABLE IF NOT EXISTS mutation_queue (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      entity TEXT NOT NULL,
      op TEXT NOT NULL,
      payload TEXT NOT NULL,
      created_at INTEGER NOT NULL
    );
  `);
  return sqlite3.version.libVersion;
}

Checkpoint: initDb() resolves with a version string like 3.46.1 and, after a page refresh, the notes table's rows are still present — confirming OPFS persistence rather than an in-memory database that resets on reload.

Step 4: Wrap the Worker in a Promise-Based Client

The main thread can't call worker functions directly — it can only post messages. Rather than scattering onmessage callbacks through the UI code, wrap the message-passing in a small module that keeps a closure-held map of pending requests, matched by a request ID, and resolves the right promise when a response arrives. If you haven't worked through how a closure keeps a variable alive across asynchronous calls like this, our breakdown of the scope chain covers the mechanism this pattern depends on.

// src/db-client.js
const worker = new Worker(new URL('./db-worker.js', import.meta.url), { type: 'module' });
const pending = new Map();
let nextId = 0;

worker.onmessage = (e) => {
  const { id, result, error } = e.data;
  const resolver = pending.get(id);
  if (!resolver) return;
  pending.delete(id);
  error ? resolver.reject(error) : resolver.resolve(result);
};

function call(type, args) {
  const id = nextId++;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    worker.postMessage({ id, type, args });
  });
}

export const dbReady = call('init');
export const run = (sql, params) => call('run', { sql, params });
export const query = (sql, params) => call('query', { sql, params });

The corresponding onmessage handler inside db-worker.js dispatches on type, calls db.exec() for run and db.selectObjects() for query, and posts the result back with the same id it received — the closure in db-client.js is what lets each caller await its own specific response instead of racing every other pending call.

Step 5: Write Through a Mutation Queue Instead of Directly to "Synced" State

Every insert, update, or delete on notes gets written locally immediately — the UI never blocks on the network — and also recorded as a row in mutation_queue. That queue is the single source of truth for "what still needs to reach the server."

// src/notes-repo.js
import { run } from './db-client.js';

export async function saveNote(note) {
  const now = Date.now();
  await run(
    `INSERT INTO notes (id, title, body, updated_at) VALUES (?, ?, ?, ?)
     ON CONFLICT(id) DO UPDATE SET title = excluded.title, body = excluded.body, updated_at = excluded.updated_at`,
    [note.id, note.title, note.body, now]
  );
  await run(
    `INSERT INTO mutation_queue (entity, op, payload, created_at) VALUES (?, ?, ?, ?)`,
    ['notes', 'upsert', JSON.stringify({ ...note, updated_at: now }), now]
  );
}

Expected output: a note saved with the network disabled (toggle Offline in DevTools → Network) still appears instantly in the UI, and a row with op = 'upsert' accumulates in mutation_queue.

Step 6: Batch and Sync the Queue on Reconnect

When the online event fires, read every queued row, group it by entity so a sync of 40 note edits and 3 tag deletions becomes two batched requests instead of 43 individual ones, then clear only the rows the server acknowledged. Grouping by a key like this is exactly the case covered in our walkthrough of replacing manual grouping logic with the native Object.groupBy() — it removes the accumulator boilerplate a reduce()-based grouping function would otherwise need here.

// src/sync.js
import { query, run } from './db-client.js';

async function flushQueue() {
  const rows = await query('SELECT * FROM mutation_queue ORDER BY created_at ASC');
  if (rows.length === 0) return;

  const grouped = Object.groupBy(rows, (row) => row.entity);

  for (const [entity, batch] of Object.entries(grouped)) {
    const res = await fetch(`/api/${entity}/sync`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(batch.map((r) => JSON.parse(r.payload))),
    });
    if (!res.ok) continue; // leave this batch queued, retry on next reconnect
    const ids = batch.map((r) => r.id).join(',');
    await run(`DELETE FROM mutation_queue WHERE id IN (${ids})`);
  }
}

window.addEventListener('online', flushQueue);

Checkpoint: disable the network, save three notes, re-enable the network, and confirm in the Network tab that exactly one POST /api/notes/sync request fires with all three notes in its body, followed by mutation_queue returning to zero rows.

Step 7: Resolve Conflicts with Last-Write-Wins

If the same note was also edited from another device while this tab was offline, the server needs a rule for which version wins. The simplest defensible rule is last-write-wins by the updated_at timestamp captured in Step 5: the server compares the incoming updated_at against the one it has stored and only applies the write if the incoming value is newer. This is not a substitute for a CRDT-based merge if the app needs field-level conflict resolution — for a single-user notes app, LWW is sufficient and the timestamp comparison lives entirely in a few lines of server code, not in this client.

Step 8: Cache the App Shell So It Boots Offline Too

The mutation queue solves data persistence, but a hard refresh with no network still needs the JS, CSS, and the .wasm binary itself to load from somewhere. A minimal service worker precaches those assets.

// public/sw.js
const CACHE = 'notes-shell-v1';
const ASSETS = ['/', '/index.html', '/src/main.js', '/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm'];

self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)));
});

self.addEventListener('fetch', (e) => {
  e.respondWith(caches.match(e.request).then((cached) => cached || fetch(e.request)));
});

Register it once in main.js with navigator.serviceWorker.register('/sw.js'). Checkpoint: with DevTools → Application → Service Workers showing "activated," toggle Offline and do a full page reload — the app shell and the database should both load with zero network requests.

Common Errors

  • OPFS is not available in this context — the worker file was never actually loaded as a module worker, or the database code was called from main.js directly instead of db-worker.js. OPFS sync handles only exist inside a worker.
  • sqlite3.wasm returns a 404 — Vite's optimizeDeps.exclude from Step 1 was skipped, so the dependency got pre-bundled and its relative asset paths broke. Re-check vite.config.js.
  • NoModificationAllowedError from the sahpool VFS — a previous tab crashed or was closed without releasing its pool handle. The pool has a fixed initialCapacity; call poolUtil.reset() during development to clear stale handles, and expect this to resolve itself for real users on their next full page load.
  • Sync duplicates notes after a flaky connection — the DELETE FROM mutation_queue in Step 6 must run only after a res.ok check; if the fetch throws or times out, leave the rows queued rather than deleting them optimistically.

Worth noting separately: WASM linear memory is not garbage collected the way JS objects are — the memory sqlite-wasm allocates for large result sets doesn't shrink back down automatically between queries. It's a different failure mode from the JS-side leaks covered in our look at how Node.js applications slowly exhaust memory, but the debugging instinct is the same: watch the number climb across repeated operations, not just after one.

Key Takeaways

  • OPFS's fast synchronous file handles are worker-only, which is why the database, not just its worker wrapper, has to live off the main thread.
  • opfs-sahpool avoids the COOP/COEP header requirement that the plain opfs VFS needs, trading away multi-tab concurrency in exchange.
  • Writing every mutation to a durable queue table — rather than trying to track "dirty" rows after the fact — is what makes the sync step idempotent and resumable across tab closes.
  • Object.groupBy() turns "sync everything that changed" into batched, per-entity requests without a manual reduce accumulator.
  • A service worker precaching the app shell and a SQL-level mutation queue are solving two different problems — asset availability and data durability — and an offline-first app needs both.