Caching
Storing data locally for faster access, reduced network usage, and offline support.
Caching is the process of storing copies of data in a local storage layer so that future requests for that data can be served faster and with less network usage . It's essential for:
- Performance – Faster data access (reading from disk or memory is faster than network)
- Bandwidth – Reduces data usage and API calls
- Offline support – Users can access data without an internet connection
- User experience – No loading spinners for cached content
🎯 The Caching Hierarchy
- Memory Cache – Fastest, but cleared when app restarts
- Disk Cache – Slower than memory, but persists across app restarts
- Network – Slowest, but always fresh
There are three main caching strategies used in Flutter apps:
A memory cache stores data in a
Map
or
Cache
object. It's fast but cleared when the app restarts.
class MemoryCache<K, V> {
final Map<K, V> _cache = {};
final int maxSize;
MemoryCache({this.maxSize = 100});
void put(K key, V value) {
if (_cache.length >= maxSize) {
// Simple LRU: remove the first entry
final firstKey = _cache.keys.first;
_cache.remove(firstKey);
}
_cache[key] = value;
}
V? get(K key) {
return _cache[key];
}
bool containsKey(K key) {
return _cache.containsKey(key);
}
void remove(K key) {
_cache.remove(key);
}
void clear() {
_cache.clear();
}
int get size => _cache.length;
}
import 'memory_cache.dart';
class AlbumCache {
final MemoryCache<String, Map<String, dynamic>> _cache = MemoryCache();
// Cache key with a timeout
static const Duration cacheDuration = Duration(minutes: 5);
void put(String id, Map<String, dynamic> album) {
_cache.put(id, album);
}
Map<String, dynamic>? get(String id) {
return _cache.get(id);
}
bool has(String id) {
return _cache.containsKey(id);
}
void clear() {
_cache.clear();
}
}
The cache_manager package provides a comprehensive caching solution with disk persistence and HTTP caching support.
📦 cache_manager Features
- Disk caching – Persist data to local storage
- HTTP caching – Respects Cache-Control headers
- Customizable – Configure cache duration and size
- Stream support – Works with images and files
- Offline-first – Serve cached data when offline
dependencies:
flutter_cache_manager: ^3.4.0
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class CustomCacheManager extends BaseCacheManager {
static const String key = 'customCache';
static CustomCacheManager? _instance;
CustomCacheManager._()
: super(
key,
maxAgeCacheObject: Duration(days: 7),
maxNrOfCacheObjects: 100,
repo: CacheObjectRepository(),
);
static CustomCacheManager get instance {
_instance ??= CustomCacheManager._();
return _instance!;
}
}
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../cache/cache_service.dart';
class ApiService {
final http.Client client;
final CustomCacheManager cacheManager;
ApiService({http.Client? client, CustomCacheManager? cacheManager})
: client = client ?? http.Client(),
cacheManager = cacheManager ?? CustomCacheManager.instance;
// Method 1: Using cache for GET requests
Future<Map<String, dynamic>> fetchDataWithCache(String url) async {
// Check cache first
final cachedFile = await cacheManager.getFileFromCache(url);
if (cachedFile != null) {
final content = await cachedFile.file.readAsString();
return jsonDecode(content) as Map<String, dynamic>;
}
// Fetch from network if not cached
final response = await client.get(
Uri.parse(url),
headers: {'Accept': 'application/json'},
);
if (response.statusCode == 200) {
// Save to cache
await cacheManager.putFile(url, response.bodyBytes, key: url);
return jsonDecode(response.body) as Map<String, dynamic>;
} else {
throw Exception('Failed to fetch data: ${response.statusCode}');
}
}
// Method 2: Get data (with offline support)
Future<Map<String, dynamic>> fetchDataWithOfflineSupport(String url) async {
try {
// Try network first
final response = await client.get(
Uri.parse(url),
headers: {'Accept': 'application/json'},
);
if (response.statusCode == 200) {
// Update cache with fresh data
await cacheManager.putFile(url, response.bodyBytes, key: url);
return jsonDecode(response.body) as Map<String, dynamic>;
} else {
// If network fails, try cache
final cachedFile = await cacheManager.getFileFromCache(url);
if (cachedFile != null) {
final content = await cachedFile.file.readAsString();
return jsonDecode(content) as Map<String, dynamic>;
}
throw Exception('No data available offline');
}
} catch (e) {
// If network fails, fall back to cache
final cachedFile = await cacheManager.getFileFromCache(url);
if (cachedFile != null) {
final content = await cachedFile.file.readAsString();
return jsonDecode(content) as Map<String, dynamic>;
}
throw Exception('Network error and no cached data available');
}
}
// Clear cache
Future<void> clearCache() async {
await cacheManager.emptyCache();
}
}
An offline-first strategy ensures your app works even without an internet connection by serving cached data when the network is unavailable.
import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class OfflineService {
final http.Client client;
final String cacheKeyPrefix = 'cached_data_';
OfflineService({http.Client? client}) : client = client ?? http.Client();
Future<Map<String, dynamic>> fetchData(String url) async {
final connectivityResult = await Connectivity().checkConnectivity();
// If we have internet, fetch fresh data
if (connectivityResult != ConnectivityResult.none) {
return _fetchFromNetwork(url);
} else {
// If no internet, try to load from cache
return _loadFromCache(url);
}
}
Future<Map<String, dynamic>> _fetchFromNetwork(String url) async {
final response = await client.get(
Uri.parse(url),
headers: {'Accept': 'application/json'},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Save to cache for offline use
await _saveToCache(url, data);
return data;
} else {
// If network returns error, fall back to cache
return _loadFromCache(url);
}
}
Future<Map<String, dynamic>> _loadFromCache(String url) async {
final prefs = await SharedPreferences.getInstance();
final cacheKey = '$cacheKeyPrefix$url';
final cachedData = prefs.getString(cacheKey);
if (cachedData != null) {
return jsonDecode(cachedData) as Map<String, dynamic>;
} else {
throw Exception('No cached data available');
}
}
Future<void> _saveToCache(String url, Map<String, dynamic> data) async {
final prefs = await SharedPreferences.getInstance();
final cacheKey = '$cacheKeyPrefix$url';
await prefs.setString(cacheKey, jsonEncode(data));
}
// Clear all cached data
Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys().where((key) => key.startsWith(cacheKeyPrefix));
for (var key in keys) {
await prefs.remove(key);
}
}
}
dependencies:
connectivity_plus: ^5.0.0
shared_preferences: ^2.2.0
Here's a complete app that uses caching to display posts with offline support.
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
class CachedPostService {
final http.Client client;
final String cacheKey = 'cached_posts';
static const Duration cacheDuration = Duration(minutes: 10);
CachedPostService({http.Client? client}) : client = client ?? http.Client();
Future<List<dynamic>> getPosts() async {
final prefs = await SharedPreferences.getInstance();
// Check cache first
final cachedData = prefs.getString(cacheKey);
final cacheTime = prefs.getInt('${cacheKey}_time');
// If cache exists and is not expired, return it
if (cachedData != null && cacheTime != null) {
final elapsed = DateTime.now().millisecondsSinceEpoch - cacheTime;
if (elapsed < cacheDuration.inMilliseconds) {
return jsonDecode(cachedData) as List<dynamic>;
}
}
// Cache expired or doesn't exist, fetch from network
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
headers: {'Accept': 'application/json'},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List<dynamic>;
// Save to cache
await prefs.setString(cacheKey, jsonEncode(data));
await prefs.setInt(
'${cacheKey}_time',
DateTime.now().millisecondsSinceEpoch,
);
return data;
} else {
// If network fails, try to return stale cache
if (cachedData != null) {
return jsonDecode(cachedData) as List<dynamic>;
}
throw Exception('Failed to load posts and no cache available');
}
}
Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(cacheKey);
await prefs.remove('${cacheKey}_time');
}
Future<void> refreshCache() async {
// Force refresh by clearing cache and fetching
await clearCache();
await getPosts();
}
}
class PostsScreen extends StatefulWidget {
@override
State<PostsScreen> createState() => _PostsScreenState();
}
class _PostsScreenState extends State<PostsScreen> {
final CachedPostService _service = CachedPostService();
List<dynamic> _posts = [];
bool _isLoading = true;
bool _hasError = false;
String? _errorMessage;
bool _isFromCache = false;
@override
void initState() {
super.initState();
_loadPosts();
}
Future<void> _loadPosts() async {
setState(() {
_isLoading = true;
_hasError = false;
_errorMessage = null;
});
try {
final posts = await _service.getPosts();
setState(() {
_posts = posts;
_isLoading = false;
// In a real app, you'd detect if data came from cache
_isFromCache = true;
});
} catch (e) {
setState(() {
_isLoading = false;
_hasError = true;
_errorMessage = e.toString();
});
}
}
Future<void> _refresh() async {
await _service.refreshCache();
await _loadPosts();
}
Future<void> _clearCache() async {
await _service.clearCache();
await _loadPosts();
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Cache cleared')));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('📦 Cached Posts'),
centerTitle: true,
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: _refresh,
tooltip: 'Refresh',
),
IconButton(
icon: Icon(Icons.delete_sweep),
onPressed: _clearCache,
tooltip: 'Clear Cache',
),
],
),
body: _buildContent(),
);
}
Widget _buildContent() {
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading posts...'),
Text(
'Checking cache...',
style: TextStyle(color: Colors.grey.shade400),
),
],
),
);
}
if (_hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(
'Unable to load posts',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
_errorMessage ?? 'An error occurred',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
SizedBox(height: 16),
ElevatedButton(onPressed: _loadPosts, child: Text('Retry')),
],
),
);
}
return Column(
children: [
// Cache status indicator
Container(
padding: EdgeInsets.symmetric(vertical: 8),
color: _isFromCache ? Colors.green.shade50 : Colors.transparent,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_isFromCache ? Icons.check_circle : Icons.cloud,
size: 16,
color: _isFromCache ? Colors.green : Colors.blue,
),
SizedBox(width: 4),
Text(
_isFromCache
? '${_posts.length} posts (cached)'
: '${_posts.length} posts',
style: TextStyle(
fontSize: 12,
color: _isFromCache
? Colors.green.shade700
: Colors.grey.shade600,
),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: _posts.length,
itemBuilder: (context, index) {
final post = _posts[index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: CircleAvatar(child: Text((index + 1).toString())),
title: Text(
post['title'] ?? 'No title',
style: TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
post['body'] ?? 'No body',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
},
),
),
],
);
}
}
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Cached Posts',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: PostsScreen(),
);
}
}
SharedPreferences
,
Hive
,
or
cache_manager
for disk caching.
✅ Do's
- Cache data that is frequently accessed and doesn't change often
- Set appropriate cache expiration times (TTL)
- Handle offline scenarios gracefully
- Provide a way to refresh or clear the cache
-
Use
cache_managerfor HTTP caching - Store cache keys consistently
- Show cache status to users (e.g., "Cached" badge)
❌ Don'ts
- Don't cache sensitive data without encryption
- Don't cache indefinitely (always set a TTL)
- Don't ignore cache expiration
- Don't cache large files without size limits
- Don't forget to clear cache when logging out
Without a TTL (Time To Live), cached data can become stale and show outdated information.
Always set a reasonable expiration time based on how often the data changes.
Not all data should be cached. Only cache frequently accessed, static, or semi-static data.
Cache data that provides value when offline. Avoid caching user-specific or sensitive data.
If cached data is corrupted or malformed, the app should fall back gracefully.
Use try-catch when reading from cache and fall back to network requests if it fails.
🎯 Key Takeaway
Caching is essential for performance , offline support , and reduced network usage . Use cache_manager for comprehensive caching solutions or SharedPreferences for simple key-value storage. Always set expiration times and handle cache read errors gracefully.