Mastering Image Caching and Lazy Loading in Hybrid Mobile Apps
Source: Dev.to
The Problem with Default WebView Caching
When you rely purely on standard <img> tags, you leave resource management up to the underlying mobile browser engine (WKWebView on iOS, Chromium on Android). This introduces several issues:
- Aggressive Cache Eviction – WebViews routinely clear their caches to free up system memory, forcing users to re‑download avatars and thumbnails they just saw.
- Duplicate Requests – If the same image appears multiple times on a screen, the browser might initiate multiple simultaneous network requests.
- Unmanaged Memory – Fetching massive images without cleanup can crash the app due to memory limits.
The Solution: A Custom LRU Blob Cache
The most effective way to bypass WebView limitations is to intercept the image‑fetching process, download the images as Blob data, and store them locally in memory as Object URLs. The provided ImageCacheService elegantly handles this using a Least Recently Used (LRU) caching strategy.
1. The Global Cache Service
The core of this architecture is an Angular service provided at the root level.
- Map‑Based Storage – Uses a standard JavaScript
Mapto store a mapping of the original source URL to the generated local Object URL. - LRU Bump – When an image is requested, the service checks if it exists in the cache. If it does, it deletes and re‑inserts the entry, moving it to the “back” of the
Map(marking it as most recently used). - Memory Management – The cache has a hard limit (
MAX_CACHE_SIZE = 100). When the limit is reached, it identifies the oldest entry (the first key in theMap), callsURL.revokeObjectURL()to free browser memory, and deletes the entry.
@Injectable({ providedIn: 'root' })
export class ImageCacheService {
private readonly MAX_CACHE_SIZE = 100;
private cache = new Map(); // original URL → object URL
private pending = new Map>(); // ongoing fetches
async get(url: string): Promise {
if (this.cache.has(url)) {
const objUrl = this.cache.get(url)!;
// LRU bump
this.cache.delete(url);
this.cache.set(url, objUrl);
return objUrl;
}
if (this.pending.has(url)) {
return this.pending.get(url)!;
}
const fetchPromise = this.fetchAndCache(url);
this.pending.set(url, fetchPromise);
const result = await fetchPromise;
this.pending.delete(url);
return result;
}
private async fetchAndCache(url: string): Promise {
const response = await fetch(url);
const blob = await response.blob();
const objUrl = URL.createObjectURL(blob);
this.addToCache(url, objUrl);
return objUrl;
}
private addToCache(url: string, objUrl: string) {
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
URL.revokeObjectURL(this.cache.get(oldestKey)!);
this.cache.delete(oldestKey);
}
this.cache.set(url, objUrl);
}
}
2. Preventing Duplicate Fetches
In lists or feeds, the same user avatar may appear multiple times. The service maintains a pending Map that tracks ongoing Promise downloads:
- If an HTTP request is already in flight for a specific URL, subsequent requests hook into the existing promise instead of firing a new network request.
- Once the fetch completes (or fails), the URL is removed from the
pendingmap.
The UI Layer: A Smart Lazy Loading Directive
With the caching logic isolated in a service, the UI needs a way to seamlessly consume it. The standalone LazyLoadDirective targets any <img> tag with the appLazyLoad attribute.
1. Structure and Placeholders
When the directive initializes, it optimizes the DOM structure and sets up a loading state:
- Picture Wrapper – If the image isn’t already inside a
<picture>tag, the directive programmatically wraps the<img>in one, adding alazy-picture-wrapperclass for easier CSS targeting. - Transparent GIF – Before the real image is fetched, the directive injects a lightweight, transparent base64 GIF (
data:image/gif;base64,...) into thesrcattribute. - Loading State – An
img-loadingCSS class is applied, which can be used for skeleton animations or spinners.
@Directive({
selector: '[appLazyLoad]',
standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
@Input('appLazyLoad') src!: string;
private abortController?: AbortController;
private unlisteners: (() => void)[] = [];
constructor(
private el: ElementRef,
private cache: ImageCacheService,
private renderer: Renderer2
) {}
ngOnInit() {
this.setupWrapper();
this.loadImage();
}
ngOnChanges() {
this.loadImage();
}
ngOnDestroy() {
this.abortController?.abort();
this.unlisteners.forEach((un) => un());
}
private setupWrapper() {
const img = this.el.nativeElement;
if (!img.parentElement?.matches('picture')) {
const picture = this.renderer.createElement('picture');
this.renderer.addClass(picture, 'lazy-picture-wrapper');
const parent = img.parentNode;
this.renderer.insertBefore(parent, picture, img);
this.renderer.removeChild(parent, img);
this.renderer.appendChild(picture, img);
}
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
this.renderer.addClass(img, 'img-loading');
}
private async loadImage() {
this.abortController?.abort();
this.abortController = new AbortController();
const signal = this.abortController.signal;
// Skip cache for data URIs
if (this.src.startsWith('data:')) {
this.applySrc(this.src);
return;
}
try {
const objectUrl = await this.cache.get(this.src);
if (signal.aborted) return;
this.applySrc(objectUrl);
} catch (e) {
// handle error (optional)
}
}
private applySrc(url: string) {
const img = this.el.nativeElement;
img.src = url;
this.renderer.removeClass(img, 'img-loading');
}
}
2. Graceful Cancellations
Mobile users scroll fast. If a user scrolls past an image before it finishes downloading, we shouldn’t waste processing power rendering it.
- AbortControllers – The directive creates a new
AbortControllerfor each load attempt. - Lifecycle Cleanup – When the
srcinput changes or the component is destroyed, the controller’sabort()method is called. - Safety Check – After the global cache resolves the Blob URL, the directive checks
signal.aborted. If aborted, it halts the DOM update, preventing race conditions where old images render over new ones.
3. Edge Cases
A robust directive must handle data that doesn’t need fetching:
- Data URIs – If the provided image source is already a base64 string (
startsWith('data:')), the directive skips the cache, applies the source directly, and immediately removes the loading class. - Event Listeners – Listens to native
loadanderrorevents to ensure theimg-loadingclass is reliably removed regardless of success or failure. These listeners are stored in anunlistenersarray and cleaned up on destroy to prevent memory leaks.
By separating the caching logic (the “brains”) from the DOM manipulation (the UI layer), this architecture provides a highly performant, native‑feeling image experience for hybrid apps.