If you run a few image-first apps as an indie developer, there comes a point where Crashlytics starts showing the same crash over and over. The repro is just "open the app, scroll the gallery for a while, zoom a few photos." The exception is always Out of memory, with no stack trace pinned to a specific screen, so the cause is hard to grab at first.
I hit this in a wallpaper app of my own. The code worked correctly, yet the longer someone used it, the more likely it was to die. The culprit was a cache that grew a little at a time. expo-image caches intelligently, but unless you design the ceiling yourself, both disk and memory keep climbing quietly. These are my notes on how I measured that growth, where I placed the caps, and what I cut on the delivery side — written in the order that paid off.
First, confirm the bloat with numbers
The worst move in a memory problem is to sprinkle clearMemoryCache() on a hunch. Clear without knowing what is consuming what, and you also tank your cache hit rate, which makes loading slow and creates a different kind of churn. Start by measuring.
expo-image does not directly report disk cache size, so I measure the cache directory with expo-file-system and turn onLoad results into metrics.
// Hook to measure load results and disk cache size
import { useCallback, useRef, useState } from 'react';
import * as FileSystem from 'expo-file-system';
type LoadMetrics = {
loadCount: number;
cacheHits: number;
cacheHitRate: number; // %
avgLoadMs: number;
diskCacheMB: number;
};
export const useImageMetrics = () => {
const startTimes = useRef<Record<string, number>>({});
const totals = useRef({ count: 0, ms: 0, hits: 0 });
const [metrics, setMetrics] = useState<LoadMetrics>({
loadCount: 0, cacheHits: 0, cacheHitRate: 0,
avgLoadMs: 0, diskCacheMB: 0,
});
const markStart = useCallback((key: string) => {
startTimes.current[key] = Date.now();
}, []);
// expo-image's onLoad returns event.cacheType ('memory' | 'disk' | 'none')
const markLoad = useCallback((key: string, cacheType?: string) => {
const started = startTimes.current[key] ?? Date.now();
const ms = Date.now() - started;
const t = totals.current;
t.count += 1; t.ms += ms;
if (cacheType === 'memory' || cacheType === 'disk') t.hits += 1;
setMetrics((prev) => ({
...prev,
loadCount: t.count,
cacheHits: t.hits,
cacheHitRate: Math.round((t.hits / t.count) * 100),
avgLoadMs: Math.round(t.ms / t.count),
}));
}, []);
const refreshDiskSize = useCallback(async () => {
const dir = (FileSystem.cacheDirectory ?? '') + 'expo-image/';
const info = await FileSystem.getInfoAsync(dir, { size: true });
const mb = (info.exists ? (info.size ?? 0) : 0) / (1024 * 1024);
setMetrics((prev) => ({ ...prev, diskCacheMB: Math.round(mb * 10) / 10 }));
}, []);
return { metrics, markStart, markLoad, refreshDiskSize };
};
// One real reading (my wallpaper app, iPhone 12, after scrolling 1,200 images):
// { loadCount: 1200, cacheHits: 1044, cacheHitRate: 87,
// avgLoadMs: 64, diskCacheMB: 612.4 }The first thing this revealed: cacheHitRate was a healthy 87%, yet diskCacheMB was over 600MB. The cache was working — working so well it never stopped accumulating. The problem was not the hit rate but the missing ceiling.
Cap the disk cache and delete oldest first
expo-image's clearDiskCache() wipes everything, which is too blunt for production. What you want is "delete only the overflow, oldest first." You can build that with expo-file-system by collecting each cached file's modification time and size, then deleting from the oldest once the total exceeds a threshold — an LRU-style eviction.
// Keep the disk cache under a 200MB ceiling
import * as FileSystem from 'expo-file-system';
const CACHE_DIR = (FileSystem.cacheDirectory ?? '') + 'images/';
const MAX_BYTES = 200 * 1024 * 1024; // 200MB
export const evictDiskCache = async () => {
const dir = await FileSystem.getInfoAsync(CACHE_DIR);
if (!dir.exists) return { freedMB: 0, keptMB: 0 };
const names = await FileSystem.readDirectoryAsync(CACHE_DIR);
const entries = await Promise.all(
names.map(async (name) => {
const info = await FileSystem.getInfoAsync(CACHE_DIR + name, { size: true });
return {
uri: CACHE_DIR + name,
size: info.exists ? (info.size ?? 0) : 0,
mtime: info.exists ? (info.modificationTime ?? 0) : 0, // seconds; newer = larger
};
})
);
const total = entries.reduce((sum, e) => sum + e.size, 0);
if (total <= MAX_BYTES) return { freedMB: 0, keptMB: Math.round(total / 1048576) };
// Oldest first (ascending mtime), drop down to 80% of the ceiling
entries.sort((a, b) => a.mtime - b.mtime);
let running = total;
let freed = 0;
const target = MAX_BYTES * 0.8;
for (const e of entries) {
if (running <= target) break;
await FileSystem.deleteAsync(e.uri, { idempotent: true });
running -= e.size; freed += e.size;
}
return { freedMB: Math.round(freed / 1048576), keptMB: Math.round(running / 1048576) };
};
// When to call: once on launch and once on returning from background.
// Example output: { freedMB: 430, keptMB: 160 }Two details mattered here. First, drop to 80% rather than exactly the threshold. Trim to the boundary and a few more loads push you back over it, so eviction runs every time. A little headroom lowers how often you delete. Second, I limited deletion to launch and background-return. Running it mid-scroll caused the disk I/O to drop frames. Heavy cleanup belongs in the moments the user is not looking at the screen.