Browser storage eviction: quota, Safari, and the rebuild path
A support ticket that begins "all my data is gone" is usually not a bug in your code. The browser deleted it, deliberately, and told nobody — not your app, not the user. The next open() succeeds against an empty database and everything downstream behaves as though the account is new.
This is the part of browser-side storage that gets skipped, because it does not show up on a laptop with a full disk and a site you visit every day. It shows up in production, on somebody's iPhone, three weeks later.
Three ways local data disappears
Quota pressure. Everything your origin stores — OPFS, IndexedDB, Cache Storage, localStorage — shares one budget, and that budget is a share of the device's free disk rather than a fixed allowance. When the device runs low the browser evicts, and it evicts by origin: not your largest table, not your oldest rows, the whole origin at once. Least recently used goes first, which means the users most likely to lose data are exactly the ones who use your app least.
Time. Safari's tracking prevention clears script-writable storage after seven days of browser use without interaction with your site. Not seven days of wall clock — seven days of Safari being used while your site is not. A user who opens your app every second Monday can lose their local database in between, forever, without ever doing anything unusual. Two things are exempt: sites installed to the home screen, and origins that were granted persistence.
Because this rule belongs to WebKit rather than to the Safari badge, on iOS it is closer to a platform rule than a browser one. Private browsing is harsher still: there is no OPFS in a Safari private window at all — a hard failure at open time, not a slow path. And iOS apps wrapping a web view through Capacitor lose their OPFS access handles when the app is backgrounded, which is a different failure with the same shape.
Everything else. Cleanup utilities delete OPFS as "Internet Cache". Windows low-disk cleanup clears it. Chrome's incognito mode caps an OPFS database well below the normal quota and produces surprising errors at the ceiling. And underneath all of that, field data across this whole ecosystem shows roughly 0.1–0.2% of users hitting outright corruption from browser crashes and third-party software — no eviction policy involved, just a file that no longer opens.
What estimate() actually tells you
const { quota, usage } = await navigator.storage.estimate();Useful, with three caveats worth knowing before you build a storage meter on it.
quota is derived from free disk space, so it moves. It is not an allowance the browser has set aside for you; it is a ceiling computed from conditions that change while your app is open. usage covers everything the origin stores, so it is not a measure of your database — await db.size() is the number that answers "are we the problem". And both are deliberately imprecise, padded and rounded so that one origin cannot fingerprint a device by measuring another's footprint. Treat them as orders of magnitude.
The important part is what estimate() does not tell you. Being comfortably under quota does not mean your data will be there tomorrow. Quota governs whether a write succeeds now; none of the three causes above is a quota violation.
persist() is a request, not a setting
const persisted = await navigator.storage.persist();Ask, always — it is one line and it is what exempts you from Safari's seven-day rule. Then read the boolean and design for false.
Chrome decides silently from engagement signals and may simply say no, with no prompt and no recourse. A false is a normal answer, not an error to retry. Even a true is narrower than it sounds: persisted storage still goes when the user clears site data, when a cleanup tool sweeps the disk, or when the file corrupts. Persistence removes one deletion path out of several.
So persist() is worth calling and not worth branching on. If your app behaves differently depending on its result, you have built two code paths and you only ever test one.
The consequence: rebuildable, or a single copy in the worst place
Here is the whole design rule, and everything else on this page is supporting detail.
If losing the local store loses user data, you have put the only copy of that data in the least durable storage available to you. Not the second-least. The disk in a browser profile is the one thing on the device that other software is actively designed to delete.
Three shapes, and only one of them is comfortable:
- A cache of server truth. Eviction costs a refetch and a spinner. This is the shape granthdb is built for, and it is fine.
- Local-first with a working sync engine. Also fine — the server copy exists, sync just has to actually run. Note that "we will add sync later" means you are in the third shape until later arrives. That distinction is the subject of offline-first versus local-first.
- User-authored data that never leaves the device. This is the dangerous one, and it is usually arrived at by accident — a draft, an unsent edit, a form the user filled in offline. If you are here deliberately, periodic
db.export()snapshots are the only backup the platform gives you.
Then exercise the rebuild path. Delete the database in a test and boot cold, on every release. A recovery path whose first real execution happens on the day a user is evicted is not a recovery path; it is untested code with a very bad audience.
Ship a reset control
Eviction is at least clean — you get an empty database. Corruption is worse: the file exists, the open fails or the reads come back wrong, and no amount of reloading fixes it. At 0.1–0.2% of users, a hundred thousand people means one to two hundred whose app is broken in a way that support cannot talk them out of.
So put deleteDatabase() behind a visible "reset local data" control. Not a debug flag, not a support macro someone pastes into the console — a control the user can find while the app is behaving badly.
The wording is doing real work, because you are asking someone to delete something. Say what goes and what comes back: this removes the copy stored on this device and downloads it again; anything not yet synced will be lost. If nothing can be lost, say that too — it turns a frightening button into an obvious one.
The short checklist
- Call
navigator.storage.persist()on first run. Log the result, do not branch on it. - Treat the local database as a cache. Every table needs an answer to "where does this come back from".
- Test the cold-start rebuild in CI by deleting the database first.
- Ship a reset control, worded honestly.
- If the data is genuinely user-authored, take
export()snapshots and store them somewhere that is not this disk.
None of that is specific to SQLite in the browser. It applies to localStorage, to IndexedDB, to Cache Storage and to OPFS equally — the eviction rules are per-origin, not per-API. What changes with a larger local store is only the size of the loss.
Where granthdb sits in this
granthdb makes the rebuild path something you can call rather than something you have to write: export() and import() for snapshots, delete() for the reset control, size() for what you actually occupy, and an OPFS → IndexedDB → memory fallback so a Safari private window gets a working database instead of an exception. It does not pretend to be durable, and the docs say so on the page where you would look for a durability guarantee.
npm install granthdb @sqlite.org/sqlite-wasm- Storage — OPFS, the fallback chain, quotas and eviction
- Security & performance — the explicit list of what browser-local storage does not give you
- Granth API —
export(),import(),delete(),size() - Use cases — including the ones where this is the wrong tool