Engineering

Client-Side Storage for Local-First Apps

SQLite WASM, OPFS, and reactive queries — the layer sync engines write into and Vue reads from

Learning Objectives

By the end of this module you will be able to:

  • Explain how SQLite runs in a browser via WebAssembly and what sandbox constraints that implies.
  • Compare OPFS, IndexedDB VFS, and in-memory storage modes by performance and persistence characteristics.
  • Identify the maturity and trade-offs of wa-sqlite versus the official SQLite WASM build.
  • Describe how reactive query architectures bridge SQLite state and Vue 3's reactivity system.
  • Integrate Drizzle ORM in a WASM context for type-safe client queries.

Core Concepts

SQLite as the Universal Client Database

SQLite has become the standard client-side storage solution for local-first applications across platforms — web, mobile, and desktop. PowerSync uses WASM SQLite in browsers and native SQLite on mobile, while ElectricSQL supports both SQLite and PGlite (a WASM Postgres implementation). This convergence is not accidental: SQLite is embeddable, requires zero administration, and speaks the same SQL dialect as your backend Postgres — a significant advantage when sync engines translate rows between the two.

The critical capability gap with alternatives is query expressiveness. SQLite compiled to WASM supports the full SQL feature set: JOINs, GROUP BY, subqueries, window functions. IndexedDB, the browser's native structured storage, provides only key-value iteration — no joins, no aggregations. For any relational data model, SQLite WASM is the only viable choice.

The Storage Layer Stack

Running SQLite in a browser requires two components working together: the SQLite engine compiled to WASM, and a Virtual File System (VFS) that handles persistence. The VFS is where the meaningful architectural decisions live.

Fig 1
In-Memory Fastest No persistence Main thread OK sql.js / any WASM build IndexedDB VFS Moderate speed Persistent Main thread OK IDBBatchAtomicVFS OPFS Fastest persistent Persistent Worker only OPFSCoopSyncVFS
The three storage modes for SQLite WASM, from fastest to most persistent

The Origin Private File System (OPFS)

OPFS provides a dedicated, sandboxed file system for each origin. Unlike IndexedDB, OPFS gives SQLite byte-level file access, which is exactly what the SQLite page cache and WAL mode expect. The synchronous access path via createSyncAccessHandle delivers performance close to native I/O.

The catch is architectural: the synchronous OPFS API is only available inside Web Workers. This means your SQLite instance must live in a worker and communicate with the main thread via message passing. This adds complexity upfront but pays off in two ways — the UI thread stays unblocked during queries, and the worker becomes the natural home for sync engine coordination.

OPFS and browser eviction

OPFS data survives browser restarts and tab closures. However, Windows storage cleanup mechanisms can remove OPFS data regardless of persistent storage permissions — a documented Edge behavior worth accounting for in user-facing error handling. Call navigator.storage.persist() to request permanent retention.

wa-sqlite vs the Official SQLite WASM Build

Two maintained options exist for the WASM engine itself.

The official SQLite WASM build launched as a beta in 2022 and is now a first-class SQLite deliverable, supported alongside the traditional C library. It provides both synchronous and asynchronous JavaScript APIs directly from the SQLite project.

wa-sqlite is an independent WASM build that reached version 1.0 in July 2024. Its defining feature is the breadth of VFS implementations it ships: OPFSCoopSyncVFS, OPFSPermutedVFS, OPFSAdaptiveVFS, and IndexedDB-backed VFS options. This makes wa-sqlite the more pragmatic choice today — particularly because its OPFSCoopSyncVFS does not require COOP/COEP HTTP headers, which are otherwise necessary to enable SharedArrayBuffer (required by some synchronous OPFS paths).

wa-sqlite's OPFSCoopSyncVFS is the current best-practice recommendation for OPFS-backed SQLite persistence, offering cross-browser support (Chrome, Firefox, Safari, Edge) and strong performance without header configuration overhead.

Reactive Queries: Bridging SQLite and Vue

The last piece of the architecture is reactivity. A sync engine writes into SQLite. A Vue component needs to display the current state of a query. The naive solution — polling on a timer — is wasteful and introduces visible lag.

A reactive query system solves this by tracking which tables each SELECT statement touches. When any write occurs, the system checks which tables were modified and re-executes only the affected subscriptions. The result is a "Write → Notification → Re-render" cycle that operates at microsecond scale, not the 10–100ms of typical async patterns.

This architecture supports hundreds of active subscriptions without degrading UI thread latency. It maps directly onto Vue 3's reactivity model: a live query becomes a ref or computed that Vue treats like any other reactive dependency. Complex loading states become largely unnecessary because the local data is always present and always current.

Compare & Contrast

Storage Option Comparison

In-MemoryIndexedDB VFSOPFS
PersistenceNone — lost on reloadYesYes
PerformanceFastest~10ms/op~0.01ms/op
Thread constraintAnyAnyWorker only
Header requirementsNoneNoneNone (OPFSCoopSyncVFS)
Best forTests, ephemeral stateSimpler setup, wider compatProduction local-first apps

IndexedDB operations in Chrome take approximately 10ms each, while SQLite WASM operations typically complete in 0.01ms — roughly a 1000x difference. For an app syncing thousands of rows, this gap is the difference between a responsive UI and a janky one.

wa-sqlite vs Official SQLite WASM Build

wa-sqliteOfficial SQLite WASM
MaintainerIndependent (rhashimoto)SQLite project
Version 1.0July 2024Beta 2022, now stable
VFS optionsMany (OPFS, IDB, in-memory)Limited
No COOP/COEP neededYes (OPFSCoopSyncVFS)Depends on VFS
Multi-tab concurrencyPartial (readwrite-unsafe mode)Partial
Adoption by sync enginesPowerSync, othersElectricSQL

sql.js vs Modern WASM Builds

sql.js has been available since 2014 and remains useful for tests and server-side SQLite operations in Node. In the browser, its critical limitation is that it only supports in-memory databases. Persistence requires manually serializing the entire database to a byte array and writing it to localStorage or IndexedDB at app shutdown — a fragile approach that breaks under crashes or abrupt closes. It is not suitable for production local-first apps.

Step-by-Step Procedure

Setting Up SQLite WASM with OPFS in a Nuxt 4 App

This procedure outlines the key decisions and integration points. Exact package versions change; treat this as an architectural checklist.

1. Decide where SQLite lives

SQLite with OPFS must run in a Web Worker. Create a dedicated worker file (e.g., workers/db.worker.ts). All database operations will happen there; the main thread and Vue components communicate via messages or a shared abstraction layer.

2. Choose your WASM build and VFS

Install wa-sqlite. For most Nuxt 4 apps, OPFSCoopSyncVFS is the right default: it works in all major browsers, requires no COOP/COEP headers, and performs well with large databases.

// workers/db.worker.ts
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs'
import { OPFSCoopSyncVFS } from '@rhashimoto/wa-sqlite/src/examples/OPFSCoopSyncVFS.js'
import * as SQLite from 'wa-sqlite'

const sqlite3 = await SQLiteESMFactory()
const vfs = await OPFSCoopSyncVFS.create('app-db', sqlite3)
SQLite.capi.sqlite3_vfs_register(vfs, 1)

3. Open the database and run migrations

Open the database file by name. Run any schema migrations here before signalling readiness to the main thread. If you are using Drizzle, generate migrations with drizzle-kit and apply them at startup.

4. Expose queries via a message interface (or comlink)

The worker should expose a narrow interface: execute a SQL string with parameters, subscribe to a query, unsubscribe. Libraries like Comlink reduce the boilerplate of the postMessage protocol considerably.

5. Wire reactive queries to Vue

On the main thread, wrap query subscriptions in Vue composables. A useLiveQuery(sql, params) composable returns a ref that the worker updates whenever a relevant table changes. Components bind to this ref the same way they would any other reactive value.

Step 5 decision point: If your sync engine already provides a reactive query API (PowerSync does, ElectricSQL does), use it rather than rolling your own. The value is in the integration, not in re-implementing table tracking.

6. Request persistent storage

// Call once at app startup
if (navigator.storage && navigator.storage.persist) {
  await navigator.storage.persist()
}

This requests permanent retention of OPFS data, reducing the risk of eviction on low-disk-space events.

7. Handle multi-tab scenarios

If your app can open in multiple tabs simultaneously, a second tab will fail to open the same OPFS database file. Implement a SharedWorker that manages the single active database connection and proxies requests from all tabs. This is the architectural complexity cost of OPFS exclusivity.

Common Misconceptions

"IndexedDB is the right storage layer — it's what browsers were designed for." IndexedDB is a key-value document store with no relational query capability. SQLite WASM operations run ~1000x faster than IndexedDB for database workloads. IndexedDB as a VFS backend for SQLite (via IDBBatchAtomicVFS) is a valid compatibility choice, but it is not the preferred path when OPFS is available.

"sql.js is mature so it's the safe choice for production." sql.js is in-memory only. Persisting data requires exporting the full database as a binary blob and manually writing it to another storage backend — an approach that loses data on crashes. For production offline-first apps, this is not acceptable.

"OPFS needs SharedArrayBuffer, which requires COOP/COEP headers." Some OPFS access paths use SharedArrayBuffer and therefore require these headers. wa-sqlite's OPFSCoopSyncVFS works without them, which matters significantly for apps deployed on infrastructure where you cannot control response headers.

"SQLite in the browser means in-memory — data won't survive a refresh." SQLite WASM with an OPFS VFS persists data across tab closures and browser restarts. In-memory is only the default when no VFS is configured.

"Multi-tab support is straightforward." Most OPFS VFS implementations require exclusive file locks, meaning only one connection can hold the database open at a time. A second tab attempting to open the same database will fail without a SharedWorker coordinating access. Design for this before shipping.

Boundary Conditions

Browser support. OPFS requires a modern browser. As of 2025, Chrome, Firefox, Safari, and Edge all support the synchronous OPFS access handle. Older browser targets or WebViews in mobile apps may require fallback to the IndexedDB VFS, with its associated performance penalty.

Storage quotas. localStorage is capped at 5 MB; IndexedDB and OPFS can use much larger quotas. Chrome allows IndexedDB up to 80% of disk in best-effort mode; Firefox caps at the lesser of 10% of disk or 10 GB. In best-effort mode, the browser can evict data under disk pressure. For user data that must survive, call navigator.storage.persist() and communicate to users that the browser may request permission.

Drizzle ORM maturity for WASM. Drizzle supports client-side SQLite WASM via libSQL WASM imports and SQL.js, enabling shared schema definitions between server (Postgres) and browser (SQLite). However, this integration has not yet achieved wide production adoption for complex local-first scenarios. Expect rough edges around migration tooling when the client and server schemas need to diverge.

Concurrency across tabs. OPFS readwrite-unsafe mode landed in Chrome 121 and wa-sqlite's OPFSPermutedVFS and OPFSAdaptiveVFS provide some multi-tab concurrency. This is not universal — the simpler VFS options still require exclusive locks. Treat multi-tab as a deliberate architectural feature requiring explicit design, not a default.

Worker communication overhead. Every query crossing the worker boundary involves serialization. For simple key lookups this is negligible, but for bulk reads returning large result sets, the structured clone overhead can become a bottleneck. Keep hot-path queries small and lean on subscriptions rather than polling.

Key Takeaways

  1. SQLite WASM is the only browser storage option with full relational query capability. IndexedDB cannot express joins or aggregations; localStorage is size-capped and synchronous in the worst way. For local-first apps syncing a relational Postgres schema, SQLite WASM is the correct foundation.
  2. OPFS is the right persistence backend, and it requires a Web Worker. The synchronous OPFS access handle provides near-native I/O performance, but it is only available off the main thread. Architect your database layer as a worker from day one — retrofitting this later is painful.
  3. wa-sqlite's OPFSCoopSyncVFS is the current recommended VFS. It works across all major browsers, does not require COOP/COEP headers, and reached production stability in 2024. The official SQLite WASM build is worth monitoring but offers fewer ready-made VFS options.
  4. sql.js is not suitable for production local-first apps. Its in-memory-only design requires manual export/import for persistence, losing data on crashes. Use it for tests and server-side scripting, not as a browser storage layer.
  5. Reactive queries are the architectural bridge to Vue. Table-level subscription tracking means SQLite state flows into Vue components as reactive refs, with sub-millisecond update cycles. This eliminates the need for polling and simplifies loading-state management considerably.

Further Exploration

Official References

wa-sqlite and VFS

Browser APIs and Standards

Integration and Practice