← Back to posts Cover image for Flutter's Offline-First Strategy: Caching Network Images and API Requests for Seamless User Experience

Flutter's Offline-First Strategy: Caching Network Images and API Requests for Seamless User Experience

· 4 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

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

  1. Ignoring Cache Size: Unbounded image caching can fill the user’s storage. Use maxNrOfCacheObjects and stalePeriod to manage this.
  2. 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).
  3. Blocking the UI: The sync process should happen in the background. Use isolates or ensure your queue operations are non-blocking.
  4. 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

Cover image for Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.