Detecting When Safari Evicts Your 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.

Eviction has no error and no event. It happens while your app is closed, it removes the entire origin’s storage rather than trimming it, and the next launch finds an empty database that looks exactly like a fresh install. An app that cannot tell those apart silently re-initialises and never mentions that the user’s queued shares are gone.

A sentinel record fixes this. It costs a few bytes and turns invisible data loss into something the app can name.

Feature Detection Gate

Two independent stores are needed, so both are checked.

// sentinel-support.js
export function sentinelStores() {
  const hasIdb = typeof indexedDB !== 'undefined';
  let hasLocal = false;
  try {
    localStorage.setItem('__probe', '1');
    localStorage.removeItem('__probe');
    hasLocal = true;
  } catch {
    hasLocal = false;               // private mode or storage disabled
  }
  return { hasIdb, hasLocal, canDetect: hasIdb && hasLocal };
}

Where only one store is available the detector degrades to “unknown” rather than guessing — reporting a phantom eviction to a user who never had one is its own kind of bug.

Reading the Two Markers A truth table over two markers. Both present means a normal launch. Both absent means a genuine first run or a full clear by the user. The mirror present with the database gone indicates eviction of IndexedDB. The database present with the mirror gone indicates local storage was cleared separately. IndexedDB sentinel localStorage mirror Conclusion present present normal launch — compare counts for a partial loss absent absent first run, or the user cleared all site data absent present eviction — the queue was wiped, report it present absent localStorage cleared separately — rewrite the mirror

Step 1 — Write the Sentinel and Keep It Current

The sentinel carries two things: proof the app has run before, and a snapshot of what the queue held.

// sentinel.js
const SENTINEL_KEY = 'queue-sentinel';
const MIRROR_KEY = 'share-queue-sentinel';

export async function writeSentinel(db, { pendingCount }) {
  const record = {
    key: SENTINEL_KEY,
    installedAt: (await readSentinel(db))?.installedAt ?? Date.now(),
    lastSeenAt: Date.now(),
    pendingCount
  };

  const tx = db.transaction('meta', 'readwrite');
  tx.objectStore('meta').put(record);
  await tx.done;

  try {
    localStorage.setItem(MIRROR_KEY, JSON.stringify(record));   // independent copy
  } catch { /* storage blocked — detection degrades, app continues */ }

  return record;
}

export async function readSentinel(db) {
  const tx = db.transaction('meta', 'readonly');
  const record = await tx.objectStore('meta').get(SENTINEL_KEY);
  await tx.done;
  return record ?? null;
}

export function readMirror() {
  try {
    return JSON.parse(localStorage.getItem(MIRROR_KEY) ?? 'null');
  } catch {
    return null;
  }
}

Update it after every queue mutation — enqueue, flush, prune. The pendingCount is what makes a partial loss visible: if the mirror says five pending and the database now holds one, four shares are gone even though the sentinel itself survived.

Step 2 — Classify the Startup State

// detect-eviction.js
import { readSentinel, readMirror, writeSentinel } from './sentinel.js';

export async function classifyStartup(db) {
  const sentinel = await readSentinel(db);
  const mirror = readMirror();
  const pendingNow = await countPending(db);

  if (!sentinel && !mirror) {
    await writeSentinel(db, { pendingCount: pendingNow });
    return { state: 'first-run' };
  }

  if (!sentinel && mirror) {
    // IndexedDB was cleared while localStorage survived: the signature of eviction.
    await writeSentinel(db, { pendingCount: pendingNow });
    return { state: 'evicted', lostPending: mirror.pendingCount ?? 0, lastSeenAt: mirror.lastSeenAt };
  }

  if (sentinel && !mirror) {
    await writeSentinel(db, { pendingCount: pendingNow });
    return { state: 'mirror-cleared' };
  }

  const expected = mirror?.pendingCount ?? sentinel.pendingCount ?? 0;
  if (expected > 0 && pendingNow === 0) {
    return { state: 'partial-loss', lostPending: expected };
  }

  return { state: 'normal', pendingCount: pendingNow };
}

partial-loss is worth keeping distinct from evicted. The first usually means something in your own code deleted more than it should — an over-eager pruner, a failed migration — and diagnosing it as eviction would send you looking at the browser instead of at the bug.

Step 3 — Report It, Then Carry On

The message should be short, honest, and actionable exactly once.

// eviction-notice.js
export function evictionNotice(result) {
  if (result.state === 'evicted' && result.lostPending > 0) {
    const n = result.lostPending;
    return {
      tone: 'warning',
      text: `${n === 1 ? 'A share that was' : `${n} shares that were`} waiting to send could not be kept — your device was low on space.`,
      action: { label: 'Install the app to keep shares safely', id: 'install' }
    };
  }
  if (result.state === 'evicted') {
    return null;                 // nothing was pending; the loss is invisible to the user
  }
  if (result.state === 'partial-loss') {
    return { tone: 'warning', text: 'Some queued shares are missing.', action: null };
  }
  return null;
}

Suppressing the notice when nothing was pending is the detail that keeps this from becoming noise. Eviction of an empty queue costs the user nothing, and a warning about it would be alarming for no reason. And offering installation as the action is genuinely useful on iOS, where an installed PWA is treated far more favourably than a tab — the same argument made in requesting persistent storage for a share queue.

Step 4 — Reduce the Odds in the First Place

Detection is a safety net, not a fix. Three habits meaningfully lower the eviction rate.

Keep usage low: an origin holding 200 MB of cached images is a far more attractive eviction candidate than one holding 6 MB, so aggressive precaching directly trades against queue durability. Flush the queue early: a share delivered within minutes cannot be evicted at all, which makes prompt background sync the strongest mitigation there is. And encourage installation where the app is used regularly, since installed apps are both more likely to be granted persistence and less likely to be swept by time-based policies.

Relative Exposure to Eviction Four configurations ordered by risk. A Safari tab with a large cache is most exposed. A Safari tab with a small cache is less so. An installed PWA on iOS is lower again. An installed app on Chromium with persistence granted is the least exposed. Safari tab · 200 MB cached · rarely revisited highest exposure — both time-based and pressure-based eviction apply Safari tab · 6 MB stored · visited weekly a small origin is a poor eviction candidate, but the time policy still applies installed PWA on iOS treated more favourably than a tab — the best available mitigation there installed on Chromium · persistence granted exempt from routine eviction — still lost if the user clears site data What the Sentinel Records The sentinel carries an install timestamp proving the app has run before, a last-seen timestamp, and the pending count at that moment. Without the count a full wipe is detectable but a partial loss is not. installedAt proves this is not a first run — the difference between loss and a fresh install lastSeenAt how long ago the queue was known good — useful in the message to the user pendingCount without it, a partial loss is invisible — the sentinel survives while shares quietly do not

Failure Modes and Recovery

Symptom Cause Minimal fix
Loss looks like a first run No sentinel written Write one on first launch and keep it fresh
Eviction never detected Sentinel stored only in IndexedDB Mirror it in localStorage
Warning shown on a genuine first run Both markers absent, treated as loss Require the mirror to survive before reporting
Warning shown every launch Sentinel never rewritten after detection Rewrite it as part of classification
Count mismatch misreported pendingCount updated only on enqueue Update after every mutation
Noisy warning on an empty queue Reported regardless of pending count Suppress when nothing was pending

Browser and Platform Caveat

No browser fires an event on eviction, so this comparison is the only detector available anywhere. Safari is the aggressive case: alongside pressure-based eviction it applies a time-based policy to origins the user has not visited for an extended period, and a browser tab is more exposed than an installed app. Chromium and Firefox evict mainly under storage pressure and skip origins holding persistent storage, which makes the sentinel a rarely-triggered safety net there rather than a routine occurrence. Two implementation notes: localStorage throws in some private-browsing configurations, so every access needs a try-catch and the detector must degrade to “unknown” rather than assume; and clearing site data removes both markers at once, which correctly reads as a first run — the user did that deliberately and does not need to be told about it.

FAQ

Does the browser fire an event when it evicts storage?

No. Eviction happens while your page is not running and there is no notification, no event and no error. The only way to know is to compare what you find on startup against a marker you wrote earlier.

Why keep a mirror in localStorage as well?

Eviction usually clears the whole origin, but not always at the same moment for every store, and some cleanups target one API. Two independent markers let you distinguish a full wipe from a partial one and from a genuine first run.

How aggressive is Safari’s eviction?

Safari applies a time-based policy to storage from origins the user has not visited recently, on top of pressure-based eviction. An installed PWA is treated more favourably than a tab, which makes installation the most effective mitigation available on iOS.