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.
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.
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 INTOor 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.
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.
Related
- Persisting a Wasm database to OPFS — where the data lives.
- Encrypting a browser database at rest — encrypted backups.
- Syncing a browser database with a server — continuous replication.
- Migrating schemas in a browser database — safe upgrades.
← Back to Databases & Persistent Storage in Wasm