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:
- All 58 feature call sites write raw — none route through
setLocalStorageItemWithRecovery; bounds are per-file convention, not enforced.
- 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:72 — muted:false tombstones never deleted
features/sidebar/lib/channelStarsStorage.ts:72 — starred: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
- 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):
- 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).
- 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.
- 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.
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.setItemcall sites indesktop/srcat119a84897confirms the concrete gaps.Current protections (already good):
shared/lib/localStorageQuota.ts— pure snapshot caches (channel messages, sidebar, skeletons, user labels) share a 2 MiB budget with write-time LRU eviction (runs on every write, not just restart), plus quota-exceeded recovery (evict disposables, retry once) and a startup probe-repair pass (main.tsx:124).shared/lib/safeStorage.ts(A throwing localStorage.getItem kills the whole desktop tree (no ErrorBoundary anywhere in desktop/src) #5078) — throw-safe reads/writes, so a full store degrades to in-memory + toast rather than crashing.Gaps:
setLocalStorageItemWithRecovery; bounds are per-file convention, not enforced.features/communities/communityIconCache.ts:37— per-relay icon data URLs, no cap/TTL, failures swallowedfeatures/channels/forcedUnreadStore.ts:123— per-channel forced-unread entries; per-entry clear onlyfeatures/messages/lib/persistentAgentAudience.ts:76— per-scope (channel/thread) agent audiences; reset only on feature disablefeatures/profile/lib/selfProfileStorage.ts:148— per relay×pubkey profile caches (avatar data URLs ≤256 KB each); GC only on community removalfeatures/sidebar/lib/channelMutesStorage.ts:72—muted:falsetombstones never deletedfeatures/sidebar/lib/channelStarsStorage.ts:72—starred:falsetombstones never deletedfeatures/sidebar/lib/channelSectionsStorage.ts:162— per user×relay section assignments, no capfeatures/sidebar/lib/channelSortPreference.ts:108— per-section sort prefs; orphans stripped but no capshared/features/store.ts:30,37— feature overrides per feature IDFailure 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)
Plan
Phase 1 — contain (localStorage, small diffs):
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 carryupdatedAt).visibilitychange/interval) for TTL-able namespaces: self-profile caches, forced-unread, agent audiences, per-identity keys not touched in N days.setLocalStorageItemWithRecovery/safeStorageso 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
time-based-localstorage-eviction, root0d85a73ca43e54748128f89c3512a4726131bf5473253395d46bf8f3a7b58bd4(Morty's full 58-site table, spot-checked).RESEARCH/LOCALSTORAGE_EVICTION_CURRENT_STATE.md.