A local-first app stores data in two places it does not own. The browser’s IndexedDB sits on the user’s machine but under the browser’s eviction policy: the platform can refuse a write, abort a transaction, or reclaim the whole origin’s storage under pressure. The sync backend sits on the provider’s machines under an account quota that turns every retained byte into a bill. Budgeting bytes on someone else’s machine means designing both of those budgets as product constraints, not error paths.
This article works through how Soundfish does it: hard account quotas expressed as typed contract constants, a failure taxonomy that keeps a quota refusal distinct from a crash, durable local evidence for every remote transition, and a precise account of what encryption does and does not cover.
two budgets, not one
The browser side and the server side have opposite failure shapes. IndexedDB quota is vague by design: the platform decides an origin’s allowance from device pressure, storage pressure, and browser policy, and it can evict “best-effort” data without asking. The only honest posture is to treat local persistence as provisional: cap what you keep, classify refusals, and never let a failed write leave half a state behind.
The server side has the opposite problem: the quota is yours, so it has to be a number. Soundfish writes its numbers into the shared contract in lib/library/model.ts, the same constants the Convex functions import, so the browser model and the backend agree by construction rather than by convention. An account gets MAX_LIBRARY_LOOPS_PER_ACCOUNT = 256 loops, MAX_LIBRARY_VERSIONS_PER_LOOP = 256 versions each, MAX_LIBRARY_VERSIONS_PER_ACCOUNT = 4_096 retained versions overall, MAX_LIBRARY_BYTES_PER_ACCOUNT = 64 MiB of referenced canonical bytes, and MAX_LIBRARY_WRITES_PER_HOUR = 600 checkpoint attempts per rolling window. Every number is a product decision that the schema enforces, and every refusal returns quota_exceeded naming which quota tripped (bytes, loops, versions, or writes), so the client can tell the user the truth.
browser storage is a lease
The browser half of the design is in lib/storage/ and lib/library/local-library.ts. Three rules carry it:
- Every logical mutation is one IndexedDB transaction. A save, a delete, an import, a sync-attempt proof, or a deletion fence each commit atomically: a failed commit is retryable without duplicates or partial state. The transaction, not the UI, is the unit of “did this happen.”
- Failures are classified, not swallowed.
QuotaExceededErrorand Firefox’sNS_ERROR_DOM_QUOTA_REACHEDmap to{ kind: "quota" };SecurityError,AbortError, decode failures, and transaction failures each get their own kind. A quota refusal is a different remedy than a corrupted record, and the caller sees which one it got. - Stored values are decoded through codecs, not trusted. Every store in the local library (
heads,snapshots,versions,syncAttempts,deletionFences,state) reads through a Zod codec, and the transaction aborts on a codec failure. Storage you cannot decode is corruption evidence, not data.
what encryption covers and what it does not
“Encrypted sync” deserves precision, because two different claims get bundled under it.
What is encrypted in Soundfish’s sync path: transport and credentials. Cloud calls run over HTTPS under an authenticated session; the Suite OIDC access token stays inside a server boundary sealed into an AES-GCM–encrypted HttpOnly cookie (12-byte IV, 128-bit tag, additional authenticated data) rather than exposed to the page or to IndexedDB. Every cloud request is also bound to the browser’s expected account through the x-soundfish-suite-account header as well as the HttpOnly session, so an account change returns a typed conflict before any sync operation runs.
What is not encrypted end-to-end: the document bytes. Cloud snapshots are the canonical #s1. fragments. The server stores canonical document bytes keyed by transport digest so it can serve them back to your other devices and verify the MusicCID. The honest statement is that Soundfish sync is authenticated and encrypted in transit and at the session boundary, but it is not end-to-end-encrypted content; the service can read the loops it stores. A local-first app that wants content the provider cannot read has to encrypt the payload before the digest and give up server-side verification of what it stores. Soundfish chose the verifiable design and scoped the trust accordingly.