SQLite is transforming local-first web applications by enabling robust, offline-capable storage. Learn how to use it as a sidecar database for your next project.
When I started building my first "offline-first" tool, I fell into the trap of trying to sync raw JSON files to IndexedDB. It worked until the data model hit a moderate level of complexity, at which point query performance cratered and data integrity became a nightmare. That's when I pivoted to SQLite running in the browser, and the shift in reliability was night and day.
Local-first web development relies on the principle that the client should own the data. Instead of waiting for a round-trip to the server to perform a read, you serve the user immediately from a local source. While IndexedDB is the standard browser API for storage, it’s notoriously clunky. It doesn't support SQL, lacks robust transaction semantics, and is generally painful to debug.
By using WebAssembly to run SQLite directly in the browser, you get a full-featured relational database that lives in the user's browser. It’s not just a cache; it’s a fully functional sidecar database that syncs with your backend when connectivity returns. If you are curious about performance trade-offs in other environments, you might want to look at how Bun runtime SQLite performance: In-memory vs. Persistent storage compares to standard browser-based implementations.
When you treat SQLite as a sidecar, you aren't replacing your backend database. Instead, you are distributing the data layer.
sql.js or wa-sqlite to interact with the database file.Here is a simplified look at how you might initialize a persistent connection:
JAVASCRIPTimport { SQLiteFS } from CE9178">'wa-sqlite'; import * as SQLite from CE9178">'wa-sqlite'; // Initialize the OPFS backend const root = await navigator.storage.getDirectory(); const fileHandle = await root.getFileHandle(CE9178">'app-data.db', { create: true }); const sqlite3 = await SQLite.createAPI(new SQLiteFS(fileHandle)); // Now you can run SQL queries directly against the local file await sqlite3.exec(db, "CREATE TABLE IF NOT EXISTS notes(id TEXT, content TEXT)");
I initially tried to build a custom synchronization layer using simple polling. It was a mistake. Every time the user switched networks, the system would attempt to upload massive chunks of data, locking the UI thread for roughly 400ms. It felt sluggish.
Switching to an event-driven sync—where the client pushes only the delta of changes—reduced that delay to about 30ms. You have to be careful with file locking, though. If you have multiple tabs open, you need a shared worker to act as the primary database controller, or you risk corrupting the SQLite file.
Using SQLite for local-first web development isn't always the right call. If your application requires massive datasets (think hundreds of megabytes) that need to be queried in real-time, the browser’s storage limits might become a bottleneck. However, for most CRUD-heavy applications, the developer experience of writing standard SQL queries far outweighs the configuration overhead.
I’m still experimenting with how to best handle schema migrations in an offline environment. Running ALTER TABLE on a client-side database that might be several versions behind the server is non-trivial. If you are used to managing relationships in a server-side framework, you might find Laravel Eloquent Relationships: A Guide to Linking Data Models to be a good refresher on how data should be structured, even if you are eventually mapping those models to a local SQLite instance.
Does SQLite in the browser support full ACID transactions? Yes, when using the OPFS backend, SQLite maintains its ACID properties. This is a massive upgrade over managing raw state in state-management libraries like Redux or Pinia.
What happens if the browser clears the cache? The browser treats OPFS as persistent storage, but users can still clear it. You must ensure that your application treats the local database as a replica, not the single source of truth. Always keep a copy on your server.
How do I handle schema updates? You should implement a versioning table inside your SQLite file. When the app loads, check the version and run any necessary migrations before initializing the main UI components.
Local-first is still a maturing field. We’re moving away from the "dumb client" model, and SQLite is the best tool we have to make that transition. Just remember: keep your sync logic simple, test your network resiliency, and always assume the local data might be out of sync.
Learn to implement pgvector in Postgres for semantic search. We cover vector embeddings, indexing, and performance tuning for production-ready AI apps.