Skip to content

desktop: localStorage has no time-based cleanup; 9 unbounded call sites bypass quota facade #5418

Description

@wesbillman

Problem

Wes raised: localStorage cleanup appears restart-driven; a desktop client left open for days may grow storage until writes fail. Audit of all 58 raw localStorage.setItem call sites in desktop/src at 119a84897 confirms the concrete gaps.

Current protections (already good):

Gaps:

  1. All 58 feature call sites write raw — none route through setLocalStorageItemWithRecovery; bounds are per-file convention, not enforced.
  2. Nine call sites are UNBOUNDED (no size/count/age bound):
    • features/communities/communityIconCache.ts:37 — per-relay icon data URLs, no cap/TTL, failures swallowed
    • features/channels/forcedUnreadStore.ts:123 — per-channel forced-unread entries; per-entry clear only
    • features/messages/lib/persistentAgentAudience.ts:76 — per-scope (channel/thread) agent audiences; reset only on feature disable
    • features/profile/lib/selfProfileStorage.ts:148 — per relay×pubkey profile caches (avatar data URLs ≤256 KB each); GC only on community removal
    • features/sidebar/lib/channelMutesStorage.ts:72muted:false tombstones never deleted
    • features/sidebar/lib/channelStarsStorage.ts:72starred:false tombstones never deleted
    • features/sidebar/lib/channelSectionsStorage.ts:162 — per user×relay section assignments, no cap
    • features/sidebar/lib/channelSortPreference.ts:108 — per-section sort prefs; orphans stripped but no cap
    • shared/features/store.ts:30,37 — feature overrides per feature ID
  3. Zero time-based cleanup anywhere: eviction is size/count-pressure only. Stale per-pubkey/per-relay/per-channel keys (old identities, removed channels) persist forever, and a session open for weeks only trims the pure-cache prefixes.

Failure mode today: not a crash — silent persistence loss (read positions/prefs stop saving) once the ~5 MB WKWebView origin quota fills, plus one toast.

Industry standard (Slack/Discord shape)

  • localStorage for small prefs/flags only (KBs); message/profile/media caches in IndexedDB (async, ~100s of MB, browser-evictable under storage pressure).
  • TTL + LRU with a periodic sweep (startup + interval/visibilitychange) so a long-running client converges to the same state as one restarted nightly.
  • A single storage facade owning budgets, TTLs, and versioned keys for every write.

Plan

Phase 1 — contain (localStorage, small diffs):

  1. Cap communityIconCache (count cap + drop oversized data URLs) and bound the other 8 unbounded sites (tombstone deletion for mutes/stars, count caps or LRU where entries carry updatedAt).
  2. Add a periodic stale-entry sweep (startup + visibilitychange/interval) for TTL-able namespaces: self-profile caches, forced-unread, agent audiences, per-identity keys not touched in N days.
  3. Route feature writes through setLocalStorageItemWithRecovery/safeStorage so quota failure handling is uniform.

Phase 2 — re-tier (larger):
4. Move pure snapshot caches (channel messages, sidebar, skeletons, profile caches) to IndexedDB; leave localStorage as prefs-only. Removes the 2 MiB-vs-5 MB pressure entirely.

Evidence

  • Audit thread: channel time-based-localstorage-eviction, root 0d85a73ca43e54748128f89c3512a4726131bf5473253395d46bf8f3a7b58bd4 (Morty's full 58-site table, spot-checked).
  • Nest notes: RESEARCH/LOCALSTORAGE_EVICTION_CURRENT_STATE.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions