Browser Storage Quota and Eviction Recovery: Data Tiering, Write Admission, and Rebuild Proofs
`navigator.storage.estimate` is only an approximation, and `persist` returning `true` does not mean user data will never disappear. This article establishes an origin-level budget, eviction detection, and a tiered recovery contract for Cache Storage, IndexedDB, and OPFS.
Key Points
- Topic Focus: TypeScript、DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
A single origin's Cache Storage, IndexedDB, OPFS, and so on typically share a browser-managed storage budget. What navigator.storage.estimate() returns is an estimate; the quota shifts with device space, browser policy, and privacy padding, and even an approved persist() cannot stop a user from clearing data. Treating "500 MB left" as a reserved promise, or randomly deleting the database after a QuotaExceededError, will lose the hardest-to-recover data first. IndexedDB Offline Draft Recovery manages migration of a single database; this article handles origin-level data categories, budgets, and full-loss recovery.
First declare which bytes can be sacrificed
Publish browserStoragePolicy.v1, pinned per account and storage namespace, fixing the data class, authoritative source, max/target bytes, minimum reserve, application eviction order, integrity rules, recovery reader, retention period, and user promise. At minimum distinguish: unsynced user facts, mirrors recoverable from the server, recomputable derived indexes, versioned application assets, and temporary telemetry. Except for unsynced facts, each class can only be reclaimed by the application when the contract's preconditions are met; the browser or user may still clear an entire best-effort bucket, and NEVER_AUTOMATIC only constrains the application's own shedding; it cannot masquerade as a guarantee that the UA will not evict.
schema: browserStoragePolicy.v1
revision: 9
namespace: app-origin@3
reserveBytes: 104857600
classes:
unsynced-user-data: {eviction: NEVER_AUTOMATIC, source: local-only}
telemetry: {appEvictionOrder: 10, source: disposable}
derived-index: {appEvictionOrder: 20, source: rebuild}
sw-assets: {appEvictionOrder: 30, condition: UNREFERENCED_RELEASE}
server-snapshot: {appEvictionOrder: 40, condition: SOURCE_GENERATION_VERIFIED}
estimate().usage only participates in conservative admission; it is not the bookkeeping truth. Before writing large objects, evaluate against the serialized upper bound, temporary copies, and the reserve; if the result is close to the threshold, reject cache assets or enter shedding. Every real write still catches QuotaExceededError; you cannot assume success just because you estimated moments ago. The state machine is PROBING -> READY -> PRESSURE -> SHEDDING -> DEGRADED -> RECOVERING -> READY; when a critical local fact is found missing, it enters LOCAL_LOSS and cannot be disguised as an empty account.
There is no free global transaction across backends
There is no unified commit across Cache Storage, IndexedDB, and OPFS that the application can rely on. When rebuilding a generation of the local mirror, first write immutable objects by content digest, verify them in each backend, then publish the storageGeneration, inventory root, and server watermark in the canonical meta store; readers only adopt a published generation. Objects left behind by a crash before the pointer commit are reclaimable orphans; they cannot be guessed by a half-finished reader.
Pressure cleanup follows the manifest order strictly: temporary telemetry, unreferenced derived indexes, old caches proven by Service Worker Release Activation to have no release references, and finally server snapshots that can still be refetched from a verified source generation. The cleaner only touches objects carrying this application's namespace and digest; it does not iterate and then delete unknown caches. Unsynced outboxes, drafts, and user imports are never automatically sacrificed by the application; if they fill the space, stop new writes, attempt to sync, and offer a controlled export, rather than claiming "optimization complete." Local facts in Local-First AI Apps must tell the user whether another copy exists.
When multiple tabs clean up or recover simultaneously, elect a local coordinator via Web Locks Leader Fencing, and have the canonical generation reject pointer updates from an old owner. When Web Lock is unavailable you may download immutable objects repeatedly, but only one generation can commit; do not let two pages delete each other's just-written assets.
Treat eviction as a cold start, not an exceptional empty array
At startup, verify the account marker, policy revision, active generation, inventory root, and key object digests. When the entire origin is evicted or cleared by the user, these markers disappear together; the application fetches a recovery manifest and watermark from the authenticated server, first restores the minimum viable facts, and then rebuilds derived data in the background. If the only locally present unsynced content disappears, the result must be UNRECOVERABLE_LOCAL_LOSS, showing the existing backup/import paths; you cannot generate an empty store and then overwrite the server or tell the user "already synced."
navigator.storage.persisted(), estimate, and usage details are recorded as diagnostic signals and do not enter into a "no data loss" promise. Recovery requests use a stable snapshot/generation, and network retries only refetch the same digest; server data changes produce a new recovery generation and do not overwrite an old root that is half verified. Quarantined objects in the Secure File Upload Pipeline also cannot be counted as authoritative local copies without confirmation.
The fault suite injects quota errors, page termination, concurrent cleanup, a sudden drop in estimate, persist rejection, a late old owner, origin disconnect, corrupted object digest, account switching, a full origin reset, and the user deleting only one class of application data, both before and after each object write and pointer CAS. Assert that automatic deletion of unsynced facts is 0, that half-generation visibility is 0, that cross-account recovery is 0, and that repeated recovery yields the same inventory root.
Release gate requires: tiered coverage of local bytes at least 99%, 100% explicit labeling of entries whose critical data has no source, 0 new cache acceptances once the reserve drops below the hard line under pressure, the critical root unchanged after shedding on the golden 5 GB fixture, 100% success rate for full-reset recovery, and 0 false "recovered" reports for local-only loss. First only inventory and estimate, then enable cleanup of derived classes, and finally enable server recovery. On an exception, turn off automatic shedding/rebuild but keep write admission and read-only export; old readers can only read generations they declare compatible, and you must never use deleteDatabase() as a rollback.
Further reading
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.