Flutter Dio Interceptor for Caching API Responses
In modern mobile applications, providing a seamless user experience even with poor or no network connectivity is crucial. This article demonstrates how to implement a robust caching mechanism for API responses in Flutter using Dio interceptors, ensuring your app remains functional offline by serving cached data when network requests fail.
Mechanism
The caching interceptor works with a straightforward yet effective approach:
- Persists GET responses: Only GET method responses are cached, as these typically represent data retrieval operations that are safe to cache
- Network-first strategy: When network connectivity is available, the interceptor fetches fresh data from the API
- Fallback to cache: If a network error occurs, the interceptor automatically falls back to previously cached responses
- Automatic cache updates: Successful responses automatically update the cache for future offline access
Development
Creating the Custom Interceptor
To implement caching with Dio, we need to create a custom interceptor that implements the Interceptor interface and handles three key lifecycle methods: onRequest, onResponse, and onError.
Storage Abstraction
First, let’s define a storage abstraction layer that allows flexibility in choosing different storage mechanisms:
/// Simple key-value storage interface for caching
abstract class CacheStorage {
Future<String?> get(String key);
Future<void> set(String key, String value);
Future<void> remove(String key);
}
/// Implementation using SharedPreferences (via AppPreference)
class CacheStorageImpl implements CacheStorage {
CacheStorageImpl(this._pref);
final AppPreference _pref;
@override
Future<String?> get(String key) async {
return _pref.getString(key);
}
@override
Future<void> set(String key, String value) async {
await _pref.setString(key, value);
}
@override
Future<void> remove(String key) async {
await _pref.remove(key);
}
}This abstraction provides a clean interface for storage operations, making it easy to swap implementations (e.g., from SharedPreferences to Hive or secure storage) without changing the interceptor code.
The Cache Interceptor
Here’s the complete implementation of the caching interceptor:
import 'dart:convert';
import 'package:dio/dio.dart';
class CacheInterceptor implements Interceptor {
final CacheStorage storage;
CacheInterceptor(this.storage);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// Pass through all requests without modification
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Cache only GET responses
if (response.requestOptions.method.toUpperCase() == 'GET') {
_saveResponseToCache(response);
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Try to retrieve cached data for GET requests
if (err.requestOptions.method.toUpperCase() == 'GET') {
final cached = await _getCachedResponse(err.requestOptions);
if (cached != null) {
// Return cached response instead of error
return handler.resolve(cached);
}
}
// Show no internet modal for network errors
if (_isNetworkError(err)) {
await NoInternetModalWidget.show();
}
// Propagate the error if no cache available
handler.next(err);
}
bool _isNetworkError(DioException err) {
return err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.connectionError;
}
Future<Response?> _getCachedResponse(RequestOptions options) async {
try {
final cacheKey = '${options.uri}';
final cacheEntry = await storage.get(cacheKey);
if (cacheEntry == null) return null;
final Map<String, dynamic> decodedCache = jsonDecode(cacheEntry);
return Response(
requestOptions: options,
data: decodedCache,
statusCode: 200,
);
} catch (_) {
return null;
}
}
Future<void> _saveResponseToCache(Response response) async {
final cacheKey = '${response.realUri}';
final cacheEntry = jsonEncode(response.data);
await storage.set(cacheKey, cacheEntry);
}
}How It Works
1. Request Phase (onRequest)
The interceptor doesn’t modify outgoing requests, allowing them to proceed normally to the API endpoint.
2. Response Phase (onResponse)
When a successful response arrives, the interceptor checks if it’s a GET request. If so, it saves the response to cache using the complete URI as the cache key:
final cacheKey = '${response.realUri}';This ensures each unique endpoint has its own cached response, preventing data conflicts.
3. Error Handling Phase (onError)
This is where the magic happens. When a request fails, the interceptor:
- Checks if the failed request was a GET method
- Attempts to retrieve cached data using the request URI as the key
- If cached data exists, resolves the error by returning the cached response using
handler.resolve(cached) - If no cache exists, propagates the original error using
handler.next(err)
The _getCachedResponse method safely handles JSON decoding and returns null if anything goes wrong, ensuring the app doesn’t crash due to corrupted cache data.
Usage
Injecting the Interceptor into Dio
To use the cache interceptor, inject it when creating your Dio instance:
import 'package:dio/dio.dart';
class ApiClient {
late final Dio dio;
ApiClient(AppPreference appPreference) {
dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
),
);
// Create cache storage implementation
final cacheStorage = CacheStorageImpl(appPreference);
// Add the cache interceptor
dio.interceptors.add(CacheInterceptor(cacheStorage));
// Add other interceptors as needed (logging, auth, etc.)
dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
));
}
}Example API Call
Once configured, your API calls work normally, with caching happening transparently:
class ProductRepository {
final ApiClient apiClient;
ProductRepository(this.apiClient);
Future<List<Product>> getProducts() async {
try {
final response = await apiClient.dio.get('/products');
return (response.data as List)
.map((json) => Product.fromJson(json))
.toList();
} catch (e) {
// Handle error or rethrow
rethrow;
}
}
}When network is available, this fetches fresh data and caches it. When offline, it returns the cached response without throwing an error.
Key Benefits
🔄 Transparent Caching
No changes required to existing API calls - caching works automatically
📴 Offline Resilience
Automatic fallback to cached data during network failures
🎯 Clean Architecture
Separation of concerns through storage abstraction
🔌 Easy Integration
Simple integration with dependency injection patterns
Advanced Enhancements
Consider extending this implementation with the following features:
Cache Expiration
Add timestamps to determine cache freshness:
Future<void> _saveResponseToCache(Response response) async {
final cacheKey = '${response.realUri}';
final cacheData = {
'data': response.data,
'timestamp': DateTime.now().millisecondsSinceEpoch,
};
final cacheEntry = jsonEncode(cacheData);
await storage.set(cacheKey, cacheEntry);
}Cache Size Management
Implement LRU (Least Recently Used) cache eviction:
class CacheManager {
final int maxCacheSize;
final CacheStorage storage;
Future<void> evictOldestCache() async {
// Implementation for removing oldest cached entries
}
}Per-Endpoint Cache Strategies
Configure different caching behaviors for different endpoints:
class CacheConfig {
final Duration? ttl;
final bool enabled;
CacheConfig({this.ttl, this.enabled = true});
}
// Usage
final cacheConfig = {
'/products': CacheConfig(ttl: Duration(hours: 1)),
'/user/profile': CacheConfig(enabled: false),
};Conclusion
Implementing a caching interceptor for Dio provides significant benefits for Flutter applications. This approach offers a robust offline-first experience where users can continue accessing previously loaded content even without internet connectivity.
The storage abstraction pattern ensures flexibility and maintainability, while the interceptor seamlessly handles the complexity of cache management without cluttering your business logic. The network-first strategy ensures users always get fresh data when possible while maintaining functionality during connectivity issues.
This interceptor provides a solid foundation for building resilient Flutter applications that gracefully handle network instability while maintaining excellent user experience. Start with this basic implementation and extend it based on your specific application requirements.