Flutter's Offline-First Strategy: Caching Network Images and API Requests for Seamless User Experience
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
Building a Flutter app that works flawlessly without an internet connection isn’t just a nice-to-have—it’s a cornerstone of a professional user experience. Users expect to scroll through previously loaded content, view cached images, and queue actions for later when connectivity is poor. An app that shows blank spaces or throws errors the moment connectivity drops feels broken. Let’s implement a solid offline-first strategy, focusing on two critical areas: caching images and persisting API requests.
The Core Problem: Ephemeral Data
By default, network operations in Flutter are ephemeral. A NetworkImage vanishes from memory when it’s no longer on screen and fails to load if you’re offline. An HTTP request made with http or dio throws an exception if the device is disconnected. This forces users into a frustrating cycle of retries and lost data. The solution is to cache assets locally and queue network operations.
Caching Network Images Effortlessly
The easiest way to cache images is using the cached_network_image package. It handles downloading, storing to the device’s cache directory, and retrieving images seamlessly. It’s a drop-in replacement for Image.network.
First, add the dependency to your pubspec.yaml:
dependencies:
cached_network_image: ^3.3.0
Then, use it in your UI:
import 'package:cached_network_image/cached_network_image.dart';
CachedNetworkImage(
imageUrl: 'https://picsum.photos/250?image=9',
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
),
The image will be downloaded once and subsequently loaded from the local cache, even when offline. The package manages cache expiration and size under the hood. For more control, you can customize the cache manager:
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// Create a custom cache manager with a longer max age
final customCacheManager = CacheManager(
Config(
'my-app-cache-key',
maxNrOfCacheObjects: 200,
stalePeriod: Duration(days: 30),
),
);
// Use it in your image
CachedNetworkImage(
cacheManager: customCacheManager,
imageUrl: 'https://example.com/image.jpg',
);
Persisting API Requests for Offline Sync
Caching images solves one part of the puzzle. The next challenge is handling data mutations—like submitting a form or sending a message—while offline. The strategy is to store the request locally when a network failure is detected and retry it when connectivity is restored.
While you can build this from scratch using sqflite or hive and a connectivity listener, it involves significant boilerplate. A more practical approach is to use a package designed for this pattern, like dio with interceptors.
Here’s a conceptual implementation using dio and a simple queue to illustrate the pattern:
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:hive/hive.dart';
class OfflineRequestQueue {
late Box<Map> _requestBox;
final Dio _dio = Dio();
final Connectivity _connectivity = Connectivity();
Future<void> init() async {
final dir = await getApplicationDocumentsDirectory();
Hive.init(dir.path);
_requestBox = await Hive.openBox<Map>('pending_requests');
_startMonitoring();
}
// Store a failed request
Future<void> _storeRequest(RequestOptions options, dynamic data) async {
await _requestBox.add({
'method': options.method,
'url': options.path,
'data': data,
'headers': options.headers,
});
}
// Retry all pending requests
Future<void> _retryPendingRequests() async {
for (var request in _requestBox.values) {
try {
await _dio.request(
request['url'],
data: request['data'],
options: Options(method: request['method'], headers: request['headers']),
);
// Remove on success
await request.delete();
} catch (e) {
print('Retry failed for $request: $e');
}
}
}
// Monitor connectivity changes
void _startMonitoring() {
_connectivity.onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
_retryPendingRequests();
}
});
}
// Wrapper for making API calls
Future<Response> fetch(String url) async {
try {
return await _dio.get(url);
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionError) {
// Store the GET request for retry
await _storeRequest(e.requestOptions, null);
// Return a placeholder for the UI
return Response(
requestOptions: e.requestOptions,
data: {'status': 'queued'},
);
}
rethrow;
}
}
}
This is a simplified skeleton. In a production app, you’d need to handle request serialization more carefully, manage IDs for updates, and potentially sync with a backend that supports conflict resolution.
Common Pitfalls to Avoid
- Ignoring Cache Size: Unbounded image caching can fill the user’s storage. Use
maxNrOfCacheObjectsandstalePeriodto manage this. - Over-Queueing: Don’t queue every failed request. Some, like simple GETs for fresh data, should just fail gracefully. Focus on user actions (POST, PUT, DELETE).
- Blocking the UI: The sync process should happen in the background. Use isolates or ensure your queue operations are non-blocking.
- Forgetting the User Experience: Always provide UI feedback. Show a banner (“No internet – changes will sync when you’re back online”) or an indicator on queued items.
The Final Touch: A Seamless Experience
Combine these two techniques—reliable image caching and a persistent request queue—and your app becomes resilient and user-centric. Users can browse cached content and take actions, knowing the app will handle the logistics when connectivity returns. This offline-first approach is what separates a good app from a great one.
This blog is produced with the assistance of AI by a human editor. Learn more
Related Posts
Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations
Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.
Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues
Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.
Solving Flutter Web Memory Leaks: A Practical Guide to Identifying and Fixing Performance Issues
Flutter Web applications can suffer from increasing memory usage over time, leading to performance degradation. This post will delve into common causes of memory leaks in Flutter Web, provide practical debugging techniques using browser developer tools and Dart DevTools, and offer actionable strategies to identify and fix these issues for a smoother user experience.