Browser durability
Audience: anyone shipping noy-db in a browser with no server of their own, who has realised that IndexedDB is not a backup. Bundle: core +
@noy-db/to-browser-idbfor the live vault,@noy-db/hub/podfor the backup, and optionally@noy-db/to-browser-fsfor the unattended tier.
Verified by
showcases/src/recipe-browser-durability.recipe.test.ts
— the pod round trip and the change-event trigger below are the code that runs there, against
an in-memory store. ⛔ The File System Access tier and event.remote === true are not
covered: the first is browser-only, and the second needs a second tab applying a signal. What
the test does pin is that a local write leaves remote falsy, so the guard below cannot
silently drop the backup it exists to take.
A browser vault has one durability problem and it is not encryption: IndexedDB is evictable. The user clears site data, the browser reclaims storage under pressure, the profile is reset — and a vault that existed only in IDB is gone. The fix is not a different store. It is a second copy, in a format the user can keep.
That copy is a .noydb pod: writePod(vault) returns the whole vault as bytes, and
readPod(bytes) reads it back. Both live in the hub, so a browser backup needs no store
package at all — the store keeps your live data, the pod is how you leave.
Prerequisites
None beyond the Bundle line. The manual tier below works in every browser; the unattended tier needs the File System Access API and degrades to the manual tier where it is absent.
What this gets you
| Capability | Provided by |
|---|---|
| Live vault that survives reload | toBrowserIdb() — IndexedDB, casAtomic: true |
| A portable, encrypted copy the user holds | writePod(vault) → a .noydb file |
| Restore into a fresh browser | readPod(bytes) |
| Backup on a timer, few clicks | to-browser-fs + rememberDirectory() — see the permission caveat below |
| Backup when data actually changes | db.on('change', …) |
Setup
import { createNoydb, writePod, readPod } from '@noy-db/hub'
import { toBrowserIdb } from '@noy-db/to-browser-idb'
const db = await createNoydb({
store: toBrowserIdb(),
user: 'alice',
secret: 'a-real-passphrase-2026',
})
const vault = await db.openVault('notes')
writePod and readPod are also available from the @noy-db/hub/pod
subpath if you prefer to keep the root barrel small.
Tier 1 — the download, which works everywhere
const bytes = await writePod(vault)
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' }))
const a = document.createElement('a')
a.href = url
a.download = podFilename('notes')
a.click()
URL.revokeObjectURL(url)
writePod is async — it returns a Promise<Uint8Array>, not bytes. The file is a
.noydb container: magic NDB1, a minimum-disclosure unencrypted header, and a
compressed body. There is no .pod and no .bundle extension.
::: warning A timer cannot click this link
A <a download> that fires from a user gesture downloads. The same line on a
setInterval does not — browsers block downloads with no user activation behind it.
A pure SPA therefore cannot back itself up on a schedule, and no amount of code
changes that. It is the reason tier 2 exists.
:::
Tier 2 — one grant, then unattended
@noy-db/to-browser-fs is usually read as a store. Here it
is a backup target, which is a much easier thing for it to be: a pod write is one
whole file, so none of the per-record concerns that make a filesystem an awkward primary
store apply.
import { rememberDirectory, recallDirectory } from '@noy-db/to-browser-fs'
// Once, from a click: the user picks a folder and the handle is persisted in IDB.
async function grantBackupFolder() {
const handle = await (window as any).showDirectoryPicker({ mode: 'readwrite' })
await rememberDirectory('backups', handle)
}
// Afterwards, with no interaction — including on a timer.
async function backupTo(handle: any, name: string, bytes: Uint8Array) {
const file = await handle.getFileHandle(name, { create: true })
const w = await file.createWritable()
await w.write(bytes)
await w.close()
}
const handle = await recallDirectory('backups')
if (handle) {
await backupTo(handle, podFilename('notes'), await writePod(vault))
} else {
// No grant yet, or a browser without the API — fall back to tier 1.
}
The handle survives reload because rememberDirectory puts it in IndexedDB;
recallDirectory returns null when there is nothing stored, which is also what you get
in a browser that never had the API. That null is the whole degradation story — a
missing capability reads as "no folder granted", the app falls back to the download, and
nothing throws. forgetDirectory drops the grant.
⚠️ A recalled handle is usable but not necessarily permitted. Remembering it saves the
user re-picking the folder; it does not carry the permission across reloads. Check
store.access() and be ready to call requestAccess() from a fresh click — 'prompt' is
a normal state on a new page load, not an error. And requestAccess() is the only call
that prompts, so spend the gesture before any slow work: a vault unlock's PBKDF2
outlasts Chrome's ~5-second activation window, and asking afterwards means the prompt
never appears. The to-browser-fs page has the four access
states and the ordering in full.
Availability is narrow — the File System Access API is a Chromium-desktop feature, and not a gap waiting to be filled: Safari's OPFS is origin-private and cannot see a mounted volume at all. Treat tier 2 as progressive enhancement and never as the only path, or Safari and Firefox users have no backup.
Backing up when data changes, not when a clock fires
A wall-clock timer backs up an idle vault and misses a burst. Bind the change event instead:
let dirty = false
db.on('change', (event) => {
// Another tab already wrote this. Let the tab that made the change own the backup.
if (event.remote) return
dirty = true
})
setInterval(async () => {
if (!dirty) return
dirty = false
const handle = await recallDirectory('backups')
if (handle) await backupTo(handle, podFilename('notes'), await writePod(vault))
}, 60_000)
Two details decide whether this works, and both are easy to get wrong:
⛔ Bind db.on('change', …), not collection.subscribe(…). subscribe is a
per-collection wrapper that hydrates the record on every event — on a lazy collection
that is a decrypt per change, paid to learn a fact you could have had for nothing. It also
only covers collections you had already subscribed to, so a collection created later is
silently unbacked. ChangeEvent carries vault, collection, id and action —
everything a "something changed, back up" trigger needs, with no record read.
⛔ Honour event.remote. It is true when the change arrived by applying another
tab's signal rather than from a write here. Ignore it and every open tab writes its own
pod for the same logical change: N tabs, N identical backups, each a full vault write.
The debounce above does not save you — each tab debounces its own copy. Hub itself reads
this flag for echo suppression in ChangeBroadcast, so you are following an established
rule, not inventing one.
Filenames and retention
const podFilename = (vault: string) =>
`${vault}-${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}.noydb`
// → notes-20260917T081500Z.noydb
Compact UTC ISO-8601 makes lexical sort equal chronological sort, so retention is a sort and a slice rather than date parsing:
const keepLastN = (names: string[], n: number) => names.sort().slice(-n)
Keep-last-N, not grandfather-father-son. A browser backup competes with the user's disk and their patience, and a rotation scheme nobody can explain gets turned off.
Why full pods, not incremental
WritePodOptions has a since option, and a nightly delta is tempting. Resist it for
this use case: incremental backups turn restore into a chain replay. A full pod
restores by itself, from one file, with no ordering to reconstruct and no missing link to
discover at the worst possible moment. The vault that fits in a browser is the vault
where a full pod is cheap — the trade only starts to favour deltas at a size IndexedDB
was not going to hold anyway.
Verify
Prove the round trip, in a fresh context, before you trust any of it:
const bytes = await writePod(vault)
// … clear site data, or open a different profile …
const restored = await readPod(bytes)
readPod returns a PodReadResult, not a vault — read its contents to confirm the
records are there. ⭐ Do this once against a genuinely cleared profile. A backup
verified only by re-reading it in the tab that wrote it has tested your Uint8Array, not
your durability story.
See also
to-browser-idb— the live store, and its atomicity guaranteeto-browser-fs— the backup target's full API, its four access states, and the activation-window trap- bundle — the
.noydbcontainer format, header disclosure, andreadPodoptions - live —
db.on('change')'s neighbour, for reacting in the UI rather than backing up