{"article":{"slug":"persistent-databases-in-the-browser-with-duckdb-wasm-and-opfs","title":"Persistent Databases in the Browser with DuckDB-Wasm and OPFS","subtitle":null,"summary":"DuckDB explains how DuckDB-Wasm can open a persistent database file in the browser’s Origin Private File System (OPFS), when data reaches disk, and how that changes browser analytics apps that previously relied on Parquet-in-IndexedDB workarounds.","content_type":"blog_post","language":"en","canonical_url":"https://duckdb.org/2026/09/18/opfs-wasm","author":{"name":"Carlo Piovesan, Geertjan Wielenga","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"DuckDB","url":"https://duckdb.org","listing_slug":null,"listing":null},"topics":[{"name":"Databases","slug":"databases","url":"https://listedarticles.com/topics/databases"},{"name":"Web Development","slug":"web-development","url":"https://listedarticles.com/topics/web-development"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1800,"reading_minutes":8,"published_at":"2026-09-18T00:00:00.000Z","added_at":"2026-09-19T21:09:25.566Z","updated_at":"2026-09-19T21:09:25.566Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/persistent-databases-in-the-browser-with-duckdb-wasm-and-opfs","markdown_url":"https://listedarticles.com/articles/persistent-databases-in-the-browser-with-duckdb-wasm-and-opfs.md","example":false,"citation":"Carlo Piovesan, Geertjan Wielenga, DuckDB. \"Persistent Databases in the Browser with DuckDB-Wasm and OPFS.\" 18 Sept 2026. https://duckdb.org/2026/09/18/opfs-wasm (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://duckdb.org/2026/09/18/opfs-wasm"},"body_markdown":"# Persistent Databases in the Browser with DuckDB-Wasm and OPFS\n\nCarlo Piovesan, Geertjan Wielenga\n\n2026-09-18 | 9 min\n\n_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._\n\nWhen [DuckDB-Wasm was launched](</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.\n\nModern 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](</docs/current/clients/wasm/instantiation.html#persistence-with-opfs>): a database opened at an `opfs://` path survives reloads and browser restarts.\n\nThe following call opens a database file in OPFS:\n    \n    \n    await db.open({\n        path: 'opfs://analytics.duckdb',\n        accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n    });\n    \n\nThe result is a regular `.duckdb` file with a write-ahead log and checkpoints that survives page reloads and browser restarts.\n\n> 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).\n\n##  Opening a Database\n\nThe 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]](</cdn-cgi/l/email-protection>)` or `@next`.\n    \n    \n    import * as duckdb from '@duckdb/duckdb-wasm';\n    \n    const bundles = duckdb.getJsDelivrBundles();\n    const bundle = await duckdb.selectBundle(bundles);\n    \n    // Worker scripts must be same-origin, so wrap the CDN worker URL in a Blob\n    const workerUrl = URL.createObjectURL(\n        new Blob([`importScripts(\"${bundle.mainWorker}\");`], {\n            type: 'text/javascript'\n        })\n    );\n    const worker = new Worker(workerUrl);\n    const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);\n    await db.instantiate(bundle.mainModule, bundle.pthreadWorker);\n    URL.revokeObjectURL(workerUrl);\n    \n    // NEW: open a persistent database in OPFS instead of the default :memory:\n    await db.open({\n        path: 'opfs://analytics.duckdb',\n        accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n    });\n    \n    const conn = await db.connect();\n    await conn.query(`\n        CREATE TABLE IF NOT EXISTS transactions (\n            id BIGINT,\n            ts TIMESTAMP,\n            merchant VARCHAR,\n            category VARCHAR,\n            amount DECIMAL(10, 2)\n        );\n    `);\n    \n    await conn.query(`INSERT INTO transactions VALUES (1, now(), 'Coolblue', 'electronics', 49.95)`);\n    await conn.query('CHECKPOINT');\n    \n    const result = await conn.query('SELECT count(*) AS n FROM transactions');\n    console.log(result.toArray()[0].n);\n    \n\nReload 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.\n\nOpening 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.\n\n###  Data Files\n\nThe 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:\n    \n    \n    await conn.query(`\n        CREATE TABLE IF NOT EXISTS orders AS\n        SELECT * FROM 'https://shell.duckdb.org/data/tpch/0_01/parquet/orders.parquet';\n    `);\n    await conn.query('CHECKPOINT');\n    \n\nDuckDB-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.\n\nWith the data local, an aggregation can be written to a Parquet file in OPFS and read back later:\n    \n    \n    COPY (\n        SELECT o_orderpriority AS priority,\n               date_trunc('month', o_orderdate) AS month,\n               sum(o_totalprice) AS total\n        FROM orders\n        GROUP BY ALL\n    ) TO 'opfs://cache/monthly_totals.parquet';\n    \n    SELECT * FROM 'opfs://cache/monthly_totals.parquet';\n    \n\nNested 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.\n\n###  File Handling Modes\n\nWith `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:\n    \n    \n    // Option 1: automatic registration of opfs:// paths found in SQL\n    await db.open({\n        path: 'opfs://analytics.duckdb',\n        accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n        opfs: { fileHandling: 'auto' },\n    });\n    \n    // Option 2: manual registration (the default)\n    await db.open({\n        path: 'opfs://analytics.duckdb',\n        accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n    });\n    await db.registerOPFSFileName('opfs://cache/monthly_totals.parquet');\n    // ... run queries against it ...\n    await db.dropFile('opfs://cache/monthly_totals.parquet');\n    \n\nAutomatic 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](</docs/current/clients/wasm/instantiation.html#persistence-with-opfs>) with `db.dropFile()` before another connection or database instance opens them.\n\n##  Durability\n\nDuckDB-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.\n\nDuckDB uses a [write-ahead log](</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.\n\nIn 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.\n\n**Call`CHECKPOINT` after writes you cannot afford to lose.** The [DuckDB documentation](</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.\n\n**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:\n    \n    \n    await conn.query('INSERT INTO transactions VALUES (...)');\n    await conn.query('CHECKPOINT');\n    \n\nIf 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:\n    \n    \n    await conn.query(`SET checkpoint_threshold = '0KB'`);\n    \n\nA clean shutdown looks like this:\n    \n    \n    await conn.query('CHECKPOINT');\n    await conn.close();\n    await db.terminate();\n    \n\nWhat happens when a tab is killed mid-transaction, and how to share one database between tabs, are covered in a follow-up post.\n\nThere 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.\n\n##  Export\n\nUsers 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](</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:\n    \n    \n    await conn.query('CHECKPOINT');\n    const root = await navigator.storage.getDirectory();\n    const handle = await root.getFileHandle('analytics.duckdb');\n    const file = await handle.getFile();\n    // Offer as a download, upload to your backend, etc.\n    const url = URL.createObjectURL(file);\n    \n\nOr export from SQL to Parquet:\n    \n    \n    COPY transactions\n    TO 'opfs://export/transactions.parquet'\n    (FORMAT parquet, COMPRESSION zstd);\n    \n\nCombined with [DuckDB's Parquet support](</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.\n\n##  Conclusion\n\nLack 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](</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.\n\nWith 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>).\n\n##### In this article\n\n  * Opening a Database\n    * Data Files\n    * File Handling Modes\n  * Durability\n  * Export\n  * Conclusion\n\n##  Recent posts \n\n[ All blog posts  ](<https://duckdb.org/news/>)\n\n[](</2026/09/16/duckdb-skills.html> \"DuckDB Skills for Claude Code\")\n\n### DuckDB Skills for Claude Code\n\n2026-09-16\n\n4 min\n\nThe DuckDB team\n\n[](</2026/09/02/try-duckdb-20-alpha.html> \"Try DuckDB v2.0-alpha\")\n\n### Try DuckDB v2.0-alpha\n\n2026-09-02\n\n2 min\n\nThe DuckDB team\n\n[](</2026/08/26/ducklabs-to-join-aws.html> \"DuckLabs to Join AWS, Projects to Remain Open Source\")\n\n### DuckLabs to Join AWS, Projects to Remain Open Source\n\n2026-08-26\n\n1 min\n\nMark Raasveldt and Hannes Mühleisen","body_html":"<h1 id=\"persistent-databases-in-the-browser-with-duckdb-wasm-and-opfs\">Persistent Databases in the Browser with DuckDB-Wasm and OPFS</h1>\n<p>Carlo Piovesan, Geertjan Wielenga</p>\n<p>2026-09-18 | 9 min</p>\n<p><em>TL;DR: DuckDB-Wasm can open a persistent database file in the browser&#39;s Origin Private File System (OPFS). This post shows how, and when data reaches disk.</em></p>\n<p>When <a href=\"/2021/10/29/duckdb-wasm.html\">DuckDB-Wasm was launched</a> 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.</p>\n<p>Modern browsers (since <a href=\"https://caniuse.com/wf-origin-private-file-system\" rel=\"nofollow ugc noopener\">March 2023</a>) now ship the <a href=\"https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system\" rel=\"nofollow ugc noopener\">Origin Private File System (OPFS)</a>, 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 <a href=\"/docs/current/clients/wasm/instantiation.html#persistence-with-opfs\">DuckDB documentation</a>: a database opened at an <code>opfs://</code> path survives reloads and browser restarts.</p>\n<p>The following call opens a database file in OPFS:</p>\n<pre><code>await db.open({\n    path: &#39;opfs://analytics.duckdb&#39;,\n    accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n});</code></pre>\n<p>The result is a regular <code>.duckdb</code> file with a write-ahead log and checkpoints that survives page reloads and browser restarts.</p>\n<blockquote><p>Note: at the time of writing, the build that npm serves as <code>latest</code> (1.33.1-dev57.0) creates the OPFS files but never writes to them, so nothing persists. It canonicalizes the path to <code>opfs:/analytics.duckdb</code> with a single slash, which no longer matches the OPFS handle. Pin 1.32.0 or use the <code>next</code> tag (1.33.1-dev64.0 or later).</p></blockquote>\n<h2 id=\"opening-a-database\">Opening a Database</h2>\n<p>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 <code>open</code> call, marked below. The import resolves to whichever version is installed, and <code>getJsDelivrBundles()</code> fetches the matching worker and <code>.wasm</code> files, so install a version that persists correctly: <code>npm install @duckdb/[[email protected]](&lt;/cdn-cgi/l/email-protection&gt;)</code> or <code>@next</code>.</p>\n<pre><code>import * as duckdb from &#39;@duckdb/duckdb-wasm&#39;;\n\nconst bundles = duckdb.getJsDelivrBundles();\nconst bundle = await duckdb.selectBundle(bundles);\n\n// Worker scripts must be same-origin, so wrap the CDN worker URL in a Blob\nconst workerUrl = URL.createObjectURL(\n    new Blob([`importScripts(&quot;${bundle.mainWorker}&quot;);`], {\n        type: &#39;text/javascript&#39;\n    })\n);\nconst worker = new Worker(workerUrl);\nconst db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);\nawait db.instantiate(bundle.mainModule, bundle.pthreadWorker);\nURL.revokeObjectURL(workerUrl);\n\n// NEW: open a persistent database in OPFS instead of the default :memory:\nawait db.open({\n    path: &#39;opfs://analytics.duckdb&#39;,\n    accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n});\n\nconst conn = await db.connect();\nawait conn.query(`\n    CREATE TABLE IF NOT EXISTS transactions (\n        id BIGINT,\n        ts TIMESTAMP,\n        merchant VARCHAR,\n        category VARCHAR,\n        amount DECIMAL(10, 2)\n    );\n`);\n\nawait conn.query(`INSERT INTO transactions VALUES (1, now(), &#39;Coolblue&#39;, &#39;electronics&#39;, 49.95)`);\nawait conn.query(&#39;CHECKPOINT&#39;);\n\nconst result = await conn.query(&#39;SELECT count(*) AS n FROM transactions&#39;);\nconsole.log(result.toArray()[0].n);</code></pre>\n<p>Reload the page and run the same code. The <code>CREATE TABLE IF NOT EXISTS</code> 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 <code>localStorage</code> key to remember. The <code>opfs://</code> prefix tells DuckDB-Wasm&#39;s file system layer to resolve the path against the origin&#39;s private file system instead of the in-memory Emscripten file system.</p>\n<p>Opening the database creates the database file and its <code>.wal</code> in OPFS. Builds from 1.33.1-dev64.0 onward also create two empty helper files, <code>.wal.checkpoint</code> and <code>.wal.recovery</code>, that DuckDB uses during checkpointing. The <code>.duckdb</code> 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.</p>\n<h3 id=\"data-files\">Data Files</h3>\n<p>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 <code>orders</code> table (scale factor 0.01, about 1,500 rows) that the <a href=\"https://shell.duckdb.org\" rel=\"nofollow ugc noopener\">DuckDB web shell</a> serves:</p>\n<pre><code>await conn.query(`\n    CREATE TABLE IF NOT EXISTS orders AS\n    SELECT * FROM &#39;https://shell.duckdb.org/data/tpch/0_01/parquet/orders.parquet&#39;;\n`);\nawait conn.query(&#39;CHECKPOINT&#39;);</code></pre>\n<p>DuckDB-Wasm reads the remote file with HTTP range requests. Because the table is created with <code>IF NOT EXISTS</code>, the file is fetched only on the first page load; on later loads the table comes from OPFS and no request goes to <code>shell.duckdb.org</code>. You can see this in the browser&#39;s Network tab, which lists the range requests on the first load and stays quiet afterwards, or in DuckDB-Wasm&#39;s own logs: the <code>ConsoleLogger</code> passed to <code>AsyncDuckDB</code> records each HTTP read, so the absence of those log lines on a reload confirms the data is served entirely from OPFS.</p>\n<p>With the data local, an aggregation can be written to a Parquet file in OPFS and read back later:</p>\n<pre><code>COPY (\n    SELECT o_orderpriority AS priority,\n           date_trunc(&#39;month&#39;, o_orderdate) AS month,\n           sum(o_totalprice) AS total\n    FROM orders\n    GROUP BY ALL\n) TO &#39;opfs://cache/monthly_totals.parquet&#39;;\n\nSELECT * FROM &#39;opfs://cache/monthly_totals.parquet&#39;;</code></pre>\n<p>Nested directories such as <code>cache/</code> are created on demand. OPFS files are ordinary DuckDB file paths, so globbing, <code>read_csv</code> and the other readers work as usual. Reading and writing <code>opfs://</code> paths from SQL needs one extra option on <code>open()</code>, described next.</p>\n<h3 id=\"file-handling-modes\">File Handling Modes</h3>\n<p>With <code>opfs: { fileHandling: &#39;auto&#39; }</code>, DuckDB-Wasm scans each statement for single-quoted <code>&#39;opfs://...&#39;</code> 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 <code>opfs://</code> path. Without it, every file other than the database has to be registered by hand:</p>\n<pre><code>// Option 1: automatic registration of opfs:// paths found in SQL\nawait db.open({\n    path: &#39;opfs://analytics.duckdb&#39;,\n    accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n    opfs: { fileHandling: &#39;auto&#39; },\n});\n\n// Option 2: manual registration (the default)\nawait db.open({\n    path: &#39;opfs://analytics.duckdb&#39;,\n    accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n});\nawait db.registerOPFSFileName(&#39;opfs://cache/monthly_totals.parquet&#39;);\n// ... run queries against it ...\nawait db.dropFile(&#39;opfs://cache/monthly_totals.parquet&#39;);</code></pre>\n<p>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 <a href=\"/docs/current/clients/wasm/instantiation.html#persistence-with-opfs\">dropping registered files</a> with <code>db.dropFile()</code> before another connection or database instance opens them.</p>\n<h2 id=\"durability\">Durability</h2>\n<p>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.</p>\n<p>DuckDB uses a <a href=\"/docs/current/internals/storage.html\">write-ahead log</a>. Committed transactions are appended to <code>analytics.duckdb.wal</code> first. The main file is updated at <em>checkpoint</em> time. A checkpoint happens automatically when the WAL grows past <code>checkpoint_threshold</code> (16 MB by default), when the database is closed cleanly, or when you run <code>CHECKPOINT</code> yourself.</p>\n<p>In a desktop process, &quot;closed cleanly&quot; 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.</p>\n<p><strong>Call<code>CHECKPOINT</code> after writes you cannot afford to lose.</strong> The <a href=\"/docs/current/clients/wasm/instantiation.html#persistence-with-opfs\">DuckDB documentation</a> is explicit about this: writes are flushed to OPFS by <code>CHECKPOINT</code>. 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.</p>\n<p><strong>Checkpoint per batch, not per statement.</strong> A large WAL also makes the <em>next</em> 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:</p>\n<pre><code>await conn.query(&#39;INSERT INTO transactions VALUES (...)&#39;);\nawait conn.query(&#39;CHECKPOINT&#39;);</code></pre>\n<p>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:</p>\n<pre><code>await conn.query(`SET checkpoint_threshold = &#39;0KB&#39;`);</code></pre>\n<p>A clean shutdown looks like this:</p>\n<pre><code>await conn.query(&#39;CHECKPOINT&#39;);\nawait conn.close();\nawait db.terminate();</code></pre>\n<p>What happens when a tab is killed mid-transaction, and how to share one database between tabs, are covered in a follow-up post.</p>\n<p>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&#39;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 <a href=\"https://ducklake.select/\" rel=\"nofollow ugc noopener\">DuckLake</a> catalog, or plain files on object storage through <code>s3://</code> paths.</p>\n<h2 id=\"export\">Export</h2>\n<p>Users will want to move their data to another device, back it up, or open it with a different tool. DuckDB-Wasm itself <a href=\"/docs/current/clients/wasm/instantiation.html#persistence-with-opfs\">cannot move files into or out of OPFS</a> yet, but the database is a plain DuckDB file and the browser&#39;s OPFS API lets you read it back as bytes:</p>\n<pre><code>await conn.query(&#39;CHECKPOINT&#39;);\nconst root = await navigator.storage.getDirectory();\nconst handle = await root.getFileHandle(&#39;analytics.duckdb&#39;);\nconst file = await handle.getFile();\n// Offer as a download, upload to your backend, etc.\nconst url = URL.createObjectURL(file);</code></pre>\n<p>Or export from SQL to Parquet:</p>\n<pre><code>COPY transactions\nTO &#39;opfs://export/transactions.parquet&#39;\n(FORMAT parquet, COMPRESSION zstd);</code></pre>\n<p>Combined with <a href=\"/docs/current/data/parquet/overview.html\">DuckDB&#39;s Parquet support</a>, 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 <code>.duckdb</code> file with your app, copy it into OPFS on first launch, and open it. Users get a local dataset without an import step.</p>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>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 <code>CHECKPOINT</code> after each batch of writes rather than after every statement, give users a way to download the database file, and read the <a href=\"/docs/current/clients/wasm/instantiation.html#persistence-with-opfs\">limitations listed in the DuckDB documentation</a> before shipping: one handle per file, and renames from SQL only work between two already-registered OPFS files.</p>\n<p>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 <a href=\"https://github.com/duckdb/duckdb-wasm\" rel=\"nofollow ugc noopener\">GitHub</a> or <a href=\"https://discord.duckdb.org\" rel=\"nofollow ugc noopener\">Discord</a>.</p>\n<h5 id=\"in-this-article\">In this article</h5>\n<ul><li>Opening a Database<ul><li>Data Files</li><li>File Handling Modes</li></ul></li><li>Durability</li><li>Export</li><li>Conclusion</li></ul>\n<h2 id=\"recent-posts\">Recent posts</h2>\n<p><a href=\"https://duckdb.org/news/\" rel=\"nofollow ugc noopener\"> All blog posts  </a></p>\n<p><a href=\"/2026/09/16/duckdb-skills.html\" title=\"DuckDB Skills for Claude Code\"></a></p>\n<h3 id=\"duckdb-skills-for-claude-code\">DuckDB Skills for Claude Code</h3>\n<p>2026-09-16</p>\n<p>4 min</p>\n<p>The DuckDB team</p>\n<p><a href=\"/2026/09/02/try-duckdb-20-alpha.html\" title=\"Try DuckDB v2.0-alpha\"></a></p>\n<h3 id=\"try-duckdb-v2-0-alpha\">Try DuckDB v2.0-alpha</h3>\n<p>2026-09-02</p>\n<p>2 min</p>\n<p>The DuckDB team</p>\n<p><a href=\"/2026/08/26/ducklabs-to-join-aws.html\" title=\"DuckLabs to Join AWS, Projects to Remain Open Source\"></a></p>\n<h3 id=\"ducklabs-to-join-aws-projects-to-remain-open-source\">DuckLabs to Join AWS, Projects to Remain Open Source</h3>\n<p>2026-08-26</p>\n<p>1 min</p>\n<p>Mark Raasveldt and Hannes Mühleisen</p>","headings":[{"level":1,"text":"Persistent Databases in the Browser with DuckDB-Wasm and OPFS","id":"persistent-databases-in-the-browser-with-duckdb-wasm-and-opfs"},{"level":2,"text":"Opening a Database","id":"opening-a-database"},{"level":3,"text":"Data Files","id":"data-files"},{"level":3,"text":"File Handling Modes","id":"file-handling-modes"},{"level":2,"text":"Durability","id":"durability"},{"level":2,"text":"Export","id":"export"},{"level":2,"text":"Conclusion","id":"conclusion"},{"level":2,"text":"Recent posts","id":"recent-posts"},{"level":3,"text":"DuckDB Skills for Claude Code","id":"duckdb-skills-for-claude-code"},{"level":3,"text":"Try DuckDB v2.0-alpha","id":"try-duckdb-v2-0-alpha"},{"level":3,"text":"DuckLabs to Join AWS, Projects to Remain Open Source","id":"ducklabs-to-join-aws-projects-to-remain-open-source"}]}}