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
Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations
Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.
Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity
Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.
Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps
Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.