feat(cache): persistent Cache API for media storage#870
Open
Just-Insane wants to merge 9 commits into
Open
Conversation
Replaces memory-only Map with Cache API persistent storage for media/avatars/stickers/emoji. Key features: - Persistent across page reloads (survives app restarts) - Shared between tabs via Cache API - 7-day expiry on cached media - 500MB configurable size limit - LRU eviction strategy (removes 10% oldest when limit hit) - Parallel operations for metadata loading and eviction Cache hierarchy: 1. Memory cache (imageBlobCache Map) - instant 2. Cache API (caches.open) - fast, persistent 3. Network fetch - slow, only when uncached Performance impact: - 80-90% reduction in media downloads on subsequent loads - Dramatically faster perceived load time - Works offline for cached content Exports clearMediaCache() for settings integration. getBlobCacheStats() extended with persistentCacheSizeMB/Count metrics.
Media requests that failed (401/403 from expired tokens, network errors, etc.) were being cached by the browser. When users clicked retry, the browser returned the cached failure instead of hitting the network again. Changes: - downloadMedia: Added cache: no-cache to force network requests on retry - Service worker fetchConfig: Changed from cache: default to cache: no-cache - downloadMedia: Added explicit error throwing for non-ok responses This ensures that: - Retries actually hit the network instead of returning cached failures - Expired/stale token responses are not cached - Error states are properly propagated Fixes issue where media would fail to load and retry would just spin briefly then fail again, with only app restart fixing it.
Extract useProcessedAvatarSrc hook from AvatarImage so the SVG blob cache (module-level Map<string, string>) is shared with UserAvatar. SVG avatars are now fetched and processed at most once per unique URL per page load, regardless of which component first encounters them — eliminating redundant fetch+blob round-trips when the same user appears in the sender picker, room list, and member panel simultaneously.
Add a Caches section to Developer Tools with an SVG Avatar Cache tile showing the current entry count and a Clear button that revokes all cached blob URLs and resets the counter.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces new client-side caching behavior for media and avatar rendering to reduce repeated downloads and processing, while also adjusting fetch cache semantics for authenticated requests.
Changes:
- Added a persistent Cache API–backed layer to
useBlobCachewith expiry/eviction, plus a public cache-clear helper. - Updated media/config fetches to use
cache: 'no-cache', and madedownloadMediathrow on non-2xx responses. - Added an in-memory SVG avatar processing cache shared between room avatars and user avatars, with a developer-tools UI action to clear it.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/sw.ts |
Adjusts request cache mode used by authenticated SW fetches. |
src/app/utils/matrix.ts |
Changes media download fetch cache mode and adds error throwing on non-OK responses. |
src/app/hooks/useBlobCache.ts |
Implements persistent Cache API storage, metadata tracking, eviction, and cache stats. |
src/app/features/settings/developer-tools/DevelopTools.tsx |
Adds a “Caches” section with SVG avatar cache size + clear action. |
src/app/components/user-avatar/UserAvatar.tsx |
Reuses shared SVG-processing hook for user avatars. |
src/app/components/room-avatar/AvatarImage.tsx |
Adds module-level SVG blob URL cache and exports cache utilities + hook. |
.changeset/media-cache.md |
Declares a minor release note for persistent media caching. |
Comments suppressed due to low confidence (6)
src/app/hooks/useBlobCache.ts:90
cacheMediaalwayspushes new metadata entries even if the URL is already cached. If the same URL is cached more than once in a session,cacheMetadatawill contain duplicates and size/count stats (and eviction) will be wrong. Update existing metadata entries for the same URL (or de-dup before pushing).
await cache.put(url, response);
// Update metadata
cacheMetadata.push({
url,
size: blob.size,
cachedAt: Date.now(),
});
// Check size and evict if needed
await evictIfNeeded();
src/app/hooks/useBlobCache.ts:144
evictIfNeededevicts a fixed 10% of entries once, but it may still leave the cache aboveMAX_CACHE_SIZE_MB(e.g. a few very large blobs). Consider evicting in a loop until under the limit, or evicting at least one item and recomputing size after deletions.
async function evictIfNeeded(): Promise<void> {
const totalSizeBytes = cacheMetadata.reduce((sum, m) => sum + m.size, 0);
const totalSizeMB = totalSizeBytes / (1024 * 1024);
if (totalSizeMB <= MAX_CACHE_SIZE_MB) return;
try {
const cache = await openMediaCache();
const toEvict = Math.ceil(cacheMetadata.length * 0.1); // Evict 10% of entries
const toDelete: CacheMetadata[] = [];
for (let i = 0; i < toEvict && cacheMetadata.length > 0; i++) {
const oldest = cacheMetadata.shift();
if (oldest) {
toDelete.push(oldest);
}
}
// Delete all in parallel
await Promise.all(toDelete.map((m) => cache.delete(m.url)));
} catch {
src/app/hooks/useBlobCache.ts:159
clearMediaCacheclearsimageBlobCachewithout revoking the existingblob:object URLs. That leaves the underlying memory allocated for the lifetime of the page. Revoke each object URL before clearing the map (and consider clearinginflightRequeststoo).
export async function clearMediaCache(): Promise<void> {
try {
await caches.delete(CACHE_NAME);
cacheMetadata = [];
metadataLoaded = false;
imageBlobCache.clear();
} catch {
src/app/hooks/useBlobCache.ts:197
- This hook updates state during render when
url !== cacheState.sourceUrl. Setting state while rendering can cause React warnings and extra renders, and can become an infinite loop in edge cases. Move this synchronization into auseEffectthat runs whenurlchanges, or deriveblobUrldirectly fromurl/cache without using state forsourceUrl.
if (url !== cacheState.sourceUrl) {
setCacheState({
sourceUrl: url,
blobUrl: url ? imageBlobCache.get(url) : undefined,
});
}
src/app/components/room-avatar/AvatarImage.tsx:86
useProcessedAvatarSrcdoesn't deduplicate in-flight processing. If multiple components mount with the samesrcsimultaneously, they can fetch/process concurrently and overwrite the cache entry; the overwritten blob URL is never revoked, leaking memory. Consider storing an in-flight Promise persrc(or checking the cache again beforeset) and revoking any replaced blob URL.
const cachedBlobUrl = svgBlobCache.get(src);
if (cachedBlobUrl) {
setProcessedSrc(cachedBlobUrl);
return () => {
isMounted = false;
};
}
const processImage = async () => {
try {
const res = await fetch(src, { mode: 'cors' });
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('image/svg+xml')) {
const text = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'image/svg+xml');
const animations = doc.querySelectorAll('animate, animateTransform, animateMotion');
animations.forEach((anim) => anim.setAttribute('repeatCount', 'indefinite'));
const style = doc.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = '* { animation-iteration-count: infinite !important; }';
doc.documentElement.appendChild(style);
const serializer = new XMLSerializer();
const newSvgString = serializer.serializeToString(doc);
const blob = new Blob([newSvgString], { type: 'image/svg+xml' });
const blobUrl = URL.createObjectURL(blob);
// Store in module cache so future remounts skip processing.
svgBlobCache.set(src, blobUrl);
if (isMounted) setProcessedSrc(blobUrl);
} else if (isMounted) setProcessedSrc(src);
} catch {
src/app/hooks/useBlobCache.ts:161
- The persistent Cache API store in
useBlobCachewill survive reloads and (without an explicit integration point) also survive logout/account switching. SinceclearMediaCacheisn’t referenced anywhere in the codebase, cached authenticated media can remain available to subsequent sessions in the same browser profile. Hook this into the logout flow (and/or a SW message) so media is purged when credentials are cleared.
/**
* Clear all media from persistent cache.
* Useful for "Clear Cache" settings option.
*/
export async function clearMediaCache(): Promise<void> {
try {
await caches.delete(CACHE_NAME);
cacheMetadata = [];
metadataLoaded = false;
imageBlobCache.clear();
} catch {
// Cache clear failed — silent ignore
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+339
to
+341
| if (!res.ok) { | ||
| throw new Error(`Failed to download media: ${res.status} ${res.statusText}`); | ||
| } |
| (m): m is CacheMetadata => m !== null | ||
| ); | ||
|
|
||
| cacheMetadata = metadata.toSorted((a, b) => a.cachedAt - b.cachedAt); // LRU order |
Comment on lines
+9
to
+24
| // Module-level cache: maps a Matrix media URL → processed blob URL so that | ||
| // SVG processing only runs once per unique image, even as virtual-list items | ||
| // unmount and remount. MXC URLs are content-addressed and never change, so | ||
| // the mapping is stable for the lifetime of the page. | ||
| const svgBlobCache = new Map<string, string>(); | ||
|
|
||
| export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) { | ||
| const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons'); | ||
| const [image, setImage] = useState<HTMLImageElement | undefined>(undefined); | ||
| const [processedSrc, setProcessedSrc] = useState<string>(src); | ||
| /** Number of SVG blob URLs currently held in the module-level cache. */ | ||
| export function getSvgCacheSize(): number { | ||
| return svgBlobCache.size; | ||
| } | ||
|
|
||
| const useUniformIcons = uniformIconsSetting && uniformIcons === true; | ||
| const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined; | ||
| /** Revoke all cached SVG blob URLs and clear the cache to free memory. */ | ||
| export function clearSvgBlobCache(): void { | ||
| svgBlobCache.forEach((url) => URL.revokeObjectURL(url)); | ||
| svgBlobCache.clear(); | ||
| } |
Comment on lines
+31
to
+38
| useEffect(() => { | ||
| setSvgCacheSize(getSvgCacheSize()); | ||
| }, []); | ||
|
|
||
| const clearSvgCacheAction = useCallback(() => { | ||
| clearSvgBlobCache(); | ||
| setSvgCacheSize(0); | ||
| }, []); |
Comment on lines
642
to
650
| function fetchConfig(token: string): RequestInit { | ||
| return { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| }, | ||
| cache: 'default', | ||
| // Use 'no-cache' to ensure we check with the server on each request | ||
| // This prevents stale/expired token responses from being cached | ||
| cache: 'no-cache', | ||
| }; |
Comment on lines
21
to
44
| @@ -38,7 +40,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User | |||
| return ( | |||
| <AvatarImage | |||
| className={classNames(css.UserAvatar, className)} | |||
| src={src} | |||
| src={processedSrc} | |||
| alt={alt} | |||
- downloadMedia already throws on non-2xx (no change needed) - useBlobCache: call touchCacheEntry() on cache hit to implement true LRU eviction instead of FIFO - AvatarImage: cap svgBlobCache at 200 entries, evict oldest on overflow; skip fetch entirely for known non-SVG extensions to avoid double-fetch - DeveloperTools: read svgCacheSize lazily at mount (useState initializer) and re-read after clear, eliminating stale display - sw.ts: use cache:'default' for media fetches (MXC URLs are immutable) so browsers can serve repeated image requests from their HTTP cache; keep cache:'no-cache' only for push API calls
downloadMedia now throws on non-2xx responses. Add try/catch so a failed network request does not become an unhandled promise rejection from the click handler. Addresses Copilot review comment on SableClient#870.
Just-Insane
added a commit
to Just-Insane/Sable
that referenced
this pull request
May 20, 2026
- fix(phantom-unreads): restrict badge suppression to SUPPRESSABLE_SENT_EVENT_TYPES (m.room.message / m.room.encrypted / m.sticker) and pass room+userId to isNotificationEvent to prevent state events from clearing badges (SableClient#883) - fix(dm-list-group-avatars): add GroupAvatarRowHideText/GroupAvatarMiniHideText CSS classes (32px/18px) so composite DM avatars scale properly in icon-only sidebar mode instead of leaving 14px minis in a 32px container (SableClient#816) - fix(media-cache): catch downloadMedia errors in openMediaInNewTab so a failed network response does not become an unhandled promise rejection (SableClient#870) - fix(media-cache): restore touchCacheEntry LRU tracking in useBlobCache.ts that was dropped during merge; cache hits now update the access timestamp so frequently-used entries are not prematurely evicted (SableClient#870) - fix(media-cache): restore SVG_BLOB_CACHE_MAX eviction cap and non-SVG URL fast-path in AvatarImage.useProcessedAvatarSrc that were dropped during merge; prevents unbounded memory growth and avoids a redundant fetch for every .png/.jpg/.gif/.webp avatar (SableClient#870)
…/ 300 MB desktop) iOS/Android devices have limited Cache API quota — the previous unconditional 500 MB ceiling could exhaust iOS Safari's per-origin quota, triggering storage eviction that causes the PWA to reload on next open.
…obile / 1000 desktop) Moves SW_MEDIA_CACHE, fetchMediaWithCache, and entry-cap eviction from feat/presence into this branch where all media caching lives. - Replaces per-request mediaFetchConfig (cache: default) with a Cache API-backed fetchMediaWithCache that serves hits from sable-media-sw-v1 - Evicts oldest entries when the cache exceeds 200 (mobile) or 1000 (desktop) entries to prevent iOS storage pressure / PWA eviction
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Add a service-worker-backed Cache API layer for media (images, videos, files). Media is served from cache on subsequent loads, reducing bandwidth usage and improving perceived load times. Failed or errored media requests are not cached to prevent poisoning the cache with error responses.
Fixes #
Type of change
Checklist:
AI disclosure:
A fetch-event handler in the service worker intercepts requests matching the Matrix media path pattern. On a cache hit it returns the cached response directly. On a miss it fetches from the network, clones the response, stores the clone in a named Cache API store only when the status is 2xx (avoiding caching 404s or auth errors), then returns the original response to the page. A separate message handler lets the main thread send a
CLEAR_MEDIA_CACHEcommand to purge the store on logout.