This post is intended to be a comprehensive view of the SQLite persistence in web browsers landscape. See the changelog at the end of this post for updates made since the original publication, and please contact us if you notice something wrong.
Background
SQLite is the most widely deployed database globally. Recently, it is also becoming an option for data persistence in browser-based applications.
There are a couple of projects bringing SQLite to web browsers:
- SQL.js has been around since 2014 — initially cross-compiling SQLite to JavaScript, then Wasm (WebAssembly) when browsers started supporting it. By itself, SQL.js only supports in-memory databases, and does not support persistence other than importing or exporting the entire database file at a time.
- In 2021, the wa-sqlite project was created, implementing experimental support for persisting SQLite data in IndexedDB. The performance was quite slow at this point.
- Later in 2021, the absurd-sql project did the same, but using a couple of new techniques to achieve much faster performance — even surpassing direct IndexedDB performance for some query patterns. The accompanying blog post has a lot of details on the complexities of the implementation. While it was a great showcase of what can be done, it was never maintained as a project to be used in production. The wa-sqlite project built further on some of these ideas.
- Towards the end of 2022, the official SQLite project released their own beta Wasm build.
While none of these projects currently claim “production level stability” for persistence, we have found that the current support is already sufficient for certain use cases. That being said, there are many options to choose from, and it may not be immediately clear what the pros and cons are.
This post gives an overview on the different considerations that go into persisting SQLite data in a web browser, with details on what the current implementations do. If you only want to know what to use right now, skip to the recommendations at the end of the post.
Concurrency in SQLite
SQLite supports concurrent access to a single database file, by utilizing multiple “connections” to a file. Concurrency should not be confused with thread-safety: A single connection can be “safe” to use from different native threads (depending on compile options used), but it serializes statements and can never execute more than one transaction at a time.
To execute multiple transactions concurrently, multiple connections can be used. Each connection operates independently and uses locking via the file system, so the effect is the same whether connections are used from different threads or different processes on the same machine.
By default, SQLite uses a rollback journal (DELETE, TRUNCATE or PERSIST modes) to protect against corruption, and supports rolling back transactions. Only a single transaction can modify the rollback journal at a time, which means that only a single write transaction can execute at a time. Any other transactions that attempt to write at the same time will fail with %%SQLITE_BUSY%%. Additionally, no read transactions can be performed while a write transaction is active. The official documentation has more details.
When using the WAL journal mode instead, the concurrency restrictions are relaxed somewhat: read transactions can be executed concurrently with a single write transaction. There is also an experimental WAL2 mode that allows multiple concurrent write transactions as long as they don’t use the same pages. This is still experimental and on a separate SQLite branch, so we won’t cover that here.
Persistence on web
SQLite has a VFS interface which can be used to implement a file system on any system. Generally, the implementation must provide methods to write a block of data, read a block of data, flush data to the file system, and lock files, in addition to other more advanced methods.
For the underlying storage, there are two primary options in web browsers:
- OPFS (origin private file system). This API gives random access to files, private to the origin of the page. In principle, this API is ideal for databases like SQLite, but the APIs currently available do still have some limitations, covered in this post.
- IndexedDB. This is a more general-purpose database for browsers. It can be used as a storage layer for SQLite by storing individual blocks of data as IndexedDB objects.
Other options such as %%localStorage%% are too restrictive, so we won’t cover that here.
Asynchronous calls
SQLite, being a C-based library, makes use of synchronous operations. This is still the case when compiling to Wasm, which means that VFS implementations in JavaScript must also be synchronous. This is a problem in the JavaScript world, where file system operations are typically asynchronous.
There are various workarounds involved in dealing with this:
Emscripten Asyncify
Asyncify translates the synchronous calls into asynchronous calls, allowing the VFS implementation to be asynchronous. There are several downsides:
- The built Wasm file size increases by around 2x.
- Performance can be reduced by 2-5x.
- The behavior of SQLite may be affected by the transforms in Asyncify, and this build is not as well tested. Any bugs in Asyncify could cause subtle issues in the SQLite build. The SQLite team does not use it for this reason.
SharedArrayBuffer + Atomics API
The basic idea is described here. This approach requires running a separate Worker process for the file system operations. It also places additional restrictions on the web origin to allow safe usage of %%SharedArrayBuffer%%. The COOP and COEP headers required are explained here.
Additionally, significant overhead is added wherever this mechanism is used.
JavaScript Promise Integration (JSPI)
The new WebAssembly JavaScript Promise Integration (JSPI) gives functionality similar to Asyncify, but as a core browser feature. JSPI has been available in Chrome without a feature flag since version 137 (May 2025), and is available in Firefox with a feature flag since version 131 (October 2024). No support on Safari yet, although there is indication that Safari may support JSPI in the future.
OPFS FileSystemSyncAccessHandle
The File System API allows obtaining a %%FileSystemSyncAccessHandle%% using %%createSyncAccessHandle()%% within dedicated Workers that provides synchronous read and write access to files within the OPFS. The latest versions of Chrome, Safari and Firefox all support this.
There are some caveats:
- While operations on an open file are synchronous [1], opening a file is still an asynchronous operation. This requires either pre-opening all files that could be used, combining with the %%SharedArrayBuffer%% + Atomics API workaround above, or using a retry mechanism (see below).
- By default, opening a file takes out an exclusive lock, meaning no other connection can read or write the same file at the same time. If files are pre-opened and kept open for the duration of the connection, it means only a single connection can be used.
Based on a WHATWG proposal in 2023, a %%mode%% argument for %%createSyncAccessHandle()%% is being introduced, with %%readwrite-unsafe%% mode allowing for multiple concurrent readers and writers. The %%readwrite-unsafe%% mode is available in Chrome 121+, but is not yet supported by Firefox or Safari.
Obtaining file access handles in JavaScript
Obtaining file access handles remains asynchronous, even with OPFS %%createSyncAccessHandle()%%. One class of workarounds involve opening the file handles and/or locks in a JavaScript layer, outside Wasm. %%OPFSCoopSyncVFS%% in the wa-sqlite project is one example of this.
Concurrency on the web
Concurrency for SQLite on the web generally has the same restrictions as on other platforms: Each connection can run one transaction at a time. Browsers do not provide direct support for multi-threading, including in Wasm, but multiple web workers can be used to get a similar effect.
Concurrent transactions may be supported over multiple tabs or within a tab, by utilizing multiple SQLite connections. However, some VFS implementations require an exclusive lock on the database and only support having a single connection open at a time, in which case concurrent transactions are not supported at all. The single connection has to be shared between any active tabs. Other VFS implementations allow multiple concurrent connections, but only a single concurrent transaction. And others do allow multiple concurrent read transactions, as long as no write transaction is active.
There is currently no implementation that supports read transactions concurrently with write transactions, although there is hope for that using SQLite’s WAL journal mode on OPFS in the future.
Future options: Concurrent write + read transactions
To support read transactions concurrently with a write transaction, WAL mode is required. This requires implementing additional VFS methods to provide “shared memory” between different connections.
While it is possible to use WAL mode without shared memory, this would prevent concurrent access, so it would not help here.
Currently, there is no VFS that implements WAL mode directly [2]. It would also not help currently, since:
- IndexedDB requires an exclusive lock on an object store for any write, so concurrent read + write access would still not be possible.
- With the exception of the %%readwrite-unsafe%% mode available since Chrome 121+, OFPS sync access handle APIs exclusively lock the file for read or write access, also making concurrent read + write access impossible.
- OPFS asynchronous APIs have significantly worse performance, negating the gains from getting concurrency.
Since the %%readwrite-unsafe%% mode became available in Chrome 121+, providing the required shared memory across connections in different web workers is still tricky, but wa-sqlite now supports some concurrency using %%readwrite-unsafe%% with %%OPFSPermutedVFS%% or %%OPFSAdaptiveVFS%% (see below).
In the future, an implementation supporting WAL mode using %%readwrite-unsafe%% would be a great addition.
Performance tweaks
Performance in SQLite is tightly related to how the file system operations are implemented.
There are a couple of ways to get better performance — either in the file system layer, or in higher-level configuration.
Batch-atomic write transactions
By default, SQLite assumes that a sequence of writes to a file may be interrupted at any point in time, due to e.g. operating system crash or power loss. The rollback journal is used to allow recovery after a crash.
However, some file systems can guarantee that a batch of write operations will all either succeed or all fail — typically by implementing a journal mode as part of the file system itself. SQLite can exploit this behavior when available, avoiding the need for a rollback journal in many (but not all) cases. It still keeps a rollback journal in memory, and may need to persist it to a file if it grows too large. On the web, IndexedDB can be used to provide the same guarantees, which then gives some nice performance improvements.
Unfortunately, even though this could in theory also provide similar concurrency to WAL mode, SQLite does not support that at the moment. Some discussion around this can be seen here.
Exclusive locking
The database can be locked in exclusive mode, only allowing a single connection to access the database as long as it is open. This reduces the number of locks needed for transactions, and can significantly speed up writes. This is configured using %%PRAGMA locking_mode = EXCLUSIVE%%.
The caveat is that concurrent read transactions are not possible in this mode. But when the file system implementation only allows a single connection at a time anyway (like a couple of the options below), this is not an issue.
Relaxed durability
By default, SQLite only acknowledges a transaction when it is safely persisted to the underlying storage. In other words, even an operating system crash or power failure should not lose the transaction.
It also offers relaxed durability, where acknowledged transactions may not be persisted if the operating system crashes, but is safe if the application crashes. This is configured using %%PRAGMA synchronous = NORMAL%%. There is a further mode of %%PRAGMA synchronous = OFF%%, which does not wait for the file system at all, but may cause database corruption if the operating system crashes.
Some VFS implementations may provide additional relaxed durability options specific to the storage layer.
When combining exclusive locking with %%synchronous = OFF%% (or an equivalent durability option in the VFS), write transactions may occur without waiting for the file system at all, which can result in a much higher number of transactions per second. Throughput within a large transaction is not expected to change much with this.
File system transparency
When using OPFS, files can be stored “transparently”, meaning the persistence format is exactly the same as what SQLite traditionally uses, with no workarounds or additional metadata required. This has the advantage of interoperability between different implementations — it may be possible to switch out libraries completely, without losing data already persisted.
Higher-level libraries
The implementations mentioned here are generally low-level — SQLite APIs are exposed directly. A typical application would ideally use a library that manages transactions, locking, connection pooling (optional), web workers (if applicable), and provides higher-level APIs for querying and persisting data.
At PowerSync we provide wrapper libraries to support both Kysely (a type-safe query builder) and Drizzle (a more full-featured ORM) on top of wa-sqlite. Currently this requires using the full PowerSync SDK. There are also some community-maintained libraries:
- SQLocal - supports Drizzle and Kysely on top of sqlite-wasm.
- kysely-sqlite-tools - supports Kysely on top of wa-sqlite.
Encryption support
SQLite supports at-rest encryption using optional extensions.
The commercial SQLite Encryption Extension (SSE) supports building for sqlite-wasm.
Alternatively, the community-based SQLite3 Multiple Ciphers has instructions for WebAssembly builds, working on both sqlite-wasm and wa-sqlite. Both options may require manually building the Wasm builds, and for wa-sqlite some changes to the build scripts may be required. PowerSync includes a build with SQLite3 Multiple Ciphers in its Web SDK, but this is not designed for standalone use outside PowerSync at the moment.
SQLCipher does not appear to officially support Wasm builds at this point.
Implementations
wa-sqlite
wa-sqlite provides a Wasm build of SQLite — both synchronous and Asyncify versions. It also provides a couple of examples and various VFS implementations. Only the higher-performing persistent VFS implementations are compared here.
These VFS implementations are intended as examples rather than production-ready code, but we have found they perform well in practice.
IDBBatchAtomicVFS
Persists versioned blocks of file data to IndexedDB. Can execute either in a worker process or the main page.
It uses batch-atomic write transactions to get very good write performance, despite the additional IndexedDB layer sitting between SQLite and the underlying file system.
Needs the Asyncify or JSPI build. Restricted to a single transaction at a time by default, but can be configured to support concurrent read transactions.
This VFS can be configured with %%PRAGMA synchronous=NORMAL%% to reduce overhead per write transaction.
OPFSAdaptiveVFS (similar to the previous OriginPrivateFileSystemVFS)
Persists files directly as files in OPFS. Uses asynchronous open and read operations, and synchronous access handles for write operations.
Needs the Asyncify or JSPI build. Restricted to a single transaction at a time by default, but can be configured to support concurrent read transactions. However, there are potential issues when writing from different connections. On Chrome, the %%readwrite-unsafe%% mode can be used for real concurrent reads.
This VFS has file system transparency, making it compatible with sqlite-wasm.
OPFSCoopSyncVFS
%%OPFSCoopSyncVFS%% is similar to %%AccessHandlePoolVFS%% in that it uses synchronous access handles, but it supports multiple concurrent connections, and has file system transparency.
%%OPFSCoopSyncVFS%% uses a workaround in that when a new access handle is needed, the VFS raises a %%SQLITE_BUSY%% error while queuing the operation. On the JavaScript side, when the %%SQLITE_BUSY%% error is observed, it waits for the asynchronous operations to open the file, then retries the SQLite operation.
Note that this approach is only required for opening access handles and locks — the actual reads and writes are synchronous, giving good performance overall.
One caveat here is that transactions spanning multiple databases are not supported.
%%OPFSCoopSyncVFS%% currently supports multiple concurrent connections, but not concurrent transactions — each transaction uses an exclusive lock. Improved concurrency may be supported in the future.
AccessHandlePoolVFS
This implementation pre-opens a number of files, so that they can be accessed synchronously in the VFS, thereby not requiring Asyncify. This means there is a pre-configured limit on the number of databases that can be opened without re-instantiating the connection, but that should not be an issue for most applications.
The persisted files use auto-generated names, each with a header containing the original filename. This makes the storage format incompatible with other implementations. There are some ideas for getting file system transparency in the future.
Since a sync access handle locks a file exclusively, concurrent transactions are not possible. And more than that — only a single connection can be opened to a file at a time. This means that accessing the same database from multiple tabs needs additional coordination, by opening %%MessageChannel%%s to a single worker process. Since the worker is associated with a single tab, some care is required to spawn a new worker when that tab closes. Issues may also arise if a page and associated worker is closed in the middle of a transaction — the application will need to be able to handle transactions failing.
OPFSPermutedVFS
OPFSPermutedVFS is a new VFS supporting concurrent reads, even when a write is active. It works similar to SQLite’s WAL mode, but with the logic implemented in the VFS itself. It uses OPFS for the database file, and IndexedDB to manage concurrency.
Concurrent transactions are currently only supported on Chrome, via %%readwrite-unsafe%% mode.
sqlite-wasm
As of late 2022, SQLite has an official Wasm build. Only a synchronous build is supported. The following VFS options are available:
opfs
The OPFS-based VFS uses sync access handles for read and write operations. Multiple connections can be open concurrently, but only a single read or write transaction can be open at a time.
Additionally, since opening a file handle is an asynchronous operation, the %%SharedArrayBuffer%% + Atomics workaround is used to make the operation synchronous. This means COOP and COEP headers are required (to use %%SharedArrayBuffer%%).
It may be possible to support concurrent access in the future by using sync access handle modes.
This VFS does have file system transparency, making it interoperable with wa-sqlite’s OPFS VFS.
There is also another option using Emscripten’s WasmFS. It uses similar mechanisms, but has additional restrictions, so I would not recommend it over the OPFS VFS.
opfs-sahpool
The OPFS SyncAccessHandle Pool VFS (%%opfs-sahpool%%) has been released in SQLite 3.43.0. It uses the same ideas as wa-sqlite’s %%AccessHandlePoolVFS%%, avoiding the need for the %%SharedArrayBuffer%% + Atomics workaround, and getting much better performance. This VFS requires an exclusive database lock, and only a single connection can be open at a time [3].
absurd-sql
The absurd-sql project uses a synchronous SQLite build, with the %%SharedArrayBuffer%% + Atomics workaround to expose the IndexedDB operations as a synchronous VFS.
This project is not actively maintained, and only included as a reference.
VFS Summary Table
In the table below, “concurrent connections” means multiple connections can be open at the same time, but only a single transaction can be active at a time. “Concurrent reads” means that multiple read transactions can be open at the same time, as long as no write transaction is open. May require additional configuration and application-level locking. Supported versions of browsers haven’t been tested as part of this post. It may be inaccurate, especially for the older version ranges.
Recommendations
As of 2025, wa-sqlite’s %%OPFSCoopSyncVFS%% is a good general-purpose VFS that has excellent performance, even with large databases, and works on recent versions of all major browsers. The increased performance makes up for the lack of concurrency support.
If support for older browser versions is required, wa-sqlite’s %%IDBBatchAtomicVFS%% is a good fallback option.
The SQLite Wasm OPFS build is also an option with good performance and with file system transparency. I would not recommend it over the wa-sqlite builds yet due to the restrictions around COOP and COEP headers.
Real-world experience
We’ve supported both wa-sqlite’s %%OPFSCoopSyncVFS%% and %%IDBBatchAtomicVFS%% in the PowerSync client SDKs since early 2025. We’ve found both work really well for most use cases, with some things standing out:
- %%IDBBatchAtomicVFS%% works well for small databases, but performance degrades with larger databases (100MB+). %%OPFSCoopSyncVFS%% keeps performing well even for databases over 1GB in size.
- We’ve observed some %%RangeError: Maximum call stack size exceeded%% errors on Safari on %%IDBBatchAtomicVFS%% with large queries. %%OPFSCoopSyncVFS%% does not have the same issue.
- Incognito mode on Safari does not support OPFS. %%IDBBatchAtomicVFS%% is a good fallback for that.
- Incognito mode on Chrome introduces a 100MB database size limit, with unexpected errors when this limit is reached.
- The behavior of %%OPFSCoopSyncVFS%% throwing %%SQLITE_BUSY%% errors in the middle of transactions can introduce some additional complexity in certain cases. In PowerSync, we implement complex operations in a SQLite extension, so we had to ensure that the extension can handle these errors. However, most applications using standard SQLite operations should not run into these issues.
- %%OPFSCoopSyncVFS%% requires using dedicated workers. Shared web workers unfortunately do not support OPFS at this point. At PowerSync we use one dedicated database worker at a time, with cross-tab messaging to the worker. This introduces some complexity and edge cases. Firefox supports spawning dedicated workers from shared workers, but Chrome and Safari do not support that at this point.
- On Chrome and Edge, tabs can be “suspended” after a period of inactivity. If a tab’s database worker is shared with other tabs, it will become inaccessible, causing queries to stop responding. We worked around this by keeping a Web Lock open on the worker, which prevents the tab from being suspended.
- On Ionic Capacitor, access handles in %%OPFSCoopSyncVFS%% can be closed when the app is in the background, causing errors when resuming the application. A workaround is to use native SQLite or %%IDBBatchAtomicVFS%%.
Acknowledgements
A special thanks to Roy Hashimoto, the author of wa-sqlite, for providing many corrections and additional details for this post. Thanks also to Stephan Beal for reaching out about clarifications and updates.
Changelog
2025-11-11
Updated the post with new wa-sqlite VFS implementations, improved browser JSPI support, and some real-world experience at PowerSync. Added a section on at-rest encryption support.
On the browser side:
- JSPI does not require a feature flag anymore as of Chrome 137, and now has similar performance to Asyncify.
- On Firefox it is still behind a feature flag, and slower than Asyncify.
- No support on Safari yet, although there is indication that Safari may support JSPI in the future.
- wa-sqlite reached 1.0 in July 2024, with new VFS implementations.
- It now supports some concurrency using the %%readwrite-unsafe%% mode with %%OPFSPermutedVFS%% or %%OPFSAdaptiveVFS%% (replaces %%OriginPrivateFileSystemVFS%%)
- In version 1.0.0, %%IDBBatchAtomicVFS%% has been rewritten, although it still uses the same principles. It no longer has a %%durability%% option, instead using the standard %%PRAGMA synchronous%% option to configure durability.
2024-02-14
Updated the post to better cover the difference between support for concurrent connections, concurrent transactions, or no concurrency.
2024-02-08
Since this post was first published on 2023-07-19, there have been some updates in the landscape.
On the browser side:
- The OPFS %%readwrite-unsafe%% mode has landed in Chrome 121. This allows concurrent access to the same file.
- WebAssembly JavaScript Promise Integration (JSPI) is a proposal to remove the need for Asyncify. This is currently available as a flag in Chrome, and Firefox support is planned. Unfortunately the current version is much slower in Chrome than Asyncify, and tab crashes are still common.
In the official sqlite3 Wasm build, the %%opfs-sahpool%% VFS has been released in SQLite 3.43.0.
In wa-sqlite, a rewrite of the current VFS implementations is in progress, which can use the above browser functionality when available:
- %%IDBBatchAtomicVFS%% can now use JSPI instead of Asyncify.
- %%OriginPrivateVFS%% replaces %%OriginPrivateFileSystemVFS%%, and can support concurrent reads via the %%readwrite-unsafe%% mode. This still uses the asynchronous OPFS APIs, so performance is not expected to be great.
- %%AccessHandlePoolVFS%% is rewritten to have file system transparency, and support concurrent reads via readwrite-unsafe. This requires pre-creating the database files from the database — it is not possible to just %%ATTACH%% to new databases at runtime, but that is not a feature that many web applications would require. This VFS does not require Asyncify or JSPI.
Details on the new VFS implementations are here.
With file system transparency and concurrent read support, the new %%AccessHandlePoolVFS%% should be a great default choice for most web apps.
There is still no web VFS that supports WAL mode. There is one experimental VFS in wa-sqlite that implements similar ideas on the JavaScript side, supporting concurrent reads while writing. This is a space to watch.
In the ORM space, there are now some options for using higher-level APIs:
- SQLocal provides Kysely and Drizzle wrappers for the official sqlite3 Wasm build.
- Another project provides Kysely dialects for both sqlite3 Wasm and wa-sqlite.
The summary table has been updated to reflect these new options.
2023-07-19
Initial publication date.

