Caching network requests in Flutter
By Charlin Joe · 25 August 2025
Network requests can be slow, especially on mobile. Caching reduces latency, saves battery, and makes your app usable offline. Here are the practical patterns for caching in Flutter.
What to cache and for how long
Product catalog, prices: 5-15 minutes (changes occasionally)
User profile: 1 hour (rarely changes)
Search results: 1-5 minutes (freshness matters)
Static content (T&Cs): 24 hours to forever
Auth token: Until expiry (usually 1 hour)
Realtime data (stock): Don't cache (or < 30 seconds)
Dio cache interceptor
dependencies:
dio: ^5.4.3
dio_cache_interceptor: ^3.5.0
dio_cache_interceptor_hive_store: ^3.2.0
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
Future<Dio> buildCachedDio() async {
final cacheDir = await getTemporaryDirectory();
final store = HiveCacheStore(cacheDir.path);
final options = CacheOptions(
store: store,
policy: CachePolicy.request, // Use cache if valid, else request
hitCacheOnErrorExcept: [401, 403], // Use stale cache on errors (except auth errors)
maxStale: const Duration(days: 7), // Max age for stale cache hits
priority: CachePriority.normal,
);
return Dio()
..interceptors.add(DioCacheInterceptor(options: options));
}
Per-request cache control
Override the cache policy for specific requests:
// Force network, bypass cache
final response = await dio.get(
'/orders',
options: buildCacheOptions(const Duration(minutes: 5),
policy: CachePolicy.refresh, // Always fetch from network, update cache
),
);
// Force cache only (offline mode)
final cached = await dio.get(
'/products',
options: buildCacheOptions(const Duration(hours: 1),
policy: CachePolicy.forceCache,
),
);
// No cache for this request
final fresh = await dio.get(
'/account/balance',
options: buildCacheOptions(Duration.zero,
policy: CachePolicy.noCache,
),
);
Manual Hive cache
For more control, cache responses manually:
class ProductRepository {
final _dio = Dio();
final _cache = Hive.box<String>('product_cache');
static const _ttl = Duration(minutes: 10);
Future<List<Product>> getProducts() async {
// Check cache
final cacheKey = 'products';
final timestampKey = '${cacheKey}_timestamp';
final cached = _cache.get(cacheKey);
final timestamp = _cache.get(timestampKey);
if (cached != null && timestamp != null) {
final cacheTime = DateTime.parse(timestamp);
if (DateTime.now().difference(cacheTime) < _ttl) {
return (jsonDecode(cached) as List)
.map((e) => Product.fromJson(e as Map<String, dynamic>))
.toList();
}
}
// Cache miss or expired — fetch from network
final response = await _dio.get('/products');
final products = (response.data as List)
.map((e) => Product.fromJson(e as Map<String, dynamic>))
.toList();
// Update cache
await _cache.put(cacheKey, jsonEncode(response.data));
await _cache.put(timestampKey, DateTime.now().toIso8601String());
return products;
}
Future<void> invalidateProducts() async {
await _cache.delete('products');
await _cache.delete('products_timestamp');
}
}
Stale-while-revalidate
Show cached data immediately, then update from network:
Stream<List<Product>> getProductsStream() async* {
// Yield cached data immediately
final cached = _getCachedProducts();
if (cached != null) yield cached;
// Fetch fresh data
try {
final fresh = await _fetchFromNetwork();
_updateCache(fresh);
yield fresh;
} catch (e) {
// Network failed — cached data already yielded
if (cached == null) rethrow; // Nothing to show
}
}
// In widget:
StreamBuilder<List<Product>>(
stream: repo.getProductsStream(),
builder: (context, snapshot) {
// Shows cached data first, then fresh data when available
if (!snapshot.hasData) return const CircularProgressIndicator();
return ProductList(products: snapshot.data!);
},
)
Image caching
// cached_network_image handles image caching automatically
CachedNetworkImage(
imageUrl: product.imageUrl,
// Limit memory footprint
memCacheWidth: 300,
memCacheHeight: 300,
// Control disk cache
cacheManager: CacheManager(
Config(
'products_images',
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 200,
),
),
)
Riverpod with cache
@riverpod
class ProductsNotifier extends _$ProductsNotifier {
@override
Future<List<Product>> build() async {
// Keep alive so cache persists across navigation
ref.keepAlive();
return ref.read(productRepositoryProvider).getProducts();
}
Future<void> refresh() async {
await ref.read(productRepositoryProvider).invalidateProducts();
ref.invalidateSelf();
}
}
Common pitfalls
Caching POST responses. POST requests should never be cached — they change server state. Only cache GET requests.
Infinite TTL for user data. Caching user profile for 24 hours means a user who updates their name won't see the change for a day. Use short TTLs for user data or invalidate explicitly after updates.
Not invalidating cache after mutations. When a user places an order, the orders list cache should be invalidated so the next fetch shows the new order.
Sign in to like, dislike, or report.