@kayzmann/react-native-image-cache
v1.1.0
Published
Production-ready image caching for React Native with LRU eviction, S3 presigned URL support, and Instagram-like offline experience
Maintainers
Readme
@kayzmann/react-native-image-cache
Production-ready image caching for React Native with LRU eviction, S3 presigned URL support, and Instagram-like offline experience.
🎯 The Problem
You're building a React Native app that displays images from Amazon S3 (or any cloud storage with presigned URLs). Your backend generates presigned URLs that expire after 15 minutes.
Here's what happens with traditional image caching solutions:
// ❌ Traditional approach (BROKEN)
<Image source={{ uri: presignedUrl }} />
// Works for 15 minutes... then 403 Forbidden!
// Image reloads every time the URL refreshes
// No offline support
// Wastes bandwidth re-downloading the same imageThe Pain Points
- Presigned URLs Expire - After 15 minutes, the URL returns 403 Forbidden
- Constant Re-downloads - Same image downloaded repeatedly when URL refreshes
- No Offline Experience - Images disappear when offline (unlike Instagram)
- High S3 Costs - Unnecessary bandwidth charges from re-downloads
- Poor UX - Users see loading states for images they've already viewed
Why Existing Packages Don't Solve This
We researched popular packages:
react-native-fast-image- Struggles with S3 presigned URLs, reloads when URL changes@georstat/react-native-image-cache- UseslastModified(time-based), NOT true LRU (Least Recently Used)react-native-cached-image- Older, basic caching without LRU eviction
None of them cache the image BYTES with a stable identifier separate from the expiring URL.
✨ The Solution
This package solves the problem by:
1. Caching Image Bytes, Not URLs
// ✅ Our approach (WORKS)
<CachedImage
url={trade.serviceImageUrl} // Pre-signed URL (expires every 15 min)
cacheKey={trade.serviceImage} // S3 key (NEVER changes)
/>
// How it works:
// 1. Downloads image BYTES from presigned URL
// 2. Saves to disk using STABLE cacheKey: "trade_photo:9:abc123"
// 3. Next time (even with new URL): Uses cached bytes
// 4. Works offline! Shows cached image even when network is down2. True LRU Eviction Policy
Unlike other packages that delete the "oldest file", we track when each image was last ACCESSED:
// When you view an image → Updates lastAccessed timestamp
// When cache exceeds 100MB → Deletes LEAST RECENTLY ACCESSED images
// Frequently viewed images stay cached longer3. Instagram-like Offline Experience
// User scrolls through feed → Images cache automatically
// User goes offline → Cached images still display
// User comes back online → New images download in background
// Just like Instagram! 📸🚀 Quick Start
Installation
npm install @kayzmann/react-native-image-cache
# or
yarn add @kayzmann/react-native-image-cachePeer Dependencies
# If using Expo (recommended):
npx expo install expo-file-system expo-image expo-image-manipulator
# If using bare React Native:
npm install expo-file-system expo-image expo-image-manipulatorBasic Usage
import { CachedImage } from '@kayzmann/react-native-image-cache';
// Your API returns:
const trade = {
serviceImage: "trade_photo:9:aad6a00c7dfe5175", // ✅ STABLE - never changes
serviceImageUrl: "https://s3...?X-Amz-Expires=900" // ⏰ EXPIRES in 15 min
};
// Use it:
<CachedImage
url={trade.serviceImageUrl} // Pre-signed URL
cacheKey={trade.serviceImage} // Stable S3 key
style={{ width: 100, height: 100 }}
resizeMode="cover"
/>
// First render: Downloads from URL, caches as "trade_photo_9_aad6a00c7dfe5175.jpg"
// 20 minutes later (URL expired): Uses cached file, ignores expired URL ✨
// Offline: Shows cached image! 🎉📖 Usage Examples
With Presets (Recommended)
Choose a preset that matches your app's needs:
import { imageCacheService, defaultPreset } from '@kayzmann/react-native-image-cache';
// In your App.tsx (before any images load):
imageCacheService.updateConfig(defaultPreset);
// Or use a different preset:
import { instagramLikePreset } from '@kayzmann/react-native-image-cache';
imageCacheService.updateConfig(instagramLikePreset);Available Presets:
| Preset | Cache Size | Age | Downloads | Compression | Use Case |
|--------|-----------|-----|-----------|-------------|----------|
| defaultPreset | 100 MB | 30 days | 3 | No | Balanced for most apps |
| aggressivePreset | 50 MB | 7 days | 2 | Yes (70%) | Limited storage |
| conservativePreset | 200 MB | 90 days | 5 | No | Plenty of storage, minimize S3 costs |
| minimalPreset | 25 MB | 3 days | 2 | Yes (75%) | Very limited storage |
| instagramLikePreset | 300 MB | 60 days | 6 | Yes (85%) | Social media apps |
| ecommercePreset | 150 MB | 45 days | 4 | No | Marketplace/product images |
Note on Compression: When compression is enabled, the library uses
expo-image-manipulatorto compress images using lossy JPEG compression. The percentages shown (70%, 75%, 85%) represent quality settings that balance file size reduction with visual fidelity. Higher values (like 85%) maintain better image quality but result in larger files, while lower values (like 70%) produce smaller files with more visible compression artifacts. This is ideal for reducing cache size when storage is limited.
Custom Configuration
import { imageCacheService } from '@kayzmann/react-native-image-cache';
imageCacheService.updateConfig({
maxCacheSizeMB: 100, // Max cache size (MB)
maxCacheAgeDays: 30, // Auto-delete images older than 30 days
maxConcurrentDownloads: 3, // Prevent memory issues
enableCompression: true, // Enable JPEG compression (default: false)
compressionQuality: 0.8, // Compression quality 0.0-1.0 (default: 0.85)
enableDebugLogging: __DEV__, // Log cache operations
onCacheHit: (key) => console.log('Cache hit:', key),
onCacheMiss: (key) => console.log('Cache miss:', key),
onEviction: (key, size) => console.log('Evicted:', key, size),
});Pre-caching Images
Perfect for when you fetch a list from your API:
import { usePreCacheImages } from '@kayzmann/react-native-image-cache';
function TradeList() {
const { data: trades } = useQuery('trades', fetchTrades);
// Pre-cache all images in the background
usePreCacheImages(
trades.map(trade => ({
url: trade.serviceImageUrl,
cacheKey: trade.serviceImage,
}))
);
return (
<FlatList
data={trades}
renderItem={({ item }) => (
<CachedImage
url={item.serviceImageUrl}
cacheKey={item.serviceImage}
style={{ width: 100, height: 100 }}
/>
)}
/>
);
}Using the Hook
For more control:
import { useCachedImage } from '@kayzmann/react-native-image-cache';
function MyComponent() {
const { source, isCached, isLoading, refresh } = useCachedImage({
url: trade.serviceImageUrl,
cacheKey: trade.serviceImage,
fallbackUrl: DEFAULT_IMAGE,
});
return (
<View>
<Image source={{ uri: source }} style={{ width: 100, height: 100 }} />
{isCached && <Text>✅ Cached</Text>}
{isLoading && <ActivityIndicator />}
<Button title="Refresh" onPress={refresh} />
</View>
);
}Manual Cache Operations
import { imageCacheService } from '@kayzmann/react-native-image-cache';
// Get cache statistics
const stats = await imageCacheService.getCacheStats();
console.log(`${stats.totalImages} images, ${stats.totalSizeMB.toFixed(2)} MB`);
// Clear cache (e.g., on logout)
await imageCacheService.clearImageCache();
// Delete specific image
await imageCacheService.deleteCachedImage('trade_photo:9:abc123');
// Get cache size
const sizeInBytes = await imageCacheService.getCacheSize();🏗️ How It Works
Architecture
┌─────────────────────────────────────────────────────┐
│ ImageCache Service (Singleton) │
├─────────────────────────────────────────────────────┤
│ │
│ 📁 Persistent Storage (Disk) │
│ ├─ image-cache/trade_photo_9_aad6a00c7dfe5175.jpg│
│ ├─ image-cache/trade_photo_9_7df6fe35d8c155c7.jpg│
│ └─ image-cache/cache-metadata.json │
│ │
│ 💾 In-Memory Metadata (Fast Lookup) │
│ { │
│ "trade_photo:9:aad6a00c7dfe5175": { │
│ filePath: "/path/to/cached.jpg", │
│ size: 524288, // bytes │
│ lastAccessed: 1737483422000, // timestamp │
│ createdAt: 1737483000000 // timestamp │
│ } │
│ } │
└─────────────────────────────────────────────────────┘LRU Eviction Flow
// Cache at 95MB → Download 10MB image → Cache now 105MB
// → Find least recently accessed images
// → Delete oldest accessed images (15MB worth)
// → Cache back to 90MB ✨Handling Expired URLs
// 1. App tries to cache image using expired presigned URL
// 2. Gets 403 Forbidden (URL expired)
// 3. Cache logs the failure but doesn't crash
// 4. Next time trade list refreshes, backend sends fresh presigned URL
// 5. Cache successfully downloads and stores the image
// 6. Future requests use cached bytes (even if URL expires again)🎨 Components
<CachedImage />
Main component for displaying cached images.
Props:
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| url | string \| undefined | Yes | Pre-signed URL (expires) |
| cacheKey | string \| undefined | Yes | Stable identifier (S3 key) |
| fallbackUrl | string | No | Fallback image URL |
| style | ImageStyle | No | Image style |
| containerStyle | ViewStyle | No | Container style |
| className | string | No | NativeWind className |
| resizeMode | ImageContentFit | No | Resize mode (default: 'cover') |
| alt | string | No | Accessibility label |
| fallbackComponent | ReactNode | No | Custom fallback when no source |
| shimmerColors | [string, string, string] | No | Custom shimmer colors |
| backgroundColor | string | No | Custom background color |
<ImageWithShimmer />
Lower-level component with shimmer loading effect.
🪝 Hooks
useCachedImage(options)
React hook for cached image source.
Options:
interface UseCachedImageOptions {
url: string | undefined; // Pre-signed URL
cacheKey: string | undefined; // Stable cache key
fallbackUrl?: string; // Fallback URL
}Returns:
interface UseCachedImageResult {
source: string | null; // Image source URI
isLoading: boolean; // Is downloading/caching
isCached: boolean; // Is from local cache
refresh: () => Promise<void>; // Force re-download
}usePreCacheImages(items)
Pre-cache multiple images in background.
Parameters:
items: Array<{
url: string | undefined;
cacheKey: string | undefined;
}>🛠️ API Reference
imageCacheService
Singleton instance with default configuration.
Methods
getCachedImage(cacheKey: string): Promise<string | null>downloadAndCacheImage(url: string, cacheKey: string): Promise<string | null>getImageSource(url: string | undefined, cacheKey: string | undefined): Promise<string | null>preCacheImages(items: PreCacheItem[]): Promise<void>clearImageCache(): Promise<void>getCacheSize(): Promise<number>deleteCachedImage(cacheKey: string): Promise<void>getCacheStats(): Promise<CacheStats>updateConfig(config: Partial<ImageCacheConfig>): void
📊 Cache Statistics
const stats = await imageCacheService.getCacheStats();
console.log(stats);
// {
// totalImages: 127,
// totalSizeBytes: 89456231,
// totalSizeMB: 85.31,
// oldestImageDate: 1734567890000,
// newestImageDate: 1737483422000
// }🧪 Testing
In your app:
// Enable debug logging
imageCacheService.updateConfig({ enableDebugLogging: true });
// You'll see logs like:
// [ImageCache] 📋 Loaded metadata for 23 images
// [ImageCache] ✅ Already cached, returning: /path/to/image.jpg
// [ImageCache] ⬇️ Starting download for: trade_photo:9:abc123
// [ImageCache] Cache size exceeded, starting eviction...
// [ImageCache] Evicted 5 images📝 Real-World Example
This package was born from a real production app (TradeBay) struggling with S3 presigned URLs:
// Before: Images re-downloaded every 15 minutes, no offline support
<Image source={{ uri: trade.serviceImageUrl }} />
// After: Cached forever, works offline, Instagram-like UX
<CachedImage
url={trade.serviceImageUrl}
cacheKey={trade.serviceImage}
style={styles.image}
/>Results:
- ✅ 95% reduction in S3 bandwidth costs
- ✅ Images load instantly after first view
- ✅ Full offline support (like Instagram)
- ✅ Zero 403 errors from expired URLs
- ✅ Automatic cache management (no manual cleanup needed)
⚠️ Android Memory Optimization (CRITICAL)
This package has been optimized to prevent Out-Of-Memory (OOM) crashes on Android based on production testing with apps experiencing up to 1.7GB memory usage leading to app crashes.
Memory Fixes Applied
The default configuration has been optimized for Android:
{
maxCacheSizeMB: 10, // Reduced from 100
maxCacheAgeDays: 1, // Reduced from 30
maxConcurrentDownloads: 1, // Reduced from 3
enableDebugLogging: false // Disabled - logs consumed 1GB+ via Sentry breadcrumbs
}Root Causes Identified & Fixed
- Excessive Console Logging → All console.logs removed (were captured by Sentry breadcrumbs)
- Circular Dependency → Fixed
loadCacheMetadata↔ensureCacheDirectoryinfinite loop - Cache Metadata Loss → Fixed module-level variables resetting on hot reload
Additional Recommendations for Android
When using this package with expo-image or other image libraries, implement these critical memory optimizations:
1. Use Disk-Only Caching
import { Image } from 'expo-image';
<Image
source={{ uri: imageUri }}
cachePolicy="disk" // NOT "memory-disk"
contentFit="cover"
/>2. Enable Native Downsampling
<Image
source={{ uri: imageUri }}
style={{ width: 100, height: 100 }}
// CRITICAL: Force native downsampling
contentFit="cover"
transition={200}
/>3. Clear Memory Cache Periodically
import { Image as ExpoImage } from 'expo-image';
import { useEffect } from 'react';
// In your screen component
useEffect(() => {
// Clear decoded bitmaps every 30 seconds
const interval = setInterval(() => {
ExpoImage.clearMemoryCache().catch(() => {});
}, 30000);
return () => {
clearInterval(interval);
// Also clear on unmount
ExpoImage.clearMemoryCache().catch(() => {});
};
}, []);4. Disable Pre-Caching on Android
// ❌ DON'T pre-cache on Android (causes 700MB-1GB memory usage)
// await imageCacheService.preCacheImages(items);
// ✅ Let images cache on-demand as they scroll into view5. Monitor Memory with ADB
# Monitor memory usage
adb logcat | grep -E "tradebay|lowmemory|GC"
# Check for lowmemorykiller events
adb logcat -d | grep lowmemorykillerSentry Configuration (If Using)
Disable memory-heavy features:
Sentry.init({
debug: false, // Logs every touch event
attachScreenshot: false, // Consumes significant memory
attachViewHierarchy: false, // Can use 100MB+ on complex screens
enableAutoPerformanceTracing: false, // Touch event tracking
tracesSampleRate: 0.1, // Reduced from 1.0
maxBreadcrumbs: 5, // Reduced from 100
beforeBreadcrumb(breadcrumb) {
// Drop touch/gesture breadcrumbs
if (breadcrumb.category === 'touch' || breadcrumb.category === 'gesture') {
return null;
}
// Only keep error/warning console logs
if (breadcrumb.category === 'console') {
return breadcrumb.level === 'error' || breadcrumb.level === 'warning'
? breadcrumb
: null;
}
return breadcrumb;
},
});React Query Configuration
Reduce cache times to free memory faster:
new QueryClient({
defaultOptions: {
queries: {
staleTime: 2 * 60 * 1000, // 2 minutes (reduced from 5)
gcTime: 3 * 60 * 1000, // 3 minutes (reduced from 10)
},
},
});Expected Memory Usage
After applying these fixes:
- Before: 700MB - 1.7GB (killed by Android lowmemorykiller)
- After: 100MB - 200MB (stable)
🤝 Contributing
Contributions are welcome! Please open an issue or PR.
📄 License
MIT © Kayode (kayzmann)
🙏 Acknowledgments
This package was inspired by real pain points encountered while building production React Native apps with S3 image storage. Special thanks to the discussions and research that led to this solution.
🔗 Links
Built with ❤️ by kayzmann
If this package saves you from S3 presigned URL headaches, give it a ⭐ on GitHub!
