Flutter Dio Interceptor for Caching API Responses

Published: (January 8, 2026 at 01:02 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

How the Caching Interceptor Works

FeatureDescription
Persist GET responsesOnly GET method responses are cached, as these typically represent data‑retrieval operations that are safe to cache.
Network‑first strategyWhen network connectivity is available, the interceptor fetches fresh data from the API.
Fallback to cacheIf a network error occurs, the interceptor automatically falls back to previously cached responses.
Automatic cache updatesSuccessful responses automatically update the cache for future offline access.

1️⃣ Storage Abstraction Layer

Creating a storage abstraction gives you the flexibility to swap out the underlying storage mechanism (e.g., SharedPreferences, Hive, Secure Storage) without touching the interceptor logic.

/// Simple key‑value storage interface for caching
abstract class CacheStorage {
  Future get(String key);
  Future set(String key, String value);
  Future remove(String key);
}

/// Implementation using SharedPreferences (via AppPreference)
class CacheStorageImpl implements CacheStorage {
  CacheStorageImpl(this._pref);
  final AppPreference _pref;

  @override
  Future get(String key) async => _pref.getString(key);

  @override
  Future set(String key, String value) async =>
      await _pref.setString(key, value);

  @override
  Future remove(String key) async => await _pref.remove(key);
}

2️⃣ Caching Interceptor Implementation

import 'dart:convert';
import 'package:dio/dio.dart';

class CacheInterceptor implements Interceptor {
  final CacheStorage storage;

  CacheInterceptor(this.storage);

  // -------------------------------------------------
  // 1️⃣ onRequest – let the request pass through unchanged
  // -------------------------------------------------
  @override
  void onRequest(
      RequestOptions options, RequestInterceptorHandler handler) {
    handler.next(options);
  }

  // -------------------------------------------------
  // 2️⃣ onResponse – cache successful GET responses
  // -------------------------------------------------
  @override
  void onResponse(
      Response response, ResponseInterceptorHandler handler) {
    if (response.requestOptions.method.toUpperCase() == 'GET') {
      _saveResponseToCache(response);
    }
    handler.next(response);
  }

  // -------------------------------------------------
  // 3️⃣ onError – try to serve cached data on network errors
  // -------------------------------------------------
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    // Attempt 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 the error
        return handler.resolve(cached);
      }
    }

    // Show a “no internet” modal for network‑related errors
    if (_isNetworkError(err)) {
      await NoInternetModalWidget.show();
    }

    // Propagate the original error if no cache is available
    handler.next(err);
  }

  // -------------------------------------------------
  // Helper methods
  // -------------------------------------------------
  bool _isNetworkError(DioException err) {
    return err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.sendTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError;
  }

  Future _getCachedResponse(RequestOptions options) async {
    try {
      final cacheKey = '${options.uri}';
      final cacheEntry = await storage.get(cacheKey);
      if (cacheEntry == null) return null;

      final Map decodedCache = jsonDecode(cacheEntry);
      return Response(
        requestOptions: options,
        data: decodedCache,
        statusCode: 200,
      );
    } catch (_) {
      // Corrupted cache – ignore and let the error propagate
      return null;
    }
  }

  Future _saveResponseToCache(Response response) async {
    final cacheKey = '${response.realUri}';
    final cacheEntry = jsonEncode(response.data);
    await storage.set(cacheKey, cacheEntry);
  }
}

Lifecycle Method Summary

MethodWhat It Does
onRequestPasses the request through unchanged.
onResponseIf the request was a GET, stores the response in the cache using the full URI as the key.
onErrorFor failed GET requests, attempts to retrieve a cached response. If found, resolves the error with the cached data; otherwise, shows a “no internet” modal (for network errors) and propagates the error.

3️⃣ Using the Interceptor with Dio

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),
      ),
    );

    // 1️⃣ Create the cache storage implementation
    final cacheStorage = CacheStorageImpl(appPreference);

    // 2️⃣ Add the cache interceptor
    dio.interceptors.add(CacheInterceptor(cacheStorage));

    // 3️⃣ (Optional) Add other interceptors such as logging, auth, etc.
    dio.interceptors.add(
      LogInterceptor(
        requestBody: true,
        responseBody: true,
      ),
    );
  }
}

Now every GET request made through ApiClient will:

  1. Try the network first – fetch fresh data when possible.
  2. Cache the response – store successful results for offline use.
  3. Fall back to cache – serve the cached data automatically when the network fails.

🎉 Wrap‑Up

  • The CacheInterceptor provides a clean, reusable way to add offline support to any Dio‑based Flutter app.
  • By abstracting storage behind CacheStorage, you can switch from SharedPreferences to Hive, SQLite, or secure storage with a single line of code.
  • The network‑first strategy ensures users always see the most up‑to‑date information when they’re online, while still delivering a functional experience when they’re not.

Transparent Caching with Dio Interceptor

class ProductRepository {
  final ApiClient apiClient;

  ProductRepository(this.apiClient);

  Future> 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.

🔄 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.

Possible Extensions

Add timestamps to determine cache freshness

Future _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);
}

Implement LRU (Least Recently Used) cache eviction

class CacheManager {
  final int maxCacheSize;
  final CacheStorage storage;

  Future evictOldestCache() async {
    // Implementation for removing oldest cached entries
  }
}

Configure different caching behaviors per endpoint

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),
};

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 guarantees fresh data when possible while maintaining functionality during connectivity issues.

Bottom line: This interceptor gives you a solid foundation for building resilient Flutter apps that gracefully handle network instability while delivering an excellent user experience. Start with the basic implementation above and extend it to meet your specific requirements.

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

Happy Coding!

Back to Blog

Related posts

Read more »