Back to Posts
Jan 7, 2026

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:

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:

  1. Checks if the failed request was a GET method
  2. Attempts to retrieve cached data using the request URI as the key
  3. If cached data exists, resolves the error by returning the cached response using handler.resolve(cached)
  4. 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.


Found this article helpful? Share it with your fellow Flutter developers!

Happy Coding!

Related

© 2026 Roshan Kunwar