Browser storage is not durable storage
IndexedDB can be deleted without asking you - under disk pressure, after seven days of Safari inactivity, or with one click in the settings. What an app can actually find out about its own storage, and what it should do with the answer.
A local-first app puts the user’s data in the browser, and the sentence that usually follows is “so it stays on their device”. True, and incomplete. Staying on the device is not the same as staying. The browser reserves the right to delete an origin’s storage, it exercises that right, and it does not ask first.
This is not an argument against local-first. It is the constraint local-first has to be designed around, the same way a server-backed app is designed around the network being down. The difference is that everyone knows the network fails, and almost nobody knows IndexedDB does.
The three things that delete an app’s local data
Disk pressure. Storage granted to an origin is “best-effort” by default: it survives as long as the origin stays under its quota, the device has room, and the user does not clear it. When the device runs low, the browser evicts best-effort origins, least recently used first. Chrome also caps total storage at a share of the disk and evicts when the whole browser passes it.
Safari’s seven-day cap. WebKit’s Intelligent Tracking Prevention deletes “all of a website’s script-writable storage after seven days of Safari use without user interaction on the site” (WebKit, March 2020). Script-writable storage means IndexedDB, localStorage, sessionStorage, service worker registrations and media keys - which is to say, everything a local-first app writes. Seven days of Safari use, not seven days of calendar time: an app opened every second week on an iPhone is inside the deletion window permanently. A web app added to the home screen is the exception, since its own use resets the clock.
One click. “Clear site data”, a new profile, a wiped machine, a colleague’s browser, private browsing closing its session. This one is not a bug and never will be: the whole point of the local-first bargain is that the user is in charge of their own device.
The eviction cases share an unpleasant property: eviction is all or nothing for an origin. Browsers do not delete half a database, because a half-deleted database is worse than none. So the failure mode is not degraded data, it is a first launch with nothing in it.
What an app can actually ask
Two calls, both cheap, both worth wiring on day one.
navigator.storage.estimate() returns an approximate usage and quota in
bytes. Approximate is deliberate: the numbers are padded so that one origin
cannot measure another. It is enough to tell a user that they are at 90 percent
of what the browser will give them, and enough to stop writing before a write
fails with QuotaExceededError in the middle of something.
navigator.storage.persist() asks for the other kind of storage: persistent,
which is only deleted when the user asks for it to be. The answer is a boolean,
and how it is decided differs by engine. Firefox shows a permission prompt.
Chromium browsers decide silently from signals like site engagement, whether
the app is installed, and whether notifications were granted, so the same call
can return false today and true after the user has actually used the app a
few times. navigator.storage.persisted() reports the current state without
asking.
The practical consequence is that persistence is not something an app has.
It is something an app may be granted, later, on a device, for reasons it does
not control. Code that treats the first false as final gives up too early;
code that assumes true was granted has not read the return value.
Designing for the deletion
If the browser can drop everything, then the browser copy cannot be the only copy. That is the architectural split worth making explicit: the local database is a working copy - fast, offline, disposable - and the durable home is somewhere the user owns and the browser cannot touch. A file on disk, their own cloud drive, a WebDAV server, an S3 bucket. selfstore is built on that split; the point stands whatever library you use, or none.
Three habits follow, and they are worth more than any of them looks:
- Make the durable copy automatic. A backup that depends on the user remembering is a backup that exists on the day they lose the data, and not before. Write on change, not on a button.
- Ask for persistence at a moment that has earned it. Calling
persist()on first paint gets a shrug in Chromium and a prompt in Firefox that the user has no reason to accept yet. Calling it after the user has created something they would hate to lose gets a better answer from both the browser and the human. - Treat empty as a state, not an error. An app whose storage was evicted starts up looking brand new. If it can say “this browser has no local data; reattach your backup” instead of silently presenting an empty workspace, the eviction becomes a minor inconvenience instead of a catastrophe.
The honest version of the promise
“Your data never leaves your device” is a claim about where data goes. It is not a claim about how long it stays, and conflating the two is how local-first apps lose people’s work while technically keeping their promise.
The version that survives contact with a real browser is longer and better: your data lives on your device for speed and for privacy, it is copied continuously to storage you own, and either copy can be lost without losing the other. That is a promise about durability, and it is one the browser cannot break on its own.