Data & persistence

OPFS vs localStorage

The embedded daemon persists two kinds of browser-local data:

  1. Encrypted seed and wallet metadata (the wallet database).
  2. SQLite state for swap tracking and other daemon bookkeeping (via an OPFS-backed VFS when available). Set swapDatabaseFileName in RuntimeConfig to name this SQLite file explicitly; set disableSwaps to turn off the Lightning swap subsystem (and its storage) entirely. When swaps are disabled, swap endpoint settings (from the network preset or your overrides) are omitted from the daemon config.

OPFS (Origin Private File System) is the production path. Files live under the page origin in a private directory the page cannot enumerate from outside the API. The shipped WASM daemon stores the encrypted seed and SQLite databases in OPFS. Set dataDir in RuntimeConfig to choose a subdirectory (for example '/my-wallet'); when unset the daemon uses its default.

localStorage is a window-only key/value store. It is not available inside Web Workers, so the default worker transport cannot use it for daemon storage. Main-thread mode can fall back to storage patterns that do not require OPFS, but you lose durable SQLite persistence and the UI thread blocks while the daemon runs.

Your app may still use localStorage for app-level markers (for example recording whether a wallet was created with a passkey or storing a passkey credential id for scoped unlock). The reference demo does this; daemon state remains in OPFS.

OPFS-backed SQLite requires SharedArrayBuffer, which browsers only expose on cross-origin isolated pages. Without isolation, OPFS persistence for the daemon will not come up cleanly.

Request persistent storage (navigator.storage.persist()) if you want the browser to deprioritize eviction of origin data. It is best-effort and does not replace backing up the recovery phrase.

// Ask the browser to treat this origin's storage as persistent.
const granted = await navigator.storage.persist();

Whether the browser grants the request depends on its own engagement heuristics (installed as a PWA, bookmarked, frequently visited, and similar signals). Do not rely on it being granted; treat it as best-effort.

The runtime cache

Separately from wallet data, the SDK keeps a copy of the daemon’s WASM binary in Cache Storage, in a bucket named wavelength-runtime-v1-<version>, where the trailing segment is the RUNTIME_MANIFEST_VERSION the binary belongs to. This is a performance cache and holds no wallet state.

It exists because the browser will not keep the module in its HTTP cache. The binary is around 20 MB compressed, which is large enough that Chrome declines to store it even when it is served with Cache-Control: immutable and hits a CDN edge, so without a cache of our own every single page load re-downloads the whole thing. Once the bytes are local, reading them back, verifying the cached module’s digest, compiling, and instantiating it takes on the order of 165 ms; hashing and compiling cost about the same as each other, and the read rather less (re-verifying hashes the full decompressed module on every read; see Integrity verification), so on a 50 Mbps connection caching takes the runtime load from roughly 3.2 s to around 165 ms.

The cache holds one runtime at a time. It is keyed on RUNTIME_MANIFEST_VERSION, not on the asset URL, so upgrading the SDK to a runtime with a new version looks in a bucket that does not exist yet, refetches, and drops the previous version’s bucket on the first load after the upgrade. That holds however you host the files: a versioned asset path is a good idea for browser caching, but it is your choice, and the runtime cache does not depend on it. A wallet that has been through several upgrades holds one copy, not one per release. Budget roughly 130 MB of origin storage for it, since what is stored is the decompressed module.

Pass runtimeCache: false to createWebClient to take every runtime binary from the network instead. That is worth knowing about when you are iterating on a local daemon build: a rebuild at the same RUNTIME_MANIFEST_VERSION produces new bytes under a bucket name that has not changed, so the cached copy keeps being served, and the browser offers no way out of this on its own. DevTools’ Disable cache governs the HTTP cache and leaves Cache Storage alone; only Application → Storage → Clear site data removes the entry.

Turning the option off deletes nothing. An existing bucket is left exactly as it is and simply not read or written, so turning it back on resumes from it.

Nothing here is load-bearing. Cache Storage is unavailable outside a secure context and rejects writes once an origin is over quota, and the browser may evict the entry at any time. Every one of those cases falls back to downloading the runtime, which is the behavior you had before the cache existed. If the origin is evicted or the entry turns out not to be usable, the SDK drops it and refetches, so a bad cached copy cannot wedge the wallet.

Requesting persistent storage, as above, also makes the browser less likely to evict this cache.

Main thread vs worker

createWebClient() accepts runtimeThread: 'worker' (default) or 'main'.

'worker' (default)

Thread: Dedicated Web Worker. Storage: OPFS.

Production web apps. Keeps the UI responsive while the daemon runs.

'main'

Thread: Page main thread. Storage: No OPFS SQLite path.

Escape hatch when Workers are unavailable or you cannot set COOP/COEP headers. Expect UI jank.

Worker mode spawns the bundled wavewalletdk-worker.js (override with workerURL). The worker receives runtimeBaseUrl on init so it can fetch wavewalletdk.wasm.gz, wasm_exec.js, and the SQLite bridge from your hosted asset folder. Main-thread mode loads those scripts on the page directly.

client.ts
import { createWebClient, defaultConfig } from '@lightninglabs/wavelength-web';
// Default: worker + OPFS (requires cross-origin isolation).
const workerClient = createWebClient({
runtimeBaseUrl: 'https://your-host/wavewalletdk/',
});
// Escape hatch: main thread, no OPFS persistence.
const mainClient = createWebClient({
runtimeThread: 'main',
runtimeBaseUrl: 'https://your-host/wavewalletdk/',
});
// Choose a dataDir subdirectory for the daemon's on-disk tree.
const config = defaultConfig('signet', { dataDir: '/my-wallet' });
await workerClient.ready();
await workerClient.start(config);

Persistence requirements for the default worker path:

  1. Serve COOP/COEP headers so crossOriginIsolated is true and SharedArrayBuffer is available.
  2. Host the runtime asset set (RUNTIME_ASSET_FILES) at runtimeBaseUrl.
  3. Use a secure context (HTTPS or localhost) for WebAuthn passkeys.
  4. Pick a stable dataDir per wallet profile; changing it starts a fresh on-disk tree.

Wiping local wallet data means clearing both OPFS entries under your origin and any app-level localStorage keys you wrote. See the demo’s wipe helper (apps/web-wallet-demo/src/lib/wipeLocalData.ts) for a reference pattern, and the wavelength-web reference for the client start/stop lifecycle.