stable

Browser durability

A pure SPA that survives a cleared cache: to-browser-idb for the live vault, a .noydb pod for the backup, and two tiers of getting that pod to disk.

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-idb for the live vault, @noy-db/hub/pod for the backup, and optionally @noy-db/to-browser-fs for 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

CapabilityProvided by
Live vault that survives reloadtoBrowserIdb() — IndexedDB, casAtomic: true
A portable, encrypted copy the user holdswritePod(vault) → a .noydb file
Restore into a fresh browserreadPod(bytes)
Backup on a timer, few clicksto-browser-fs + rememberDirectory() — see the permission caveat below
Backup when data actually changesdb.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 guarantee
  • to-browser-fs — the backup target's full API, its four access states, and the activation-window trap
  • bundle — the .noydb container format, header disclosure, and readPod options
  • live — db.on('change')'s neighbour, for reacting in the UI rather than backing up