API Response Caching
4 min read
Networking & Performance
| Strategy | Read flow | When to use |
|---|---|---|
| Cache-first | Cache → if hit, return; else network → cache | Rarely changing data (config, categories, lookups) |
| Network-first, cache-fallback | Network → success → cache; else fall back to cache | Frequently changing data where freshness matters (feed, messages) |
| Stale-while-revalidate (SWR) | Cache (instantly if present) + revalidate in background → emit new value when ready | Most reads — instant UI + always fresh-ish |
| Time-bounded (max-age) | Cache if not expired; else network | Known 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
| Layer | Pros | Cons |
|---|---|---|
Dio interceptor (dio_cache_interceptor) | Transparent, declarative max-age headers | Less control, harder to reason about per-resource invalidation |
| Repository wrapper | Explicit, testable, fine-grained | More boilerplate |
HTTP layer (server's Cache-Control + on-device HTTP cache) | Closest to standards | Mobile HTTP cache layers are inconsistent |
State manager (Riverpod's keepAlive + autoDispose lifetime) | Co-located with state | Doesn'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
-
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.
-
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'sref.invalidate(p)andref.refresh(p)are good plumbing for this. -
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?"
-
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.