Persistent Databases in the Browser with DuckDB-Wasm and OPFS

Lobsters Hottest Tools

Summary

DuckDB-Wasm now supports persistent databases in browsers using the Origin Private File System (OPFS), enabling data to survive page reloads and browser restarts with built-in storage.

<p><a href="https://lobste.rs/s/k5shc5/persistent_databases_browser_with">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 09/19/26, 10:06 PM

# Persistent Databases in the Browser with DuckDB-Wasm and OPFS Source: [https://duckdb.org/2026/09/18/opfs-wasm](https://duckdb.org/2026/09/18/opfs-wasm) *TL;DR: DuckDB\-Wasm can open a persistent database file in the browser's Origin Private File System \(OPFS\)\. This post shows how, and when data reaches disk\.* When[DuckDB\-Wasm was launched](https://duckdb.org/2021/10/29/duckdb-wasm.html)in 2021, databases could not be persisted: everything lived in the Wasm heap and vanished when the tab closed\. Keeping data meant serializing tables to Parquet, storing the bytes in IndexedDB, and re\-registering them on the next page load\. This was doable, but had to be handled at the application layer and was not offered out of the box by DuckDB\-Wasm\. Modern browsers \(since[March 2023](https://caniuse.com/wf-origin-private-file-system)\) now ship the[Origin Private File System \(OPFS\)](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system), a per\-origin, sandboxed file system with random\-access reads and writes\. DuckDB\-Wasm \(tested with versions 1\.32\.0 and 1\.33\.1\-dev64\.0\) can use it as a storage backend, as described in the[DuckDB documentation](https://duckdb.org/docs/current/clients/wasm/instantiation.html#persistence-with-opfs): a database opened at an`opfs://`path survives reloads and browser restarts\. The following call opens a database file in OPFS: ``` await db.open({ path: 'opfs://analytics.duckdb', accessMode: duckdb.DuckDBAccessMode.READ_WRITE, }); ``` The result is a regular`\.duckdb`file with a write\-ahead log and checkpoints that survives page reloads and browser restarts\. > Note: at the time of writing, the build that npm serves as`latest`\(1\.33\.1\-dev57\.0\) creates the OPFS files but never writes to them, so nothing persists\. It canonicalizes the path to`opfs:/analytics\.duckdb`with a single slash, which no longer matches the OPFS handle\. Pin 1\.32\.0 or use the`next`tag \(1\.33\.1\-dev64\.0 or later\)\. ## [Opening a Database](https://duckdb.org/2026/09/18/opfs-wasm#opening-a-database) The setup is the same as for any DuckDB\-Wasm application: pick a bundle, start a worker, instantiate the database\. The only new part is the`open`call, marked below\. The import resolves to whichever version is installed, and`getJsDelivrBundles\(\)`fetches the matching worker and`\.wasm`files, so install a version that persists correctly:`npm install @duckdb/\[email protected\]`or`@next`\. ``` import * as duckdb from '@duckdb/duckdb-wasm'; const bundles = duckdb.getJsDelivrBundles(); const bundle = await duckdb.selectBundle(bundles); // Worker scripts must be same-origin, so wrap the CDN worker URL in a Blob const workerUrl = URL.createObjectURL( new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' }) ); const worker = new Worker(workerUrl); const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker); await db.instantiate(bundle.mainModule, bundle.pthreadWorker); URL.revokeObjectURL(workerUrl); // NEW: open a persistent database in OPFS instead of the default :memory: await db.open({ path: 'opfs://analytics.duckdb', accessMode: duckdb.DuckDBAccessMode.READ_WRITE, }); const conn = await db.connect(); await conn.query(` CREATE TABLE IF NOT EXISTS transactions ( id BIGINT, ts TIMESTAMP, merchant VARCHAR, category VARCHAR, amount DECIMAL(10, 2) ); `); await conn.query(`INSERT INTO transactions VALUES (1, now(), 'Coolblue', 'electronics', 49.95)`); await conn.query('CHECKPOINT'); const result = await conn.query('SELECT count(*) AS n FROM transactions'); console.log(result.toArray()[0].n); ``` Reload the page and run the same code\. The`CREATE TABLE IF NOT EXISTS`statement finds the existing table and does nothing, the insert adds a second row, and the count prints 2\. There is no sync step, no export, no`localStorage`key to remember\. The`opfs://`prefix tells DuckDB\-Wasm's file system layer to resolve the path against the origin's private file system instead of the in\-memory Emscripten file system\. Opening the database creates the database file and its`\.wal`in OPFS\. Builds from 1\.33\.1\-dev64\.0 onward also create two empty helper files,`\.wal\.checkpoint`and`\.wal\.recovery`, that DuckDB uses during checkpointing\. The`\.duckdb`file is a regular DuckDB database file\. If you pull it out of OPFS \(shown below\) and open it with the CLI or the Python client, it works\. ### [Data Files](https://duckdb.org/2026/09/18/opfs-wasm#data-files) The same prefix works for data files\. A common pattern is to load a remote dataset once, keep it in the persistent database, and cache derived results as Parquet files in OPFS\. The example below uses the TPC\-H`orders`table \(scale factor 0\.01, about 1,500 rows\) that the[DuckDB web shell](https://shell.duckdb.org/)serves: ``` await conn.query(` CREATE TABLE IF NOT EXISTS orders AS SELECT * FROM 'https://shell.duckdb.org/data/tpch/0_01/parquet/orders.parquet'; `); await conn.query('CHECKPOINT'); ``` DuckDB\-Wasm reads the remote file with HTTP range requests\. Because the table is created with`IF NOT EXISTS`, the file is fetched only on the first page load; on later loads the table comes from OPFS and no request goes to`shell\.duckdb\.org`\. You can see this in the browser's Network tab, which lists the range requests on the first load and stays quiet afterwards, or in DuckDB\-Wasm's own logs: the`ConsoleLogger`passed to`AsyncDuckDB`records each HTTP read, so the absence of those log lines on a reload confirms the data is served entirely from OPFS\. With the data local, an aggregation can be written to a Parquet file in OPFS and read back later: ``` COPY ( SELECT o_orderpriority AS priority, date_trunc('month', o_orderdate) AS month, sum(o_totalprice) AS total FROM orders GROUP BY ALL ) TO 'opfs://cache/monthly_totals.parquet'; SELECT * FROM 'opfs://cache/monthly_totals.parquet'; ``` Nested directories such as`cache/`are created on demand\. OPFS files are ordinary DuckDB file paths, so globbing,`read\_csv`and the other readers work as usual\. Reading and writing`opfs://`paths from SQL needs one extra option on`open\(\)`, described next\. ### [File Handling Modes](https://duckdb.org/2026/09/18/opfs-wasm#file-handling-modes) With`opfs: \{ fileHandling: 'auto' \}`, DuckDB\-Wasm scans each statement for single\-quoted`'opfs://\.\.\.'`literals, registers those files before execution \(creating them and any missing directories if needed\) and drops the handles afterwards\. The option only takes effect when the database itself was opened from an`opfs://`path\. Without it, every file other than the database has to be registered by hand: ``` // Option 1: automatic registration of opfs:// paths found in SQL await db.open({ path: 'opfs://analytics.duckdb', accessMode: duckdb.DuckDBAccessMode.READ_WRITE, opfs: { fileHandling: 'auto' }, }); // Option 2: manual registration (the default) await db.open({ path: 'opfs://analytics.duckdb', accessMode: duckdb.DuckDBAccessMode.READ_WRITE, }); await db.registerOPFSFileName('opfs://cache/monthly_totals.parquet'); // ... run queries against it ... await db.dropFile('opfs://cache/monthly_totals.parquet'); ``` Automatic mode is convenient for one\-off reads\. Manual mode requires more code but avoids re\-acquiring an OPFS access handle on every statement, which adds up for applications that run many small queries\. A file can be held by only one handle at a time, so the DuckDB documentation recommends[dropping registered files](https://duckdb.org/docs/current/clients/wasm/instantiation.html#persistence-with-opfs)with`db\.dropFile\(\)`before another connection or database instance opens them\. ## [Durability](https://duckdb.org/2026/09/18/opfs-wasm#durability) DuckDB\-Wasm writes to OPFS the same way native DuckDB writes to a local disk: through a write\-ahead log and periodic checkpoints\. What differs is that a browser tab is rarely closed cleanly, so the defaults that work on a desktop can leave you with a slow reopen\. DuckDB uses a[write\-ahead log](https://duckdb.org/docs/current/internals/storage.html)\. Committed transactions are appended to`analytics\.duckdb\.wal`first\. The main file is updated at*checkpoint*time\. A checkpoint happens automatically when the WAL grows past`checkpoint\_threshold`\(16 MB by default\), when the database is closed cleanly, or when you run`CHECKPOINT`yourself\. In a desktop process, "closed cleanly" is the common case\. In a browser tab, it is not: the user closes the tab, the phone kills the background page, the laptop lid goes down\. None of these run your shutdown code reliably\. Two rules follow from that\. **Call`CHECKPOINT`after writes you cannot afford to lose\.**The[DuckDB documentation](https://duckdb.org/docs/current/clients/wasm/instantiation.html#persistence-with-opfs)is explicit about this: writes are flushed to OPFS by`CHECKPOINT`\. Committed transactions are appended to the WAL, and DuckDB replays the WAL on the next open, but a browser tab can be terminated at any point, so a checkpoint is the only way to be certain that the data is in the main file\. **Checkpoint per batch, not per statement\.**A large WAL also makes the*next*open slower, because replay has to happen before the first query\. For an interactive app, checkpointing after each batch of user edits keeps both the data safe and the reopen fast: ``` await conn.query('INSERT INTO transactions VALUES (...)'); await conn.query('CHECKPOINT'); ``` If you would rather not track batches, set the checkpoint threshold to zero once after connecting\. DuckDB then checkpoints after every statement, which costs some write throughput but removes the question entirely: ``` await conn.query(`SET checkpoint_threshold = '0KB'`); ``` A clean shutdown looks like this: ``` await conn.query('CHECKPOINT'); await conn.close(); await db.terminate(); ``` What happens when a tab is killed mid\-transaction, and how to share one database between tabs, are covered in a follow\-up post\. There is a second kind of durability to keep in mind, one that sits below DuckDB\. OPFS is browser storage, not a hard guarantee\. The browser can evict it when disk space runs low or when the origin has not been visited for a long time, and the user can clear it from the site's settings\. Treat OPFS as a fast local cache for accelerating startup and persisting working state, not as your only copy of data you cannot lose\. For durable storage, keep the source of truth somewhere stable and sync back to it: a[DuckLake](https://ducklake.select/)catalog, or plain files on object storage through`s3://`paths\. ## [Export](https://duckdb.org/2026/09/18/opfs-wasm#export) Users will want to move their data to another device, back it up, or open it with a different tool\. DuckDB\-Wasm itself[cannot move files into or out of OPFS](https://duckdb.org/docs/current/clients/wasm/instantiation.html#persistence-with-opfs)yet, but the database is a plain DuckDB file and the browser's OPFS API lets you read it back as bytes: ``` await conn.query('CHECKPOINT'); const root = await navigator.storage.getDirectory(); const handle = await root.getFileHandle('analytics.duckdb'); const file = await handle.getFile(); // Offer as a download, upload to your backend, etc. const url = URL.createObjectURL(file); ``` Or export from SQL to Parquet: ``` COPY transactions TO 'opfs://export/transactions.parquet' (FORMAT parquet, COMPRESSION zstd); ``` Combined with[DuckDB's Parquet support](https://duckdb.org/docs/current/data/parquet/overview.html), this allows preparing and cleaning data in the browser before uploading it to a server\. And because the on\-disk format is standard, the reverse works too: ship a pre\-built`\.duckdb`file with your app, copy it into OPFS on first launch, and open it\. Users get a local dataset without an import step\. ## [Conclusion](https://duckdb.org/2026/09/18/opfs-wasm#conclusion) Lack of persistence was the main limitation of DuckDB\-Wasm for a long time\. With OPFS, DuckDB\-Wasm can open a database file in the browser, commit transactions to a WAL, checkpoint, and reopen the same database after a reload\. Three things make it work well: run`CHECKPOINT`after each batch of writes rather than after every statement, give users a way to download the database file, and read the[limitations listed in the DuckDB documentation](https://duckdb.org/docs/current/clients/wasm/instantiation.html#persistence-with-opfs)before shipping: one handle per file, and renames from SQL only work between two already\-registered OPFS files\. With this, a local\-first application no longer needs a server, IndexedDB wrapper, or custom serialization to keep analytical data between sessions\. Try it in your own application, and share what you build on[GitHub](https://github.com/duckdb/duckdb-wasm)or[Discord](https://discord.duckdb.org/)\. ![DuckDB Skills for Claude Code](https://duckdb.org/images/blog/thumbs/claude-skills.svg) ### DuckDB Skills for Claude Code ![Try DuckDB v2.0-alpha](https://duckdb.org/images/blog/thumbs/duckdb-preview-2-0.svg) ### Try DuckDB v2\.0\-alpha ![DuckLabs to Join AWS, Projects to Remain Open Source](https://duckdb.org/images/blog/thumbs/ducklabs-aws.svg) ### DuckLabs to Join AWS, Projects to Remain Open Source Mark Raasveldt and Hannes Mühleisen

Similar Articles

OPFS + Pyodide test harness

Simon Willison's Blog

A test harness for experimenting with the Origin Private File System (OPFS) in browsers using Pyodide, built to explore persistent SQLite storage for Datasette Lite.

A Preview of DuckDB v2.0

Lobsters Hottest

This article previews the upcoming features of DuckDB v2.0, including server mode, new SQL parser, and storage format, marking a significant update to the database system.

A Preview of DuckDB v2.0

Hacker News Top

DuckDB v2.0, codenamed Cyanoptera, previews new features including server mode, triggers, VARIANT type, asynchronous I/O, and a new SQL parser, set to release this fall.

Asynchronous I/O in DuckDB: Work, Thread, Work

Lobsters Hottest

DuckDB is introducing asynchronous I/O for Parquet and CSV files in v2.0, improving remote storage query performance by avoiding blocking worker threads during data fetches.