DEV Community

Mastering Image Caching and Lazy Loading in Hybrid Mobile Apps

Building hybrid mobile apps with web technologies provides massive development speed, but it also exposes apps to the quirks of mobile WebViews. One of the most common performance bottlenecks is image loading. WebViews often have unpredictable caching behaviors, leading to unnecessary network requests, flickering UI, and high memory consumption.

To build a truly native-feeling experience, you need to take control of how your application fetches, caches, and renders images. Using the provided Angular code as a blueprint, let's break down a robust strategy for handling images in hybrid applications.

The Problem with Default WebView Caching

When you rely purely on standard <img src="..."> 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. Here is how it operates:

  • Map-Based Storage: It 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, defined as MAX_CACHE_SIZE = 100. When the cache reaches this capacity, it identifies the oldest entry (the first key in the Map), uses URL.revokeObjectURL() to free the browser memory, and deletes it from the cache.

2. Preventing Duplicate Fetches

In lists or feeds, it is common to have the same user avatar appear multiple times on the screen.

  • To solve this, the service maintains a pending Map that tracks ongoing Promise<string> downloads.

  • If an HTTP request is already in flight for a specific URL, subsequent requests for that same URL will hook into the existing promise rather than 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 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 is perfect for hooking up CSS skeleton animations or spinners.

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 instantiates a local AbortController every time it attempts to load a new image source.

  • Lifecycle Cleanup: If the src input changes, or if the component is destroyed (ngOnDestroy), the AbortController is triggered (abort()).

  • Safety Check: After the global cache service resolves the fetched Blob URL, the directive checks signal.aborted. If the signal was 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 entirely, applies the source directly, and immediately removes the loading class.

  • Event Listeners: It listens to native load and error events to ensure the img-loading class is reliably removed regardless of whether the image succeeds or fails. Furthermore, these listeners are tracked in an unlisteners array and cleanly removed when the component is destroyed 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.

Top comments (0)