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.
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 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-Memory | IndexedDB VFS | OPFS | |
|---|---|---|---|
| Persistence | None — lost on reload | Yes | Yes |
| Performance | Fastest | ~10ms/op | ~0.01ms/op |
| Thread constraint | Any | Any | Worker only |
| Header requirements | None | None | None (OPFSCoopSyncVFS) |
| Best for | Tests, ephemeral state | Simpler setup, wider compat | Production 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-sqlite | Official SQLite WASM | |
|---|---|---|
| Maintainer | Independent (rhashimoto) | SQLite project |
| Version 1.0 | July 2024 | Beta 2022, now stable |
| VFS options | Many (OPFS, IDB, in-memory) | Limited |
| No COOP/COEP needed | Yes (OPFSCoopSyncVFS) | Depends on VFS |
| Multi-tab concurrency | Partial (readwrite-unsafe mode) | Partial |
| Adoption by sync engines | PowerSync, others | ElectricSQL |
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
- 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.
- 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.
- 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.
- 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.
- 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
- SQLite WASM official documentation — The authoritative reference for the official WASM build, VFS options, and persistence modes.
- SQLite WASM Persistence Documentation — Covers multi-tab handling, OPFS data lifecycle, and session persistence semantics.
wa-sqlite and VFS
- wa-sqlite GitHub repository — Source, VFS implementations, and the concurrency discussions that explain multi-tab constraints.
- PowerSync: SQLite Persistence on the Web — 2025 Update — A practical comparison of VFS options with updated browser compatibility data.
Browser APIs and Standards
- Chrome for Developers: SQLite WASM with OPFS — The original technical deep-dive on the OPFS integration from the Chrome team.
- MDN: Storage quotas and eviction criteria — The definitive reference for browser storage limits across technologies.
Integration and Practice
- SQLite in Vue: Complete Guide to Building Offline-First Web Apps — Worked example of reactive query integration with Vue 3.
- Drizzle ORM SQLite documentation — Covers the WASM driver options and migration tooling for client-side schemas.
- RxDB: Browser Storage Comparison — Performance benchmarks and practical comparison across browser storage technologies.
- Offline-first frontend apps in 2025: IndexedDB and SQLite in the browser — A survey of the current landscape from a frontend engineering perspective.