The bug report is always the same sentence: "I lost my progress." Nothing crashed, no error was logged, the IndexedDB code is fine — and the data is gone. This is not a bug in your app. The default contract for browser storage is best-effort, and best-effort is the spec's polite word for deletable. This post is the part of the storage docs that matters in production: what actually triggers deletion in each engine, how much space you really get, and the exact code that turns a save file from disposable into protected.
Two modes: best-effort and persistent
Every origin's storage sits in one of two modes. Best-effort is the default: the data survives as long as the origin is under quota, the device has room, and nothing decides to clean up. No permission is asked when you write it, and no warning is given when it goes. Persistent is opt-in, and data in it is only removed when the user removes it through browser settings.
Which APIs are covered? The quota-managed group is IndexedDB, the Cache API, the Origin Private
File System, localStorage / sessionStorage and service worker
registrations. Cookies and the HTTP cache are managed separately and are not bounded by this quota.
Note that the mode is a property of the origin, not of a database — you cannot make one
object store durable and leave another disposable. (Unless you use buckets; more on that below.)
What actually deletes your data
- Storage pressure. When the device is running out of disk, the browser evicts origins on a least-recently-used basis. In WebKit the whole origin's data goes as a unit, and "last used" means the last user interaction or the last storage operation. An origin with an active page, or in persistent mode, is skipped.
- Exceeding the quota. The write that needs the space fails with a
QuotaExceededError. Over the overall (all-origins) limit, eviction starts. - Safari's 7-day cap. This is the one that surprises people. WebKit's tracking prevention deletes all script-writable storage — IndexedDB, localStorage, sessionStorage, media keys, service worker registrations and caches — after seven days of no user interaction with the site. Classified domains lose all website data after 30 days of browser use without interaction. The important exemption: a site added to the Home Screen as a web app is skipped by that removal algorithm entirely, and its data is isolated from Safari's.
- The user. Clearing site data, or private browsing ending. Nothing protects against this, and nothing should.
So "my data vanished after a week on iPhone" and "my data vanished when the phone filled up" are two different mechanisms with two different fixes: the first is answered by persistence or an install prompt, the second by persistence or spending less space.
How much space do you actually get?
Do not hardcode a number; the answer is a fraction of the device. In Chromium, an origin granted
persistent storage may use up to 50% of total disk size, capped at 8 TiB, and is exempt from the
shared group limit. In WebKit, from macOS 14 and iOS 17, a browser app gives each origin up to
roughly 60% of total disk with an overall ceiling of about 80% across all origins; other apps
embedding web content (a WKWebView inside someone else's app) get about 15% per
origin and 20% overall. A site saved to the Home Screen or Dock uses the browser-app quota.
At runtime, ask:
const { usage, quota } = await navigator.storage.estimate();
const pct = (usage / quota * 100).toFixed(1);
Both numbers are deliberately fuzzy (padded to avoid fingerprinting), and quota is
what is available now, not a reservation. Treat it as a gauge, not a contract, and log
the ratio in your telemetry — a support ticket that arrives with "usage was 94% of quota" answers
itself.
Requesting persistence properly
The whole API is three calls, but the sequence matters:
async function protectSaves() {
if (!navigator.storage?.persist) return 'unsupported';
if (await navigator.storage.persisted()) return 'already';
const granted = await navigator.storage.persist();
return granted ? 'granted' : 'denied';
}
Check persisted() first so you never re-ask. Then note who is on the other side of
persist(): Firefox shows the user a permission popup.
Chromium and Safari decide silently from engagement signals — installed as an
app, bookmarked, high site engagement, notification permission — and just resolve
false if they are unconvinced. That has two consequences. First, always branch on
the result; a denial is normal, not exceptional. Second, timing is strategy: call it after the
user has done something that shows commitment (finished a level, created a document), not in
your bootstrap, because a cold first-load request is the one most likely to be refused.
And when it is denied, you need a real plan, not a shrug. The honest options: sync to a server when one exists, offer an explicit export (a JSON download of the save is ten lines and users understand files), and prompt installation on iOS since a Home Screen web app escapes the 7-day cap. That prompt is not a growth tactic there — it is the durability fix.
Storage Buckets: saying which data matters most
persist() is all-or-nothing for the origin, which is a blunt instrument when your
80 MB of cached textures and your 40 KB of save data live side by side. The Storage
Buckets API, shipped in Chromium 122, splits an origin into separately evictable buckets:
const saves = await navigator.storageBuckets.open('saves', {
persisted: true,
durability: 'strict'
});
const db = await new Promise(res => {
const r = saves.indexedDB.open('game', 1);
r.onsuccess = () => res(r.result);
});
Each bucket exposes its own indexedDB, caches and
getDirectory(), so the storage layer above it is unchanged. Two options are worth
understanding. durability: 'strict' asks the browser to minimise data loss on power
failure, at the cost of slower writes, more battery and more flash wear; 'relaxed'
(the default) may forget the last few seconds of writes but is faster. And expires
sets a wall-clock time after which the bucket may be dropped — genuinely useful for analytics
queues and downloaded content licences. Buckets are Chromium-only today, so build the
single-bucket path first and layer buckets on where they exist.
Failing well: QuotaExceededError
A full disk should degrade, not destroy. The pattern that has served me well is a save function
that treats quota as a recoverable condition: catch the error on the write, call
estimate(), delete regenerable data first (cached assets, thumbnails, old
replay logs), retry once, and only then surface a
user-visible message offering an export. Two habits make that possible: write large payloads
incrementally rather than in one giant transaction, and keep a strict split between data you can
rebuild and data you cannot. If you know which is which, eviction becomes an inconvenience
instead of an incident.
A checklist before you ship
- Classify every stored byte as regenerable or irreplaceable. Only the second kind needs durability, and it should be small.
- Call
persisted()thenpersist()at an engagement moment, and record the outcome in telemetry. - Handle the denial: server sync, export to file, and an install prompt on iOS.
- Catch
QuotaExceededErroron every write path and free regenerable data first. - Test by filling the disk, by clearing site data mid-session, and — for WebKit — by leaving the site untouched for over a week.
- Never store money, entitlements or scores only in the browser. Local state is a cache of the server's truth; if it disappears, the account should not.
That last rule is the one worth internalising. Everything else here is mitigation. The browser has told us plainly what it promises, and the default promise is "best effort" — so any design that treats client storage as the sole record of something valuable is not fighting a bug, it is disagreeing with the platform.