Mert Özen Further With Every Line
Frontend Mobile with Flutter — #14

Local Storage and Offline in Flutter: shared_preferences, flutter_secure_storage, SQLite/drift and Caching Strategy

Mert Özen Nov 15, 2026 9 min 5 views
Local Storage and Offline in Flutter: shared_preferences, flutter_secure_storage, SQLite/drift and Caching Strategy

In Flutter, small non-secret settings go to shared_preferences, tokens to flutter_secure_storage (iOS Keychain, Android Keystore) and data you query to SQLite through drift. We move Bookstore's cart to drift, try a migration and a reactive query, and show the catalog without internet in the order fresh cache, network, stale cache, all without changing the ViewModels.

Version note: The examples in this post were run with Flutter 3.47.8 (Dart 3.13.7), shared_preferences 2.5.6, flutter_secure_storage 11.2.0, drift 2.35.4 (drift_dev 2.35.3, sqlite3 3.5.2), drift_flutter 0.3.1 and build_runner 2.17.1. The output comes from flutter test runs; in the tests the database lived in a temporary file or in memory, and the platform plugins ran with the in-memory stand-ins provided for tests. The Keychain, the Android Keystore and closing and reopening the app on a device need a device, so they were not run; those parts follow the docs.

Guess First

In the mobile app of Bookstore, the online bookstore used as the shared example across this site's series, a user looked at the catalog in the morning. 35 minutes later, on the metro without internet, they open the app again. With the code from the end of the previous post, what do they see: an empty list, a "Try again" error or the books from the morning? By the end of this post they will see the third, with the app telling them that the data is old.

What Goes Where?

There are three common ways to keep data on the device, and each one is for a different job:

DataPackageWhere on the deviceIn Bookstore
Small, non-secret settingshared_preferencesiOS: NSUserDefaults, Android: DataStore Preferences, web: localStoragethe last email that signed in
Small secret valueflutter_secure_storageiOS: Keychain, Android: storage encrypted with a Keystore keyaccess and refresh tokens
Structured data, queriesdrift (SQLite)a SQLite file in the app's own folderthe catalog cache and the cart

The rule is simple: data you search, join or page goes into the database; a single value goes into a setting; a value that does harm when stolen goes into secure storage. Writing the token to shared_preferences is the most common mistake: on Android that file is an unencrypted XML or DataStore file, readable on a rooted device or in a backup.

shared_preferences: Small, Non-Secret Values

The package has three APIs. According to the docs, the old SharedPreferences class will be deprecated in the future; for new code it recommends SharedPreferencesAsync (every read goes to the platform) or SharedPreferencesWithCache (reads once at start-up, then synchronous getters). The package's README also says that a write is not guaranteed to reach the disk, so it must not be used for critical data. In Bookstore we only keep the last email that signed in:

// Not a secret: only used to show who is signed in and to prefill the form
final SharedPreferencesAsync _prefs;
static const _emailKey = 'last_email';
String? _email;
String? _lastEmail;

@override
String? get lastEmail => _lastEmail;

/// Called once at start-up: tokens on the device mean a signed-in user.
Future<void> restore() async {
  _lastEmail = await _prefs.getString(_emailKey);
  if (await _api.tokens.read() != null) _email = _lastEmail;
}

main is now async: since plugins are called before runApp, first WidgetsFlutterBinding.ensureInitialized(), then await auth.restore(). That way the router runs its first redirect with the right sign-in state, and the sign-in page does not flash at start-up.

flutter_secure_storage: the Tokens

The second implementation of the TokenStore interface from the previous post:

/// Tokens in the Keychain (iOS) or in Keystore-encrypted storage (Android).
class SecureTokenStore implements TokenStore {
  SecureTokenStore([this._storage = const FlutterSecureStorage()]);

  final FlutterSecureStorage _storage;
  static const _access = 'access_token';
  static const _refresh = 'refresh_token';

  @override
  Future<Tokens?> read() async {
    final access = await _storage.read(key: _access);
    final refresh = await _storage.read(key: _refresh);
    if (access == null || refresh == null) return null;
    return (accessToken: access, refreshToken: refresh);
  }

ApiClient and ApiAuthRepository did not change; main passes SecureTokenStore() instead of InMemoryTokenStore. In tests the plugin has no platform side; with FlutterSecureStorage.setMockInitialValues({}) and InMemorySharedPreferencesAsync we simulated closing and reopening the app by reading the same storage with new objects:

before restore: false
after restore: true, ada@example.com
stored keys: [access_token, refresh_token]
after sign-out: false, form prefilled with ada@example.com

According to the package's docs, since 10.0 the data on Android is encrypted with an AES key that is protected by an RSA key in the Keystore, and the minimum Android SDK is 23. The web is different: an experimental implementation that encrypts with WebCrypto, keeps the keys in localStorage and works only on HTTPS or localhost. So on the web, "secure" does not give the protection the Keychain gives. Our web build read the token at start-up and loaded the catalog list in Chromium without problems, but the right place for token security on the web is the BFF approach, which never gives the token to the browser; it is covered in the BFF post of the Authentication and Authorization series.

drift: Tables, Joins and the EF Core Equivalent

drift is a library for type-safe queries on SQLite; you write tables as Dart classes and build_runner generates the query code. For someone who knows EF Core, the mapping is: AppDatabase is a DbContext, the table getters are DbSets, the query builder is the counterpart of LINQ, and schemaVersion with onUpgrade are the migrations. The biggest difference is that there is no change tracking: every write is an explicit insert/update/delete call.

/// Books the app has seen: the catalog cache and the books in the cart.
@DataClassName('CachedBook')
class Books extends Table {
  TextColumn get isbn => text()();
  TextColumn get title => text()();
  TextColumn get author => text()();
  IntColumn get priceKurus => integer()();
  IntColumn get publishedYear => integer()();
  // Added in schema version 2
  TextColumn get description => text().nullable()();
  // Where the book was in the catalog; null if it came from elsewhere
  IntColumn get catalogPage => integer().nullable()();
  IntColumn get catalogPosition => integer().nullable()();
  DateTimeColumn get fetchedAt => dateTime()();

  @override
  Set<Column> get primaryKey => {isbn};
}

@DataClassName('CartLineRow')
class CartLines extends Table {
  TextColumn get isbn => text().references(Books, #isbn)();
  IntColumn get quantity => integer()();

  @override
  Set<Column> get primaryKey => {isbn};
}

@DataClassName('CartLineRow') comes from a name clash: by default drift generates a row class named CartLine for the CartLines table, the same name as the CartLine record in our domain. dart run build_runner build said "Built with build_runner/aot in 17s" on the first run; we put the generated app_database.g.dart into the repo so that the project compiles without running build_runner. The cart is now read from two tables with a join:

@override
Future<Result<List<CartLine>>> getLines() async {
  // SELECT ... FROM cart_lines INNER JOIN books ON books.isbn = cart_lines.isbn
  final query = _db.select(_db.cartLines).join([
    innerJoin(_db.books, _db.books.isbn.equalsExp(_db.cartLines.isbn)),
  ])..orderBy([OrderingTerm.asc(_db.books.title)]);
  final rows = await query.get();
  return Result.ok([
    for (final row in rows)
      (
        book: bookFromRow(row.readTable(_db.books)),
        quantity: row.readTable(_db.cartLines).quantity,
      ),
  ]);
}

DriftCartRepository implements the three methods of CartRepository with the same contract; CartViewModel and the cart screen did not change. In a test we opened the database in a temporary file, closed it and opened it again:

after reopen: Dune x2, Emma x1

NativeDatabase in the tests uses real SQLite. The sqlite3 package provides the native library through a build hook: by default, a compiled SQLite published in the package's GitHub releases and verified with sha256 is downloaded on the first build. On the device, drift_flutter's driftDatabase(name: 'bookstore') opens the database in the app's data folder.

Migrations and Reactive Queries

Once the app has been released, a schema change has to be made without breaking the data already on devices. When a user updates from version 1 to version 2, their SQLite file is still in the old schema. drift tracks this with SQLite's user_version value:

@override
int get schemaVersion => 2;

@override
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (m) => m.createAll(),
  onUpgrade: (m, from, to) async {
    // Version 1 had no description column
    if (from < 2) await m.addColumn(books, books.description);
  },
  beforeOpen: (details) => customStatement('PRAGMA foreign_keys = ON'),
);

In a test we prepared the database with the tables version 1 would create, one row and PRAGMA user_version = 1, and opened AppDatabase on top of it:

user_version: 2, columns: isbn, title, author, price_kurus, published_year, catalog_page, catalog_position, fetched_at, description
kept row: Dune, description: null

It is the counterpart of Migrate() in EF Core, but you write the migration code by hand. drift_dev has tools that save schema files and test the migrations from every old version; once more than one version has been released, they are safer than hand-written ifs.

The part of drift that has no direct counterpart in EF Core is the reactive query: a query opened with watch() is a Stream that emits a new result whenever the table changes. The total number of books in the cart:

/// Emits the number of copies in the cart now and after every change.
Stream<int> watchCartCount() {
  final total = cartLines.quantity.sum();
  return (selectOnly(
    cartLines,
  )..addColumns([total])).map((row) => row.read(total) ?? 0).watchSingle();
}
cart counts: [0, 2, 5, 0]

Two Dunes, three Emmas and clear(): after every write the stream delivered the new total. Since Bookstore's ViewModels work with repositories that return Futures, we use this stream only in a test for now; when several screens need to show the same data live, a repository that returns a Stream is the natural next step.

An Offline Catalog: Fresh Cache, Network, Stale Cache

For the catalog, CachedBookRepository wraps ApiBookRepository and still looks like a BookRepository. The strategy has three steps: if the cache is less than 10 minutes old, do not go to the network at all; otherwise try the API and save the result; if there is no network, show the old records and say so.

@override
Future<Result<List<Book>>> getPage(int page) async {
  final cached = await _cachedPage(page);
  final fetchedAt = cached.isEmpty ? null : cached.first.fetchedAt;
  if (fetchedAt != null && _now().difference(fetchedAt) < maxAge) {
    _status.showingSaved(null);
    return Result.ok([for (final row in cached) bookFromRow(row)]);
  }
  switch (await _remote.getPage(page)) {
    case Ok(:final value):
      await _savePage(page, value);
      _status.showingSaved(null);
      return Result.ok(value);
    // Only a network failure falls back; a 500 or bad JSON stays an error
    case Failure(error: NetworkException()) when cached.isNotEmpty:
      _status.showingSaved(fetchedAt);
      return Result.ok([for (final row in cached) bookFromRow(row)]);
    case Failure(:final error):
      return Result.failure(error);
  }
}

The constructor uses Dart 3.12's private named parameters: you write required this._remote, and the caller writes remote:. The answer to "Guess First", from a test that moves the clock by hand:

remote calls after 5 min: [0]
offline after 35 min: 20 books, first Dune, status offline: true, saved at 2026-11-15 10:00:00.000Z (read back as UTC: false)
500 with a cache: Server error

At the fifth minute no second request went to the API. At the 35th minute there was no network and the user saw the 20 books from the morning; OfflineStatus notified, and an "Offline: showing books saved on ..." banner appeared above the bottom navigation (widget test: banner: 1). The 500, on the other hand, was not hidden behind the cache: a server error and a missing connection are different things, and hiding the first delays noticing the problem.

This is not a full stale-while-revalidate: a Future can return a single value, so we cannot show the old data and then deliver the new data through the same call. Real stale-while-revalidate needs a repository that returns a Stream and a ViewModel that listens to it; drift's watch() is ready for that.

Production Notes

On the web, drift also needs sqlite3.wasm and a worker file. Since the series targets Android and iOS, main does not open a database on the web (kIsWeb); the catalog and the cart stay in memory there.

Android's automatic backup. The flutter_secure_storage docs say that encrypted data restored from a backup can throw InvalidKeyException because the key is not on the new device, and recommend android:allowBackup="false" or backup rules that leave these files out. On iOS, Keychain entries can survive deleting and reinstalling the app; a "first launch" flag in shared_preferences can be used to clear old tokens on the first start. We tried neither on a device.

Timestamps. By default drift stores DateTime as Unix time and returns it as local time; that is the read back as UTC: false in the output. Comparisons work, but if you forget toUtc() in logs or when sending the value back to the API, a time zone difference shows up. drift's build options also let you store dates as text such as 2026-11-15T10:00:00.000Z.

Limit the cache. Catalog pages are overwritten by catalogPage, but book rows are never deleted; in an app used for months, old rows should be cleaned up at start-up.

Common Pitfalls

The first pitfall is writing the token to shared_preferences. It works, it passes the tests and it sits there unencrypted.

The second pitfall is forgetting WidgetsFlutterBinding.ensureInitialized() and calling a plugin before runApp; the app fails at start-up.

The third pitfall is changing the schema without increasing schemaVersion, or updating only onCreate. Everything works on new installs, and the app of every user who updates crashes with "no such column".

The fourth pitfall is falling back to the cache on every error. Users see old prices, and the team notices the server error late.

A Small Experiment

★☆☆ Lower maxAge from 10 minutes to 1 minute and see how the "remote calls after 5 min" line in the test changes.

★★☆ Let the book details page write to the cache too: when getByIsbn succeeds, save the book with its description to the Books table, and show with a test that the details page opened through a deep link works without the internet.

★★★ Connect the cart badge to watchCartCount(): add Stream<int> watchItemCount() to CartRepository, let CartViewModel listen to it and cancel the subscription in dispose. Verify with a widget test that the badge stays correct when two screens change the cart at the same time.

In the next post we look at tests as a whole: fake repositories and mocktail, widget tests with provider and go_router, an end-to-end flow with the integration_test package, and the platform differences of golden tests.