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.