Cover image for blog post: "Building a Smart LRU Cache in Flutter: Dedup, Prefetch, and Memory Pressure"
Back to blog posts

Building a Smart LRU Cache in Flutter: Dedup, Prefetch, and Memory Pressure

A production-grade in-memory cache for Flutter — LRU eviction, in-flight deduplication, adjacent-page prefetching, and OS memory pressure handling. Battle-tested on a 604-page Quran reader.

Published onApril 19, 20265 minutes read

Table of Contents

👋 Hey there,

If your Flutter app is re-decoding the same image, re-parsing the same JSON, or re-querying SQLite for data you just fetched two seconds ago — you need a cache. Not package:cached_network_image, not a Map<K, V> sitting on a StatefulWidget. You need a real one, with eviction, deduplication, and a plan for when iOS screams didReceiveMemoryWarning.

This post walks through the cache layer I built for a Quran reader — 604 high-resolution page images, glyph hit-testing, surah names — and why each layer exists. Everything here is plain Dart + one Flutter binding. No dependencies.

Why Map<K, V> is not a cache

A plain Map has three problems:

The fix is a three-layer stack: an LRU cache at the bottom, a deduplicating service on top of it, and a page-aware prefetcher on top of that.

Layer 1 — An LRU cache backed by LinkedHashMap

The trick most people miss: Dart’s LinkedHashMap already maintains insertion order. That’s 80% of an LRU. To mark a key as “recently used,” you remove it and re-insert — it jumps to the end. Evict by popping .keys.first.

class Cache<K, V> {
  final int countLimit;
  final String? name;
  final LinkedHashMap<K, V> _cache = LinkedHashMap<K, V>();
 
  Cache({this.countLimit = 50, this.name});
 
  V? object(K key) {
    final value = _cache.remove(key);
    if (value != null) {
      _cache[key] = value; // move to end = most recently used
    }
    return value;
  }
 
  void setObject(V value, K key) {
    _cache.remove(key);
    while (_cache.length >= countLimit && _cache.isNotEmpty) {
      _cache.remove(_cache.keys.first); // evict oldest
    }
    _cache[key] = value;
  }
}

A few things that look small but matter:

That’s the foundation. Everything else sits on top.

Layer 2 — Deduplicating in-flight operations

Here’s a real bug I shipped once: the user scrolled, we started fetching page 42. Before it finished, they scrolled back and triggered page 42 again. Two SQLite queries, two JSON decodes, two setState calls. On a slow device, the second overwrote the first’s UI update with identical data — burning frames for nothing.

The fix is a Completer map keyed by the same key as the cache:

class OperationCacheableService<I, O> {
  final Cache<I, O> cache;
  final CacheableOperation<I, O> operation;
  final Map<I, Completer<O>> _inProgressOperations = {};
 
  Future<O> get(I input) async {
    final cached = cache.object(input);
    if (cached != null) return cached;
 
    // Already fetching? Wait for that one.
    if (_inProgressOperations.containsKey(input)) {
      return _inProgressOperations[input]!.future;
    }
 
    final completer = Completer<O>();
    _inProgressOperations[input] = completer;
 
    try {
      final result = await operation(input);
      cache.setObject(result, input);
      completer.complete(result);
      return result;
    } catch (e) {
      completer.completeError(e);
      rethrow;
    } finally {
      _inProgressOperations.remove(input);
    }
  }
}

Three callers asking for page 42 concurrently now do exactly one operation and three awaits. The finally is load-bearing — without it, a thrown error leaves a poisoned completer in the map forever.

⚠️ This is single-isolate safe only. If you call it from a background isolate, you need Isolate.run boundaries or a port-based protocol. The Completer lives on one heap.

Layer 3 — Page-aware prefetching

Reading a book is predictable: if the user is on page 42, they’re probably headed to 43 next, maybe 44 after that. A smart cache exploits that:

Future<O> get(int pageNumber) async {
  final result = await _service.get(pageNumber);
  _prefetchAdjacentPages(pageNumber); // fire-and-forget
  return result;
}
 
void _prefetchAdjacentPages(int currentPage) {
  for (int i = 1; i <= nextPagesCount; i++) {
    final nextPage = currentPage + i;
    if (nextPage <= totalPages && _service.getCached(nextPage) == null) {
      unawaited(_service.get(nextPage).catchError((e) {
        debugPrint('

Two things worth calling out:

I default to 2 previous + 3 next pages. That’s asymmetric because most reading goes forward. Tune it for your app — a left-to-right reader flips the numbers.

Layer 4 — Memory pressure is not optional

Here’s the thing nobody talks about: iOS will kill your app if you ignore memory warnings, and Android will do it with even less ceremony. Flutter gives you a hook; use it.

class QuranCacheManager with WidgetsBindingObserver {
  static QuranCacheManager? _instance;
  static QuranCacheManager get instance => _instance ??= QuranCacheManager._();
 
  QuranCacheManager._() {
    WidgetsBinding.instance.addObserver(this);
  }
 
  @override
  void didHaveMemoryPressure() {
    debugPrint('

didHaveMemoryPressure fires when the OS signals the app is close to being killed. Clearing the cache here is the difference between a slight scroll stutter and a process termination.

A few rules I learned the hard way:

Putting it together — the singleton manager

The public API ends up small:

final mgr = QuranCacheManager.instance;
mgr.initialize(loadPageImageInfo: _loadFromDisk);
 
// Hot path — called every frame during scroll
final info = await mgr.getPageImageInfo(42);
 
// Synchronous peek — won't trigger a load
final cached = mgr.getCachedPageImageInfo(42);
 
// Paint glyphs, cache the hit-test data
mgr.cacheGlyphs(42, glyphs);

Three independent caches (page images, glyphs, surah names) share one memory-pressure observer. When the signal comes, all three drop in one call.

Common pitfalls

1. Caching null. If your operation can legitimately return null, wrap it in a sentinel (Optional, Result.empty()) before caching. Otherwise every call becomes a cache miss.

2. Forgetting to unawaited prefetch. The analyzer warns, but more importantly, letting an exception escape a fire-and-forget future crashes in release. Always .catchError.

3. Singleton + hot reload. Hot reload re-runs main() but keeps the old heap. Your _instance can point to a QuranCacheManager whose observer was never re-registered against the new WidgetsBinding. Add a reset() for tests, and accept a dev-mode wart.

4. Using this across isolates. Completer, LinkedHashMap, and WidgetsBinding are all isolate-local. If you move the operation to a background isolate (correct, for heavy decoding), the cache stays on the UI isolate and the background isolate returns results over a port.

5. Cache invalidation on content change. If the user switches mushaf (or your content source changes at all), call clearAll(). Stale pages with correct-looking keys are the worst class of bug.

Wrapping up

A good cache is three things stacked:

And one thing over the top: memory-pressure awareness so the OS doesn’t kill you for hoarding.

None of this is Quran-specific. Swap PageImageInfo for Uint8List, User, or ParsedMarkdown and you’ve got the same layer for any repeated async load. The whole thing is ~250 lines of Dart — no dependencies, no magic, no dragons.

Start by replacing one Map<K, V> with the LRU. Then wrap your loader in OperationCacheableService. Then — and only then — look at prefetching. Build it in layers; debug it in layers.