Week 10: Local Persistence & Offline-First Patterns

A phone loses signal far more often than a laptop drops Wi-Fi. This week gives the app two ways to store data on-device — a real SQL database with sqflite, and a fast key-value/object store with Hive — then combines them with Week 9's networking into a genuine offline-first pattern.

Phase 5 of 8 Week 10 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Store and query structured data on-device with sqflite
  • Store simple key-value and object data with Hive
  • Choose between sqflite and Hive for a given kind of data
  • Build a screen that shows cached data instantly and refreshes from the network in the background

1. Structured Data with sqflite

terminal
flutter pub add sqflite path
db/product_db.dart
Future<Database> openProductDb() async {
  final dbPath = await getDatabasesPath();
  return openDatabase(
    join(dbPath, 'products.db'),
    version: 1,
    onCreate: (db, version) => db.execute(
      'CREATE TABLE products(id INTEGER PRIMARY KEY, name TEXT, price REAL)',
    ),
  );
}

Future<void> cacheProducts(Database db, List<Product> products) async {
  final batch = db.batch();
  for (final p in products) {
    batch.insert('products', p.toJson(), conflictAlgorithm: ConflictAlgorithm.replace);
  }
  await batch.commit(noResult: true);
}

Future<List<Product>> loadCachedProducts(Database db) async {
  final rows = await db.query('products');
  return rows.map((row) => Product.fromJson(row)).toList();
}

A batch groups many inserts into one transaction — meaningfully faster than inserting each row individually, and the right default whenever caching a whole list at once.

2. Key-Value & Object Storage with Hive

Hive is a fast, pure-Dart NoSQL store — no native SQL layer underneath, which makes it noticeably quicker to set up for data that doesn't need real relational queries.

terminal
flutter pub add hive hive_flutter
initializing and using a Hive box
await Hive.initFlutter();
final settingsBox = await Hive.openBox('settings');

settingsBox.put('theme_mode', 'dark');
final themeMode = settingsBox.get('theme_mode', defaultValue: 'system');

Hive can also store typed objects directly (via generated TypeAdapters) rather than just primitives, but for something like a user preference or a cached auth token, the plain key-value form above is often all that's needed.

3. Choosing Between Them

Kind of dataUse
Structured, queryable records (a product catalog, orders)sqflite
Simple preferences, flags, small cached objectsHive
A single sensitive value (an auth token)flutter_secure_storage — not covered in depth here, but worth knowing exists for exactly that case

This mirrors the exact storage-selection framework this site's React Native course teaches for AsyncStorage/SecureStore/SQLite — the specific package names differ per platform, but the underlying decision (does this need real queries, or just a fast key-value read) is the same question every mobile framework's storage layer answers.

4. An Offline-First Screen

a provider that loads cache first, then refreshes from the network
final productsProvider = FutureProvider<List<Product>>((ref) async {
  final db = await openProductDb();
  final cached = await loadCachedProducts(db);

  // kick off a network refresh, but don't block on it —
  // return cached data immediately if there's any
  unawaited(_refreshInBackground(db, ref));

  if (cached.isNotEmpty) return cached;

  // no cache yet — this genuinely has to wait on the network
  final fresh = await fetchProducts();
  await cacheProducts(db, fresh);
  return fresh;
});

Future<void> _refreshInBackground(Database db, Ref ref) async {
  try {
    final fresh = await fetchProducts();
    await cacheProducts(db, fresh);
    ref.invalidateSelf(); // triggers a rebuild with the fresh data once it's ready
  } catch (_) {
    // offline or the request failed — the cached data already on screen is still valid
  }
}

This is the real shape of offline-first: never make the user stare at a spinner for data that's already sitting on the device, and treat a failed background refresh as "stay on the cached data," not as an error the user needs to see.

5. Hands-on Exercise

Hands-on

Make the product list offline-first

Combine sqflite caching with Week 9's networking so the app is usable with no connection.

Requirements:

  1. A sqflite database caching the product list fetched in Week 9, with a batch insert on every successful network fetch.
  2. A provider that returns cached data immediately if present, then refreshes from the network in the background and updates the UI when the refresh completes.
  3. A Hive box storing a simple preference (e.g. sort order or a "last synced at" timestamp), read on app startup.
  4. Test genuinely offline: turn on airplane mode after the first successful load, force-quit and relaunch the app, and confirm the product list still appears from cache with no crash or infinite spinner.
  5. A visible (but non-blocking) indicator — a small banner or icon — showing when the app is currently offline or serving cached data.
Hint

If the UI doesn't update after a successful background refresh, confirm you're actually invalidating or re-reading the provider once fresh data lands — a background Future that fetches and caches data but never signals the provider system (via ref.invalidateSelf() or an equivalent) leaves the UI correctly showing cached data forever, with no visible bug until you specifically check whether it ever picks up the refresh.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does grouping cache inserts into a single batch matter for a list of any real size, rather than inserting each row individually?

Each individual insert against sqflite is its own transaction by default, with real overhead per operation — batching many inserts into one transaction (batch.commit) does all of them as a single unit, which is measurably faster for anything beyond a handful of rows and is the standard pattern for caching a whole fetched list at once.

Q2

Why is Hive a reasonable choice for a user preference like theme mode, where sqflite would be overkill?

A preference is a single simple value with no relational structure and no need for querying — Hive's key-value model reads and writes it directly with minimal setup, where sqflite would require defining a table and writing SQL for what's fundamentally just "get/set one value."

Q3

Why does the offline-first provider return cached data immediately rather than always waiting for the network refresh to finish first?

The entire point of offline-first is that a user with cached data available should never sit on a loading spinner for information the device already has — showing cached data instantly, then quietly updating in the background if a fresher version arrives, is what makes the app feel fast and usable even on a poor or absent connection.

Q4

Why does a failed background refresh in this pattern not surface as a visible error to the user?

The user is already looking at valid cached data — a failed refresh (offline, a timeout, a server error) doesn't invalidate what's currently on screen, it just means the data might be slightly stale. Treating that as a hard error would interrupt a perfectly functional experience over something that isn't actually broken; the appropriate response is simply to keep showing the cached data and try again later.