expo-image
Caching, placeholders, writeToCacheAsync, and progressive loading - the expo-image cookbook for Expo SDK 57 feeds, galleries, and offline-first thumbnails.
Search across all documentation pages
Caching, placeholders, writeToCacheAsync, and progressive loading - the expo-image cookbook for Expo SDK 57 feeds, galleries, and offline-first thumbnails.
Quick-reference recipe card - copy-paste ready.
npx expo install expo-imageimport { Image } from "expo-image";
const BLURHASH =
"L6PZfSi_.AyE_3t7t7R**0o#DgR4";
export function FeedThumb({ uri }: { uri: string }) {
return (
<Image
source={{ uri }}
placeholder={{ blurhash: BLURHASH }}
placeholderContentFit="cover"
contentFit="cover"
transition={300}
cachePolicy="memory-disk"
style={{ width: "100%", aspectRatio: 1, borderRadius: 8 }}
/>
);
}// Seed cache after expo-image-picker returns a local URI
import { Image } from "expo-image";
export async function cachePickedPhoto(localUri: string, assetId: string) {
const cacheKey = `upload:${assetId}`;
await Image.writeToCacheAsync(localUri, cacheKey);
return cacheKey;
}When to reach for this:
When to avoid:
require() icons only - RN Image is fine.expo-document-picker and a document viewer.expo-image.Feed row with progressive bytes, cache seeding from capture, and list-safe recycling.
npx expo install expo-image expo-image-picker// src/media/FeedImageRow.tsx
import { useState } from "react";
import { Image, ImageLoadEventData } from "expo-image";
import { StyleSheet, Text, View } from "react-native";
type Props = {
id: string;
uri: string;
blurhash: string;
width: number;
};
export function FeedImageRow({ id, uri, blurhash, width }: Props) {
const [progress, setProgress] = useState(0);
return (
<View style={[styles.card, { width }]}>
<Image
source={{ uri, cacheKey: `feed:${id}` }}
recyclingKey={id}
placeholder={{ blurhash }}
placeholderContentFit="cover"
contentFit="cover"
cachePolicy="memory-disk"
priority="normal"
transition={250}
onProgress={({ loaded, total }) => {
if (total > 0) setProgress(loaded / total);
}}
onLoad={() => setProgress(1)}
style={styles.image}
/>
{progress > 0 && progress < 1 ? (
<Text style={styles.progress}>{Math.round(progress * 100)}%</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: { aspectRatio: 4 / 3, borderRadius: 10, overflow: "hidden", backgroundColor: "#e5e7eb" },
image: { flex: 1 },
progress: {
position: "absolute",
bottom: 8,
right: 8,
fontSize: 11,
color: "#fff",
backgroundColor: "rgba(0,0,0,0.45)",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
});// src/media/prefetchFeed.ts
import { Image } from "expo-image";
export async function prefetchNextPage(urls: string[]) {
return Image.prefetch(urls, { cachePolicy: "memory-disk" });
}What this demonstrates:
recyclingKey={id} blanks the cell before the new source loads - critical in virtualized lists.onProgress drives a lightweight percent label without custom download code.cacheKey in source pairs with writeToCacheAsync for captures you already own.Image.prefetch warms disk + memory before the user opens a detail screen.cachePolicy | Behavior | Use when |
|---|---|---|
disk (default) | Download once, read from disk later | Most remote thumbnails |
memory-disk | Hot in RAM, falls back to disk | Avatars shown on many screens |
memory | RAM only, evicted aggressively | Hero image on one screen visit |
none | Always refetch | Signed URLs that expire quickly |
<Image source={{ uri: signedUrl }} cachePolicy="none" />Signed URLs with short TTL should use none or a cache key that rotates when the signature rotates.
<Image
source="https://cdn.example.com/hero.webp"
placeholder={{ blurhash: post.blurhash }}
placeholderContentFit="cover"
contentFit="cover"
transition={400}
/>placeholderContentFit to contentFit - default scale-down on placeholders causes flicker.number from require() as placeholder while the HD URL loads.import { Image } from "expo-image";
import * as FileSystem from "expo-file-system";
export async function cacheDownloadedFile(remoteUrl: string, stableId: string) {
const tempUri = `${FileSystem.cacheDirectory}dl-${stableId}.jpg`;
await FileSystem.downloadAsync(remoteUrl, tempUri);
const cacheKey = `asset:${stableId}`;
await Image.writeToCacheAsync(tempUri, cacheKey);
return { cacheKey, displayUri: tempUri };
}<Image source={{ uri: displayUri, cacheKey }} contentFit="cover" style={{ flex: 1 }} />ImageRef from takePictureAsync({ pictureRef: true }).ImageRef flattens to one frame - pass the file URI for lossless animation.cacheKey skip network entirely.function onProgress(event: { loaded: number; total: number }) {
const ratio = event.total > 0 ? event.loaded / event.total : 0;
// Drive a determinate bar or skeleton width - keep work O(1)
}onLoadStart / onLoad / onDisplay mark skeleton → loaded → painted transitions.priority="high" for above-the-fold hero images.<Image
recyclingKey={item.id}
source={{ uri: item.thumbUrl, cacheKey: `thumb:${item.id}` }}
allowDownscaling
style={{ width: 72, height: 72 }}
/>allowDownscaling (default true) decodes near view size - leave on unless you need max quality in a tiny box.enforceEarlyResizing on iOS reduces peak memory for oversized CDN assets.await Image.clearMemoryCache();
await Image.clearDiskCache();Call from a Settings → Clear cache action or when the user logs out. Disk clear is async and should not run on every screen focus.
recyclingKey in FlashList - previous user's avatar flashes. Fix: recyclingKey={item.id}.placeholderContentFit="cover" matching contentFit.cachePolicy="none" or rotate cacheKey with signature.Image migration - resizeMode maps to contentFit, not 1:1 for all values. Fix: read the prop table when porting.writeToCacheAsync on remote URL - API expects local file. Fix: download to cache dir first.| Alternative | Use when | Don't use when |
|---|---|---|
expo-image | Remote feeds, caching, placeholders | You only render bundled PNGs |
RN Image | Legacy screens, require() icons | New feed development |
react-native-fast-image | Brownfield app already standardized on it | Greenfield Expo SDK 57 |
Skia useImage | GPU canvas drawing | Simple <Image> thumbnails |
npx expo install expo-imageWorks in Expo Go - no config plugin required for basic usage.
writeToCacheAsync(pickerUri, cacheKey) then render with the same cacheKey.expo-image caches still assets via SDWebImage/Glide.expo-video has a separate video cache API - see expo-video.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026