Networking & REST API Integration

Caching Responses for Offline Reading

16 min Lesson 11 of 13

Caching Responses for Offline Reading

A production-quality Flutter app does not show a blank screen or a spinner every time it launches. Instead, it immediately displays the last known data from a local cache and then fetches fresh data in the background — a pattern called stale-while-revalidate. This lesson teaches how to implement that pattern using shared_preferences (for small JSON payloads) or Hive (for larger, structured data), so users can read content even when the device is fully offline.

Why Caching Matters

Without a cache, every app launch incurs a network round-trip before anything useful is visible. This leads to three common pain points:

  • Slow perceived startup — users stare at loading spinners even on fast connections.
  • No offline support — a network error shows an empty screen instead of the last fetched content.
  • Wasted bandwidth — unchanged data is re-downloaded on every launch.

Caching fixes all three by persisting API responses locally and reading them instantly at startup.

Stale-While-Revalidate Strategy

The recommended caching strategy for most read-heavy screens follows three steps:

  • 1. Read the cache immediately — on app launch, load whatever was stored from the last successful network call and render it right away.
  • 2. Fetch fresh data in the background — fire the network request without blocking the UI.
  • 3. Update the cache and UI — when the response arrives, overwrite the cache and rebuild the widget with the new data.

If the network request fails (no connection, server error), the user still sees the cached data instead of an error screen.

Caching with shared_preferences

shared_preferences stores key-value pairs and is ideal for caching small JSON strings (a few kilobytes). Add it to pubspec.yaml:

pubspec.yaml dependency

dependencies:
  shared_preferences: ^2.2.3
  http: ^1.2.1

The pattern below wraps the cache logic in a service class to keep the UI code clean:

ApiCacheService using shared_preferences

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

class ApiCacheService {
  static const String _postsKey = 'cached_posts';
  static const String _postsTsKey = 'cached_posts_ts';

  /// Returns the cached list if available, null otherwise.
  Future<List<Map<String, dynamic>>>? loadCache() async {
    final prefs = await SharedPreferences.getInstance();
    final raw = prefs.getString(_postsKey);
    if (raw == null) return null;
    final List<dynamic> decoded = jsonDecode(raw);
    return decoded.cast<Map<String, dynamic>>();
  }

  /// Fetches from the network and overwrites the cache on success.
  Future<List<Map<String, dynamic>>> fetchAndCache() async {
    final response = await http.get(
      Uri.parse('https://jsonplaceholder.typicode.com/posts'),
    );
    if (response.statusCode != 200) {
      throw Exception('HTTP ${response.statusCode}');
    }
    final List<dynamic> data = jsonDecode(response.body);
    final posts = data.cast<Map<String, dynamic>>();

    // Persist to cache with a timestamp
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_postsKey, response.body);
    await prefs.setInt(
      _postsTsKey,
      DateTime.now().millisecondsSinceEpoch,
    );
    return posts;
  }

  /// Returns how old the cache is, or null if no cache exists.
  Future<Duration?> cacheAge() async {
    final prefs = await SharedPreferences.getInstance();
    final ts = prefs.getInt(_postsTsKey);
    if (ts == null) return null;
    return DateTime.now().difference(
      DateTime.fromMillisecondsSinceEpoch(ts),
    );
  }
}

Wiring the Cache to a Widget

The widget calls loadCache() during initState to show data immediately, then fires fetchAndCache() to refresh in the background:

PostsScreen with stale-while-revalidate

import 'package:flutter/material.dart';

class PostsScreen extends StatefulWidget {
  const PostsScreen({super.key});

  @override
  State<PostsScreen> createState() => _PostsScreenState();
}

class _PostsScreenState extends State<PostsScreen> {
  final ApiCacheService _cache = ApiCacheService();
  List<Map<String, dynamic>> _posts = [];
  bool _isRefreshing = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadData();
  }

  Future<void> _loadData() async {
    // Step 1 — show cache immediately (no spinner)
    final cached = await _cache.loadCache();
    if (cached != null && mounted) {
      setState(() => _posts = cached);
    }

    // Step 2 — fetch fresh data in the background
    setState(() => _isRefreshing = true);
    try {
      final fresh = await _cache.fetchAndCache();
      if (mounted) setState(() => _posts = fresh);
    } catch (e) {
      // Network failed — stale cache is still visible
      if (mounted) setState(() => _error = 'Offline — showing cached data');
    } finally {
      if (mounted) setState(() => _isRefreshing = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Posts'),
        actions: [
          if (_isRefreshing)
            const Padding(
              padding: EdgeInsets.all(16),
              child: SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(strokeWidth: 2),
              ),
            ),
        ],
      ),
      body: Column(
        children: [
          if (_error != null)
            MaterialBanner(
              content: Text(_error!),
              actions: [
                TextButton(
                  onPressed: () => setState(() => _error = null),
                  child: const Text('Dismiss'),
                ),
              ],
            ),
          Expanded(
            child: _posts.isEmpty
                ? const Center(child: CircularProgressIndicator())
                : ListView.builder(
                    itemCount: _posts.length,
                    itemBuilder: (context, index) {
                      final post = _posts[index];
                      return ListTile(
                        title: Text(post['title'] as String),
                        subtitle: Text(post['body'] as String),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}
Note: Always check mounted before calling setState() inside an async function. If the user navigates away while the network request is in flight, the widget is disposed and calling setState() on it throws an error.

Caching with Hive

Hive is a lightweight, fast NoSQL database that stores typed objects. It is the better choice when caching large lists, binary data, or when you need faster reads than JSON decoding allows. Add the dependencies:

Hive setup in pubspec.yaml

dependencies:
  hive: ^2.2.3
  hive_flutter: ^1.1.0

dev_dependencies:
  hive_generator: ^2.0.1
  build_runner: ^2.4.9

Open a Hive box in main() before runApp(), then read and write it exactly like a typed map:

Hive cache in main.dart and service

import 'package:hive_flutter/hive_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  await Hive.openBox<String>('apiCache');
  runApp(const MyApp());
}

// In the service:
class HiveCacheService {
  Box<String> get _box => Hive.box<String>('apiCache');

  List<Map<String, dynamic>>? loadCache(String key) {
    final raw = _box.get(key);
    if (raw == null) return null;
    return (jsonDecode(raw) as List)
        .cast<Map<String, dynamic>>();
  }

  Future<void> saveCache(String key, String jsonString) async {
    await _box.put(key, jsonString);
  }
}

Cache Invalidation and TTL

Caches become stale. A TTL (time-to-live) forces a full refresh after a configurable period, preventing users from seeing outdated data indefinitely:

  • Store a timestamp alongside the cached payload.
  • On startup, compare DateTime.now() with the stored timestamp.
  • If the age exceeds the TTL, skip the cache read and force a fresh fetch.
  • Typical TTLs: news feeds (5 min), product catalogues (1 hr), static config (24 hr).
Tip: Use shared_preferences for small JSON payloads (user profile, settings, a handful of items). Switch to Hive or sqflite when caching more than a few hundred rows or when you need to query the cache by field rather than by a fixed key.
Warning: Never cache sensitive data (passwords, auth tokens, personal health information) in shared_preferences — it is stored in plain text on the device. Use flutter_secure_storage for secrets.

Summary

Caching API responses is one of the highest-impact UX improvements you can make in a Flutter app. The key ideas from this lesson are:

  • Use the stale-while-revalidate pattern: show cache instantly, refresh in the background.
  • shared_preferences is sufficient for small JSON payloads; use Hive for larger or more structured data.
  • Store a timestamp with every cached payload and implement a TTL policy.
  • Always handle the case where the network fails — your cached data is the last line of defence against a blank screen.
  • Check mounted before every setState() call inside async methods.