API Response Caching

Medium PriorityAsked in ~50% of senior interviews

4 min read

Networking & Performance

StrategyRead flowWhen to use
Cache-firstCache → if hit, return; else network → cacheRarely changing data (config, categories, lookups)
Network-first, cache-fallbackNetwork → success → cache; else fall back to cacheFrequently changing data where freshness matters (feed, messages)
Stale-while-revalidate (SWR)Cache (instantly if present) + revalidate in background → emit new value when readyMost reads — instant UI + always fresh-ish
Time-bounded (max-age)Cache if not expired; else networkKnown freshness windows ("config valid for 1h")

For mutable resources, also think about invalidation: when do you drop entries? Usually on writes, on logout, or via TTL.


Code in action — max-age + offline fallback

class CachedApi {
  CachedApi(this._dio, this._box);

  final Dio _dio;
  final Box<Map> _box;

  Future<T> get<T>(
    String path, {
    required T Function(Map<String, dynamic>) fromJson,
    Duration maxAge = const Duration(minutes: 5),
    bool forceRefresh = false,
  }) async {
    final key = path;

    // 1. Try fresh cache
    final entry = _box.get(key)?._toEntry();
    if (!forceRefresh && entry != null && !entry.isExpired(maxAge)) {
      return fromJson(entry.data);
    }

    // 2. Network
    try {
      final res = await _dio.get(path);
      await _box.put(key, _Entry(res.data, DateTime.now()).toMap());
      return fromJson(res.data);
    } on DioException {
      // 3. Offline / failed → stale cache
      if (entry != null) return fromJson(entry.data);
      rethrow;
    }
  }
}

class _Entry {
  _Entry(this.data, this.ts);
  final Map<String, dynamic> data;
  final DateTime ts;
  bool isExpired(Duration max) => DateTime.now().difference(ts) > max;
  Map<String, dynamic> toMap() => {'data': data, 'ts': ts.toIso8601String()};
}
extension on Map { _Entry _toEntry() => _Entry(
  Map<String, dynamic>.from(this['data']),
  DateTime.parse(this['ts']),
); }

Stale-while-revalidate with a Stream

Stream<List<Post>> watchPosts() async* {
  final cached = await _box.get('posts');
  if (cached != null) yield cached.toList();           // emit cached immediately

  try {
    final fresh = await _api.fetchPosts();
    await _box.put('posts', fresh);
    yield fresh;                                        // emit refresh
  } catch (_) {
    if (cached == null) rethrow;                        // no cache, surface error
  }
}

In Riverpod, this maps to a StreamProvider so the UI shows cached data instantly and switches to fresh when ready.


Where to put caching

LayerProsCons
Dio interceptor (dio_cache_interceptor)Transparent, declarative max-age headersLess control, harder to reason about per-resource invalidation
Repository wrapperExplicit, testable, fine-grainedMore boilerplate
HTTP layer (server's Cache-Control + on-device HTTP cache)Closest to standardsMobile HTTP cache layers are inconsistent
State manager (Riverpod's keepAlive + autoDispose lifetime)Co-located with stateDoesn't survive app restart

Most production apps end up with repository-level caching for control + Riverpod's in-memory caching for the same session.


Common mistakes to avoid

❌ No invalidation strategy
   Cache grows forever; user sees stale data after they edit a record.
   ✅ Invalidate on writes (e.g., updateUser → invalidate user cache).

❌ Caching everything aggressively
   Sensitive endpoints (auth, payments) shouldn't cache. PII shouldn't sit in
   plaintext on disk forever.
   ✅ Be explicit about what's cached; cache-safe endpoints only.

❌ Not falling back to stale cache on network failure
   User loses connectivity → app appears broken instead of showing the last data.
   ✅ Always try stale cache before throwing.

❌ Hashing the URL but ignoring query params / headers
   /users/1?lang=en and /users/1?lang=es are different responses.
   ✅ Include query params in the cache key.

❌ Storing decoded model objects directly
   Models change shape; deserialising old cached entries crashes.
   ✅ Cache raw JSON, deserialise on read (or version the cache).

❌ One global TTL for all endpoints
   Currency rates need 1h; categories need 24h; live feed needs 30s.
   ✅ Per-resource maxAge.

Interview follow-ups

  1. What's stale-while-revalidate and why is it so popular? It serves the cached response immediately AND fires a background request to revalidate. The user sees content instantly (UX win); the cache updates seamlessly. Drawback: the screen may briefly show stale data before swapping to fresh — usually invisible, occasionally jarring.

  2. How do you invalidate cache after a mutation? Two patterns. (1) Tagging: each cache entry has tags (e.g., user:1), mutations clear matching tags. (2) Optimistic update: write the new value to the cache before the server confirms, roll back on failure. Riverpod's ref.invalidate(p) and ref.refresh(p) are good plumbing for this.

  3. When should you NOT cache? Authentication endpoints, payments, anything with PII you don't want sitting on disk, anything that mutates frequently and where stale data has real consequences. Always think: "what's the worst-case if a user sees yesterday's value here?"

  4. What's the difference between in-memory cache (Riverpod) and disk cache (Hive)? In-memory survives within a session — clears on app restart. Disk survives restarts (and works offline). Pair them: in-memory for hot paths (sub-millisecond), disk for cold restart and offline. The repository checks memory → disk → network in order.


How helpful was this content?

Please sign in to rate this article.