DEV Community

Roshan Jung Kunwar
Roshan Jung Kunwar

Posted on

Flutter Dio Interceptor for Caching API Responses

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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}';
Enter fullscreen mode Exit fullscreen mode

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,
    ));
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When network is available, this fetches fresh data and caches it. When offline, it returns the cached response without throwing an error.

Key Benefits

<h3>πŸ”„ Transparent Caching</h3>
<p>No changes required to existing API calls - caching works automatically</p>



<h3>πŸ“΄ Offline Resilience</h3>
<p>Automatic fallback to cached data during network failures</p>



<h3>🎯 Clean Architecture</h3>
<p>Separation of concerns through storage abstraction</p>



<h3>πŸ”Œ Easy Integration</h3>
<p>Simple integration with dependency injection patterns</p>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
};
Enter fullscreen mode Exit fullscreen mode

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!

Find full article on:
https://www.roshankunwar.com.np/posts/flutter-dio-interceptor-for-caching-api-responses

Top comments (0)