Local storage options
When you build a Flutter app that users rely on daily, you quickly realize that not everything belongs in the cloud. Device storage matters—whether it's a user's authentication token that needs protecting, their preference for dark mode, or a full ledger of transactions they want to access offline. Choosing the right local storage option is one of those decisions that feels small until it isn't, because picking the wrong tool means painful migrations, security holes, or performance cliffs later. Let me walk you through the landscape of local storage options, and show you how to match each tool to the data it's meant to hold.
Understanding your storage options
Flutter gives you several storage primitives, each with a distinct purpose. I've used all of them in production, and the pattern I've learned is simple: don't use one tool for everything. Let me compare the main players.
| Storage | Best for | Encrypted | Queryable | Size limit |
|---|---|---|---|---|
| shared_preferences | Small settings, flags, user preferences | No (platform-dependent) | No | ~500 KB per app |
| flutter_secure_storage | Tokens, API keys, passwords, secrets | Yes (platform native) | No | ~1-10 MB (varies by OS) |
| Drift (SQLite) | Relational offline data, complex queries | With sqlite_async | Yes, powerful SQL | Gigabytes |
| Hive / Isar | Fast key-value objects, collections | Yes (Hive) | Indexes and queries | Gigabytes |
shared_preferences: Small settings and user preferences
I think of shared_preferences as the quick-access shelf by the door. You store lightweight, non-sensitive data here: theme mode, notification toggles, language preference, onboarding flags. On Android it uses SharedPreferences; on iOS it's UserDefaults. Both are fast, but they're not encrypted by default and they're not designed for large datasets.
Here's a real example from an e-commerce app I shipped: storing whether the user prefers list view or grid view in the product feed.
import 'package:shared_preferences/shared_preferences.dart';
class PreferencesService {
static const String _viewModeKey = 'product_view_mode';
Future<void> setViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_viewModeKey, mode);
}
Future<String> getViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_viewModeKey) ?? 'grid';
}
}
The pattern is straightforward: call getInstance(), read or write your value, move on. Where I've seen teams stumble is doing too much here. I once saw a codebase that stored a JSON-encoded list of 50,000 product IDs in shared_preferences. Performance tanked because every read had to deserialize the entire string. If your data structure is growing, graduate to a real database.
flutter_secure_storage: Tokens and secrets
This is where authentication credentials belong. flutter_secure_storage wraps the native secure storage: Keychain on iOS and Keystore on Android. The encryption is handled by the OS, which means your app never touches the plaintext key material directly.
I use this for refresh tokens, API keys, and sensitive configuration. Here's the pattern I follow in every auth-enabled app:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenService {
static const String _refreshTokenKey = 'refresh_token';
static const String _accessTokenKey = 'access_token';
final _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(
keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_OAEPwithSHA_256andMGF1Padding,
storageCipherAlgorithm: StorageCipherAlgorithm.AES_GCM_NoPadding,
),
);
Future<void> saveTokens(String accessToken, String refreshToken) async {
await _storage.write(key: _accessTokenKey, value: accessToken);
await _storage.write(key: _refreshTokenKey, value: refreshToken);
}
Future<String?> getRefreshToken() async {
return _storage.read(key: _refreshTokenKey);
}
Future<void> clearTokens() async {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
}
}
One critical thing: on Android and iOS, secure storage works differently at the OS level. Make sure your build configuration is correct, especially on Android where you need to handle the keystore properly in production builds. I've also learned not to store everything here. It's slower than shared_preferences because of the encryption overhead, so use it only for sensitive data.
Drift (SQLite): Relational offline data
When you need structured, queryable data that survives offline sessions, SQLite (via Drift) is your foundation. I've built entire offline trading platforms on this. You can define schemas, write migrations, run joins, and index fields efficiently.
Think of a ride-hailing app storing completed trips, driver ratings, payment history. All relational, all queryable.
import 'package:drift/drift.dart';
part 'database.g.dart';
class Trips extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get driverId => text()();
TextColumn get startLocation => text()();
TextColumn get endLocation => text()();
RealColumn get fare => real()();
DateTimeColumn get completedAt => dateTime()();
IntColumn get rating => integer().nullable()();
}
@DriftDatabase(tables: [Trips])
class AppDatabase extends _$AppDatabase {
AppDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 2;
Future<List<Trip>> getRecentTrips({required int limit}) =>
(select(trips)..orderBy([(t) => OrderingTerm(expression: t.completedAt, mode: OrderingMode.desc)]))
.limit(limit)
.get();
Future<double> getTotalSpent(String driverId) => (select(trips)
..where((t) => t.driverId.equals(driverId)))
.watch()
.map((rows) => rows.fold<double>(0, (sum, trip) => sum + trip.fare))
.first;
}
The real power here is migrations. When your schema evolves, Drift's migration system handles it. Later in this article I'll walk through that separately because it's crucial.
Hive and Isar: Fast key-value objects and collections
Hive and Isar sit between shared_preferences and Drift. They store Dart objects directly—no need to manually serialize to JSON or map rows. They're fast, they support encryption, and they let you query collections with indexes.
I reach for these when I need to store user-generated lists, chat messages, or lightweight object hierarchies that don't require complex joins. Here's Hive storing a list of bookmarked products from an e-commerce app:
import 'package:hive/hive.dart';
part 'product_bookmark.g.dart';
@HiveType(typeId: 0)
class ProductBookmark {
@HiveField(0)
final int id;
@HiveField(1)
final String name;
@HiveField(2)
final double price;
@HiveField(3)
final DateTime savedAt;
ProductBookmark({
required this.id,
required this.name,
required this.price,
required this.savedAt,
});
}
class BookmarkService {
static const String _boxName = 'bookmarks';
Future<void> addBookmark(ProductBookmark bookmark) async {
final box = await Hive.openBox<ProductBookmark>(_boxName);
await box.add(bookmark);
}
Future<List<ProductBookmark>> getAll() async {
final box = await Hive.openBox<ProductBookmark>(_boxName);
return box.values.toList();
}
Future<void> removeBookmark(int index) async {
final box = await Hive.openBox<ProductBookmark>(_boxName);
await box.deleteAt(index);
}
}
The developer experience is smooth—objects are strongly typed, there's no boilerplate SQL, and read performance is excellent. Isar is a newer alternative that offers similar benefits plus more advanced querying. Both support encryption out of the box.
Match the data to your storage choice
The hardest part isn't the API—it's deciding which tool handles which piece of data. Let me give you a decision framework I've used across multiple platforms.
Settings and preferences: Use shared_preferences. Theme, language, UI state. Small, fast, simple. If it doesn't need encryption and isn't sensitive, put it here.
Secrets and credentials: Use flutter_secure_storage. Tokens, API keys, passwords. Non-negotiable. Never store these in shared_preferences or logs.
Structured, relational data: Use Drift. Orders, transactions, messages with metadata, anything that benefits from queries and joins. You need to own the schema.
Object collections: Use Hive or Isar. Lists of bookmarks, recent searches, user-generated collections. Lightweight, typed, queryable but not relational.
In a typical app, you'll use all of them. A travel booking app I built stored user language choice in shared_preferences, authentication tokens in secure storage, booking history and related flight data in Drift, and recent searches in Hive. Each tool earned its place.
Plan migrations: Local schemas change too
This is where I've seen junior teams get blindsided. You ship your app with a schema. Users have data. Six months later you add a column, rename a field, or restructure your model. If you haven't planned migrations, you'll either lose data or crash on launch.
Drift has built-in migration support. Define a migration for each schema version change:
@DriftDatabase(tables: [Trips, Ratings])
class AppDatabase extends _$AppDatabase {
AppDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from == 1) {
// Adding a column for driver ratings in version 2
await m.addColumn(trips, trips.driverRating);
}
},
);
}
For Hive, the pattern is different. You don't have automatic migration—you handle it in code:
Future<void> initializeHive() async {
final appDocDir = await getApplicationDocumentsDirectory();
Hive.init(appDocDir.path);
Hive.registerAdapter(ProductBookmarkAdapter());
final box = await Hive.openBox<ProductBookmark>('bookmarks');
// Manual migration: if old version, transform data
if (!box.containsKey('migrated_v2')) {
for (var i = 0; i < box.length; i++) {
final bookmark = box.getAt(i);
// Transform if needed
}
await box.put('migrated_v2', true);
}
}
shared_preferences and secure storage don't have schema—they're key-value stores. But you should still version your keys and handle renaming gracefully. I've seen apps break because a key was removed without checking if it existed first.
Common mistakes
- Storing everything in shared_preferences: I've watched apps lag because they JSON-encoded large datasets into strings. Graduate to a real database when your data grows.
- Putting secrets in shared_preferences: It's not encrypted on Android by default. I've done security audits where tokens were readable in plaintext. Use secure_storage.
- Forgetting migrations: Your app crashes on production because a column is missing. Test schema upgrades before shipping.
- Over-normalizing in Hive: Hive doesn't have joins. If you find yourself querying and manually joining objects in code, you need Drift.
- Not handling initialization order: Calling storage APIs before
WidgetsFlutterBinding.ensureInitialized()causes crashes. Initialize early inmain().
Key takeaways
- Match your storage tool to your data type: shared_preferences for settings, secure_storage for secrets, Drift for relational data, Hive for fast object collections.
- Don't use one tool for everything. Mixing concerns makes code hard to reason about and creates security vulnerabilities.
- Plan migrations from day one. Schema changes are inevitable, and migrations that work are worth the upfront planning.
- Encrypt sensitive data. Use flutter_secure_storage for tokens and credentials—never shared_preferences.
- Test offline scenarios. Your local storage strategy should let users access core features without a network connection.






Comments
Loading comments…