← 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's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.