Mastering Image Caching and Lazy Loading in Hybrid Mobile Apps

Published: (February 16, 2026 at 01:19 AM EST)
5 min read
Source: Dev.to

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 Map to 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 the Map), calls URL.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 pending map.

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 a lazy-picture-wrapper class 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 the src attribute.
  • Loading State – An img-loading CSS 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 AbortController for each load attempt.
  • Lifecycle Cleanup – When the src input changes or the component is destroyed, the controller’s abort() 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 load and error events to ensure the img-loading class is reliably removed regardless of success or failure. These listeners are stored in an unlisteners array 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.

0 views
Back to Blog

Related posts

Read more »