It started with a low-storage warning on my own iPhone. I opened Settings → General → iPhone Storage, and near the top of the list sat one of the wallpaper apps I run as an indie developer. The app binary itself was around 40 MB. "Documents & Data," however, had grown to 2.4 GB.
That number stung. I had already moved every image out of the bundle and onto remote delivery to keep the binary small — and yet the app was quietly eating users' storage from the other side. The culprit turned out to be expo-image's disk cache.
Since I ship the same architecture across several apps on both the App Store and Google Play, I want to walk through the symptoms, how to reproduce the problem, and the three-layer fix that worked for me.
Where the symptom shows up — app size vs. Documents & Data
There are two places to check first:
- iOS: Settings → General → iPhone Storage → your app. The screen separates "App Size" from "Documents & Data"
- Android: Settings → Apps → your app → Storage & cache. The growth lands in the "Cache" bucket
"App Size" is the binary plus bundled assets — the part you control at build time. "Documents & Data" is everything the app writes at runtime, and downloaded image caches live there.
In other words, no matter how aggressively you trim your binary, an unbounded runtime cache means the storage footprint users actually see keeps climbing.
If you are lucky, someone leaves a review saying the app "eats storage." In my experience, most users simply uninstall without a word. A bloated footprint also makes your app a prime candidate when iOS suggests offloading unused apps under storage pressure.
Reproduction and root cause — the disk cache only grows
expo-image's cachePolicy defaults to memory-disk. Every image you display gets cached in memory and on disk, which makes the next render fast. As a loading-speed optimization this design is excellent — I wrote about that side of it in Slow Images in My Rork Wallpaper App: Switching to expo-image and What Actually Changed.
The problem is that there is no public API to cap the disk cache size from your app. The cache implementation is delegated to SDWebImage on iOS and Glide on Android, so eviction timing and limits depend on each library's defaults and on OS behavior.
A wallpaper app hits this weakness head-on:
- Each full-resolution image weighs several megabytes
- Users scroll through hundreds of items in the grid
- Previews open full-resolution images one after another
You can reproduce the bloat on a real device by scrolling through roughly 300 grid items, opening 30 previews, and then checking the Settings app — that alone adds hundreds of megabytes. Your most loyal daily users are exactly the ones whose cache grows into gigabytes.
iOS does reserve the right to purge the Caches directory under storage pressure, but in my observation it rarely happens before the user has already noticed the problem. I recommend treating cache management as your app's job, not the OS's.
Fix 1 — split cachePolicy by screen role
The first change that paid off was giving thumbnails and full-resolution previews different caching strategies.
// components/WallpaperThumbnail.tsx — grid thumbnail
import { Image } from 'expo-image';
type Props = { thumbUrl: string };
export function WallpaperThumbnail({ thumbUrl }: Props) {
return (
<Image
source={{ uri: thumbUrl }}
style={{ width: '100%', aspectRatio: 9 / 16 }}
contentFit="cover"
recyclingKey={thumbUrl}
transition={150}
cachePolicy="memory-disk" // grids get revisited, so keep these on disk
/>
);
}// screens/WallpaperPreview.tsx — full-resolution preview
import { Image } from 'expo-image';
import { StyleSheet } from 'react-native';
export function WallpaperPreview({ fullUrl }: { fullUrl: string }) {
return (
<Image
source={{ uri: fullUrl }}
style={StyleSheet.absoluteFill}
contentFit="contain"
cachePolicy="memory" // never write full-res images to disk
/>
);
}The decision rule is simple: will this exact image likely be displayed again soon? Grid thumbnails get re-rendered constantly, so the disk cache earns its keep. A full-resolution preview is closer to a one-time view; keeping it on disk rarely pays off. Better to hold it in memory only and leave the disk clean.
This single change visibly slowed the growth of Documents & Data, because one full-resolution image weighs as much as dozens of thumbnails.
Fix 2 — shrink the bytes you write in the first place
Tuning cachePolicy controls where bytes go; you can also reduce how many bytes there are. Serve a downscaled thumbnail URL for grids, and fetch full resolution only when it is genuinely needed.
When I moved my images to remote delivery, I set up two URL variants per image: one for thumbnails and one for actually setting the wallpaper. I covered the background in Keeping a wallpaper app's binary small: moving images out of the bundle, and the same two-tier setup turns out to matter for cache bloat too. If a thumbnail is 100 KB, scrolling 300 items writes about 30 MB to disk — a tiny fraction of what full-resolution images in the grid would cost.
Apps freshly generated by Rork often reuse one URL for both the grid and the preview, so this is worth checking before anything else.
Fix 3 — generational clearing plus a "Clear cache" button
Even with both fixes, caches accumulate over months of use. As the last line of defense I added two things.
// lib/cache-maintenance.ts — reset the disk cache every 30 days
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Image } from 'expo-image';
const KEY = 'imageCacheClearedAt';
const INTERVAL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
export async function maintainImageCache(): Promise<void> {
const raw = await AsyncStorage.getItem(KEY);
const lastCleared = raw ? Number(raw) : 0;
if (Date.now() - lastCleared < INTERVAL_MS) return;
await Image.clearDiskCache();
await AsyncStorage.setItem(KEY, String(Date.now()));
}Call this on app launch — a useEffect in your root layout works fine. It also fires on the very first launch, but the cache is empty at that point, so nothing is lost.
Then give users a manual escape hatch in the settings screen:
// handler for the "Clear image cache" button in settings
import { Alert } from 'react-native';
import { Image } from 'expo-image';
export async function onClearImageCache(): Promise<void> {
await Image.clearMemoryCache();
await Image.clearDiskCache();
Alert.alert('Done', 'Image cache cleared.');
}Having an in-app way to clear the cache matters more on iOS than you might expect. Android users can clear an app's cache from system settings, but iOS offers no standard way short of deleting the app entirely. If someone is about to remove your app over storage, I would much rather they clear the cache and stay.
The 30-day interval comes from my own usage data: even heavy users accumulate only a few hundred megabytes of browsing cache per month in my apps. If your content rotates faster, a shorter interval makes sense.
Prevention — check Documents & Data before every release
What makes this issue sneaky is that development workflows hide it. Dev builds get reinstalled constantly, so the cache never matures, and nobody watches free space on a simulator.
My pre-release checklist now includes:
- On a real device, scroll about 300 grid items and open 30 previews, then check Documents & Data in the Settings app
- If the number far exceeds the expected total (thumbnails plus a margin), audit
cachePolicyto see whether full-resolution images are being written to disk
It is an unglamorous ten minutes, but far cheaper than reading about it in a store review.
As a next step, install your current build on a real device and look at Documents & Data once. If the number surprises you, start with Fix 1 and work down the list. I hope this saves you the gigabytes it cost me to learn.