Backing Up and Exporting Browser Databases

This page answers one task: an application keeps users’ data in a WebAssembly database inside the browser — SQLite with OPFS, PGlite in IndexedDB, or DuckDB-Wasm — and that data may be the only copy. You want users to be able to export it, the application to back it up safely, and restores to work when they are needed.

Prerequisites

  • [ ] A browser database persisted to OPFS or IndexedDB.
  • [ ] A worker that owns the database connection.
  • [ ] Optionally, a server endpoint or storage bucket for backups.

Why browser data needs backups

Browser storage is not as durable as it looks. Browsers evict origin data under storage pressure unless the origin holds persistent storage; users clear site data, often without realising what it contains; private browsing modes discard everything at the end of the session; reinstalling a browser or switching devices leaves the data behind. A local-first app that stores a user’s work only in OPFS can lose all of it in one click. Backups — user-initiated exports and automatic copies elsewhere — are part of the product, not an afterthought.

Backing up a live database also needs care: copying the OPFS file while the database is writing can produce a corrupt copy. The database must produce a consistent snapshot.

Ways browser data gets lost and the matching protection Storage eviction under pressure is mitigated by requesting persistent storage. Users clearing site data and moving devices are mitigated by exports and server backups. Corruption from copying a live file is avoided with consistent snapshots. Bugs in migrations are mitigated by a backup before each migration. risk protection eviction under storage pressure navigator.storage.persist() user clears site data, new device export + server backup copying a live database file consistent snapshot (VACUUM INTO / backup API) bad schema migration automatic backup before migrating

Step 1 — request persistent storage

if (navigator.storage?.persist) {
  const persisted = await navigator.storage.persisted() || await navigator.storage.persist();
  console.log(persisted ? "storage is persistent" : "storage may be evicted");
}
const { usage, quota } = await navigator.storage.estimate();

Browsers grant persistence on their own criteria (installed apps, engagement, bookmarks); it reduces eviction risk but does not prevent users from clearing data. Show the status in settings, and remind users to export if persistence is denied.

Step 2 — take a consistent snapshot

For SQLite, VACUUM INTO writes a complete, consistent copy of the database to a new file, compacted, while the original stays usable:

// in the database worker
db.exec(`VACUUM INTO '/backups/notes-${Date.now()}.db'`);   // a new OPFS file

The SQLite online backup API (exposed by the official Wasm build’s lower-level functions) copies page by page and can run incrementally for very large databases. Either way, the copy is a valid database file. For PGlite, use its dump facilities (pg_dump-style SQL export or its data-directory export feature); for DuckDB-Wasm, EXPORT DATABASE to Parquet or CSV files, or COPY tables out.

Step 3 — let users export and import

Offer a visible “Export data” action that produces a file users can keep:

// main thread: ask the worker for a snapshot and download it
const bytes = await dbWorker.exportSnapshot();      // Uint8Array of the VACUUM INTO file
const blob = new Blob([bytes], { type: "application/vnd.sqlite3" });
const url = URL.createObjectURL(blob);
Object.assign(document.createElement("a"), { href: url, download: "notes-backup.sqlite" }).click();
URL.revokeObjectURL(url);

For large databases, stream the file instead of building one large array: the File System Access API’s showSaveFilePicker returns a writable stream where supported. Import is the reverse — read the file, validate it (PRAGMA integrity_check, schema version check), and replace or merge.

A user-initiated export and later restore The user clicks export. The database worker writes a consistent snapshot with VACUUM INTO, reads it back and streams it to a downloaded file. Later, on a new device, the user imports the file, the worker validates it with an integrity check and schema version, and replaces the empty database. click Export settings page VACUUM INTO snapshot consistent copy download / save picker user keeps the file Import on new device pick the file validate + replace integrity_check, version

Step 4 — back up to a server automatically

For users with accounts, schedule backups: after significant changes or daily, take a snapshot, compress it, encrypt it client-side if the server should not read it, and upload it. Keep several generations server-side so a bad change can be rolled back. Incremental approaches reduce traffic for large databases — upload only changed pages (track them with a page-level hash list) or rely on a sync protocol — but a periodic full snapshot remains the simplest reliable backup.

Step 5 — test restores

A backup that has never been restored is a hope, not a backup. Automate a restore test: take a snapshot of a test database with known contents, restore it into a fresh origin or a new OPFS directory, open it, run integrity checks and compare row counts and checksums. Test restoring snapshots from older app versions too, which exercises schema migrations on import. Run it in a real browser in CI.

Backups before migrations

Schema migrations are the most common cause of self-inflicted data loss. Before running migrations on a user’s database, take a VACUUM INTO snapshot in OPFS; if the migration fails or the app detects a problem afterwards, restore from it. Delete old pre-migration snapshots after a successful run and a grace period, to avoid filling the quota. See migrating schemas in a browser database.

File formats users can use

A raw SQLite file is a great backup format — complete, compact, openable with standard tools — but not everyone can read it. Consider also offering a human-readable export (JSON, CSV, Markdown for notes) so users are never locked in. That export does not need to round-trip perfectly; it exists so the user owns their data in a form they can use elsewhere.

Managing backup storage within the quota

Snapshots kept in OPFS count against the origin’s quota, and a database that keeps three local snapshots uses four times its own size. Keep local snapshots few and short-lived: one pre-migration snapshot, removed after a successful run, and perhaps one recent automatic snapshot as a quick undo. Check navigator.storage.estimate() before writing a snapshot and skip or warn if it would exceed a safe fraction of the quota, since a write that fails halfway leaves a partial file to clean up. Server-side or user-downloaded copies are the real backups; local snapshots exist for fast recovery from application mistakes, not for device loss. When the quota is tight, prefer an export that streams straight to the server without being stored in OPFS first.

Reminding users at the right moments

Users rarely export on their own. Prompt at moments when the risk is real and the request makes sense: when persistence was denied, before the user clears data through your app’s settings, after a large import or a long editing session, and periodically for users without an account. Show when the last backup or export happened. For apps with accounts, make server backup the default and explain what it covers, so the export button becomes a convenience rather than the only safety net.

Restoring partially

Whole-database restores replace everything, including work done since the backup. Where the data model allows, offer partial restores: attach the backup as a second database (ATTACH DATABASE in SQLite) and copy back only the selected documents or tables. That turns “I deleted a note last week” from a choice between losing a week’s work and losing the note into a simple recovery.

Expected output

The settings page shows “Storage: persistent, 41 MB of 2 GB used”; “Export data” downloads a 38 MB .sqlite snapshot taken with VACUUM INTO; importing it on another browser restores all notes after an integrity check; signed-in users get an encrypted daily server backup with seven generations; and CI restores a snapshot from each of the last three app versions.

Gotchas

  • Copying the live OPFS file. Inconsistent copies. Use VACUUM INTO or the backup API.
  • Assuming browser storage is permanent. Eviction and clearing happen. Back up elsewhere.
  • Building huge arrays for export. Memory spikes. Stream large exports.
  • Never testing restores. Backups fail when needed. Automate restore tests.
  • Migrating without a snapshot. One bug destroys data. Snapshot first.
  • Local snapshots filling the quota. They count against storage. Keep few and short-lived.

Performance note

VACUUM INTO of a 200 MB database took about 1.4 s in a worker on a laptop and 4 s on a mid-range phone; compressing the snapshot with a Wasm zstd encoder reduced the upload from 200 MB to 46 MB.

Backup sizes for a 200 MB notes database Megabytes for the live database file, a VACUUM INTO snapshot, and the snapshot compressed with zstd before upload. MB live OPFS file 200 MB VACUUM INTO snapshot 182 MB snapshot + zstd 46 MB

Frequently Asked Questions

Can I back up while the user is editing? Yes — snapshots are consistent and the database stays usable, though a large VACUUM INTO takes a moment of worker time.

Does navigator.storage.persist() prevent all loss? No — users can still clear data; it only reduces automatic eviction.

How do I back up IndexedDB-based databases? Use the database’s own export functions; copying IndexedDB records directly risks inconsistency.

Should backups be encrypted? If the server should not read user data, encrypt them client-side before upload.

Can I restore just one deleted item from a backup? Yes with SQLite — attach the backup as a second database and copy back the selected rows.

How many local snapshots should I keep? One or two at most — a pre-migration snapshot and perhaps a recent automatic one; real backups belong off the device.

Which format should a user-facing export use? Offer the raw database file for complete restores and a readable format such as JSON or CSV so users can take their data elsewhere.

← Back to Databases & Persistent Storage in Wasm