Privacy Takes Work: Persistent Storage Is Not a Backup
FateLoom Engineering Log — August 2, 2026
Privacy takes effort from everyone who values it—and every practical step is worth taking.
That sentence sounds philosophical, but in a local-first application it becomes an engineering constraint. FateLoom keeps calendars in the browser: there is no account, cloud sync, or server-side recovery copy. This preserves autonomy and avoids transferring private planning data to us. It also means we cannot quietly recover a calendar after the browser loses it.
The feature described here connects that abstract responsibility to a deliberately limited practical solution. Persistent browser storage reduces one class of data loss. It does not turn a browser into a backup system.
The concrete risk
FateLoom currently stores its calendar library in localStorage. Reloading or restarting the browser does not normally remove it, but normal browser storage belongs to a best-effort origin bucket. Under storage pressure, a browser may evict a non-persistent origin. Eviction removes the origin's storage as a unit rather than selecting the least important calendar. The Storage Standard defines the bucket model; MDN's eviction guide explains the operational consequences.
navigator.storage.persist() asks the browser to move that origin to persistent mode. Its promise resolves to a boolean, but browsers decide differently: Firefox can present a permission prompt, while Chromium-based browsers commonly grant or refuse through heuristics. The request therefore cannot be treated as a conventional, portable consent dialog. It must also come from a secure context and may simply be unavailable. MDN documents the API contract; web.dev describes the browser heuristics and recommends requesting from a meaningful user gesture.
Even a successful request only protects against automatic eviction. Private browsing sharpens the risk because privacy-conscious users may make it their default. Chrome removes site data when all Incognito windows close; Firefox deletes site data when a private window closes. Persistent storage cannot turn that deliberately temporary session into durable storage. Users must export before leaving. (Chrome, Firefox)
Investigation during development
Our first model was attractive because it was simple: call persisted() at startup and show green when it returned true. We then exercised the lifecycle rather than only the happy path: grant protection, remove or reset it from browser controls, reload FateLoom, and inspect the resulting state.
That changed the design. The initial normalization inferred permission: "granted" whenever persisted() was true, so it could discard a simultaneously observed prompt or denied. We did not record both raw API values during the first manual observation, so we cannot honestly claim that a browser violated the standard. What we can show is that our model destroyed evidence that mattered to the UI. We made the adapter defensive against contradictory signals and added those contradictions to the test suite before considering the feature complete.
Options considered
We could have stopped at “export regularly.” That is honest, but it ignores a browser protection designed for locally created, important data.
We could have requested persistence or opened an explanation automatically at startup. That might increase acceptance, but it would interrupt every new user before they had context, and it would turn a privacy feature into pressure. It also works against the guidance to make the request from a meaningful gesture.
We could have trusted only persisted(). That minimizes code and states, but a green indicator is a strong claim. When two available signals conflict, optimism is the wrong failure mode.
We also considered detecting private mode and warning only there. There is no standard signal; detection techniques infer it from browser side effects that vendors deliberately remove. Google closed a FileSystem API loophole because detection undermined Incognito, and the W3C advises that web features should not expose private mode. Chasing those tricks would be fragile and contrary to FateLoom's purpose. We chose a permanent warning for everyone. (Google, W3C)
We chose to inspect without requesting, expose a small status control beside “Saved locally,” and call persist() only when the user selects Request protection. The cost is more state and browser-specific guidance. The gain is that each state has an honest next action.
The practical solution
The adapter combines bucket state with the queryable persistent-storage permission. A known non-granted permission invalidates a contradictory persistent result; an unavailable permission query remains non-fatal.
const [persisted, permission] = await Promise.all([
storage.persisted(),
queryPersistencePermission(navigatorObject),
]);
function effectivePersistenceState(persisted, permission) {
if (permission === "denied" || permission === "prompt") {
return { persisted: false, permission };
}
return {
persisted: Boolean(persisted),
permission: persisted ? "granted" : permission,
};
}
Browser state stays separate from FateLoom's pending | rejected | requested decision. prompt remains retryable; denied becomes orange, records rejected, removes the ineffective request button, and explains how to change the site permission. Missing APIs and exceptions also become orange without blocking calendar editing, import, or export. Startup only inspects:
function storageProtectionVisualState() {
if (app.storagePersistence.support === "checking") {
return "checking";
}
if (app.storagePersistence.permission === "denied") {
return "attention";
}
if (app.storagePersistence.persisted) {
return "protected";
}
if (app.storagePersistence.support !== "available") {
return "attention";
}
return storagePersistenceDecision() === "pending" ? "pending" : "attention";
}
async function refreshBrowserStoragePersistence() {
app.storagePersistence = await inspectBrowserStoragePersistence();
if (
app.storagePersistence.permission === "denied" &&
storagePersistenceDecision() !== "rejected"
) {
setStoragePersistenceDecision("rejected");
}
renderStatus();
if (els.storageProtectionDialog.open) {
renderStorageProtectionDialog();
}
}
The internal result is intentionally small:
support: checking | available | unsupported | error
persisted: boolean
permission: granted | prompt | denied | unavailable
requestAvailable: boolean
No calendar document, schema version, export format, public API, or Service Worker flow changed.
Verification
Unit tests cover persistent and best-effort buckets, prompt, denied, unavailable permissions, exceptions, and contradictory signals. The essential regression is explicit:
const result = await inspectBrowserStoragePersistence({
storage: { persisted: async () => true, persist: async () => true },
permissions: { query: async () => ({ state: "denied" }) },
});
assert.equal(result.persisted, false);
assert.equal(result.permission, "denied");
Browser tests cover startup without a request, cancellation, local rejection, a successful grant, external blocking, reset to prompt, manual instructions, retry, and the permanent private-session warning. A contract test also rejects known detection-hook names. On August 2, 2026, npm run verify passed all 41 Node test files plus the security, lint, invariant, and landing-media checks; the complete Playwright run passed all 47 browser scenarios, including the six storage-specific cases.
What remains uncertain
Browser behaviour and permission surfaces will evolve. The Permissions API exposes granted, prompt, and denied, but support remains feature-dependent. Firefox documents both clearing a site's permission back to its default and changing persistent-storage permission through Page Info. Chrome documents its broader site-permission controls and reset flow, but does not promise an equivalent persistent-storage switch everywhere.
We deliberately have no production telemetry to settle these differences by observing users. That is consistent with the privacy premise, but it limits what we can claim. Export reminders also remain advice rather than automation.
So the green message is intentionally narrow: “This browser confirms that FateLoom storage is protected from automatic cleanup.” Then it adds the part no API can guarantee: “The world is uncertain, so export your calendars regularly.”
References used during implementation
- WHATWG Storage Standard
- MDN:
StorageManager.persist() - MDN: Storage quotas and eviction criteria
- MDN: Permissions API
- web.dev: Persistent storage
- Firefox: Site Permissions panel
- Firefox: Page Info permissions
- Chrome: Change site settings permissions
- Chrome: Browse in Incognito mode
- Firefox: Common myths about Private Browsing
- Google: Protecting private browsing in Chrome
- W3C: Web Platform Design Principles — Private browsing