Requesting Persistent Storage for a Share Queue
This guide is part of Storage Quota and Eviction for Share Queues, which itself is part of Offline-First PWA Patterns for Web Share API.
By default, browser storage is best-effort: when the device runs low on space, the browser clears whole origins it considers expendable, and a queue of shares that were never delivered goes with it. navigator.storage.persist() asks to be excluded from that sweep. It is one call, it costs nothing, and it is the difference between a queue that survives a low-storage night and one that quietly empties.
Feature Detection Gate
Two related checks: whether the API exists, and whether the grant has already been made.
// persistence-state.js
export async function persistenceState() {
if (window.isSecureContext !== true) {
return { supported: false, persisted: false, reason: 'insecure-context' };
}
if (!navigator.storage?.persist || !navigator.storage?.persisted) {
return { supported: false, persisted: false, reason: 'unsupported' };
}
return { supported: true, persisted: await navigator.storage.persisted() };
}
Calling persisted() before persist() is not merely tidy. On an origin that already has the grant, persist() resolves true immediately, but the query makes the intent explicit and keeps the request path reserved for origins that genuinely need it.
Step 1 — Ask at the Moment of Strongest Engagement
The browser decides from signals: is the app installed, is it bookmarked, does the user return, has notification permission been granted. Asking immediately on first page load is the weakest possible moment.
// request-persistence.js
import { persistenceState } from './persistence-state.js';
const ASKED_KEY = 'persistence-requested-at';
export async function requestPersistenceAfterAction() {
const state = await persistenceState();
if (!state.supported || state.persisted) return state;
// Only ask once per session; the heuristics do not change within one.
if (sessionStorage.getItem(ASKED_KEY)) return { ...state, skipped: 'already-asked' };
sessionStorage.setItem(ASKED_KEY, String(Date.now()));
const granted = await navigator.storage.persist();
return { supported: true, persisted: granted };
}
Call it right after the user does something that shows commitment — queues their first share, installs the app, enables notifications. That is when the engagement signals the browser reads are at their strongest, and it costs the user nothing because there is no dialog.
// wire-persistence.js
import { requestPersistenceAfterAction } from './request-persistence.js';
import { enqueueShare } from './share-queue.js';
export async function queueShareAndSecureStorage(payload) {
const entry = await enqueueShare(payload); // capture first — this must never be delayed
requestPersistenceAfterAction(); // fire and forget; not awaited
return entry;
}
The persistence request is deliberately not awaited. Capturing the share is the user-visible promise, and it must not wait on a storage negotiation that may take a moment.
Step 2 — Treat a Denial as an Ordinary Outcome
false is not an error and not a refusal to ask again later. It means the heuristics were not satisfied yet.
// persistence-policy.js
import { persistenceState } from './persistence-state.js';
export async function queueDurability() {
const state = await persistenceState();
if (!state.supported) {
// Safari: no persist() at all. The queue must simply expect to be evicted.
return { level: 'best-effort', advice: 'keep-queue-small', canRequest: false };
}
if (state.persisted) {
return { level: 'persisted', advice: 'normal', canRequest: false };
}
return { level: 'best-effort', advice: 'prune-aggressively', canRequest: true };
}
Turning the answer into an advice value lets the rest of the app respond meaningfully. On a best-effort origin the pruner should run more often and the queue cap should be lower, because every megabyte held is a megabyte at risk. On a persisted origin the queue can hold more for longer.
Step 3 — Surface Durability Honestly, Without Alarming Anyone
Users do not need to know about storage buckets. They do need to know if something may not survive.
// durability-note.js
import { queueDurability } from './persistence-policy.js';
export async function durabilityNote(pendingCount) {
if (pendingCount === 0) return null;
const { level } = await queueDurability();
if (level === 'persisted') return null; // nothing worth saying
return pendingCount === 1
? 'This share will be sent when you are back online. Keep the app installed so it is not cleared.'
: `${pendingCount} shares will be sent when you are back online. Keep the app installed so they are not cleared.`;
}
The message only appears when there is something pending and the storage is best-effort — the one case where the advice is actionable. Note that it doubles as an install prompt with a real reason attached, which is considerably more persuasive than a generic banner and fits the guidance in designing contextual permission prompts.
Failure Modes and Recovery
| Symptom | Cause | Minimal fix |
|---|---|---|
persist is not a function |
Insecure context, or Safari | Feature-detect; fall back to aggressive pruning |
| Always denied | Asked on first load with no engagement | Ask after a queued share or an install |
| Repeated requests every render | No once-per-session guard | Record the attempt in sessionStorage |
| Share capture feels slow | persist() awaited on the capture path |
Fire and forget after the write |
| Data lost despite the grant | User cleared site data | Expected — keep eviction detection in place |
| Queue grows unbounded on a granted origin | Persistence treated as unlimited space | Quota still applies; keep pruning |
Browser and Platform Caveat
navigator.storage.persist() is available in Chromium and Firefox and requires a secure context. Safari does not expose it at all and applies its own time-based policy instead, clearing storage for origins the user has not visited for an extended period — an installed PWA is treated more favourably than a browser tab, which makes installation the practical equivalent of a persistence request on iOS. Nothing about the grant increases your quota; it only changes eviction eligibility, so a persisted origin can still hit QuotaExceededError and still needs the recovery path in recovering from QuotaExceededError when queueing files. In a private window the request is typically denied and the whole budget is cleared at session end, so treat any queue there as strictly temporary.
Ask, Then Forget About It
Persistence is worth requesting and not worth building around. The request is one call with no prompt and no cost, so there is no reason to skip it — and the grant changes only eviction eligibility, not quota, so nothing downstream should behave differently because it succeeded.
Design the queue to survive being erased, request persistence at the moment engagement is highest, and treat a grant as a reduction in how often that survival logic will be needed rather than a reason to remove it.
FAQ
Does persist() show the user a prompt?
Not in current browsers. The decision is made from engagement heuristics — installation, bookmarking, notification permission, repeat visits — so the promise resolves without any user-visible dialog. Do not build UI that implies a choice is being offered.
Should I retry persist() if it returns false?
Not in a loop. The heuristics change slowly, so retrying immediately achieves nothing. Ask again in a later session after the user has engaged further — installing the app is the single strongest signal.
Is persisted data really permanent?
No. Persistence exempts the origin from routine eviction under storage pressure, but the user can still clear site data, uninstall the app, or use a private window. The queue must always tolerate its own disappearance.