SharedPreferences #
Local data storage is one of the important pillars in modern mobile app development. When building apps with Flutter, you often face the need to store data that is simple, persistent, and lightweight. The most common examples are storing whether the user has completed the onboarding page, app theme preferences (dark or light), default language settings, or push notification (FCM) registration tokens. For simple key-value pair storage scenarios, SharedPreferences is the primary solution you’ll use most often.
SharedPreferences isn’t a relational database like SQLite or a high-performance NoSQL database like Hive or ObjectBox. SharedPreferences is a simple wrapper leveraging the built-in storage APIs of each target platform. This library is very efficient for storing small data, but can backfire on app performance if you use it wrongly. In this article, we’ll break down in depth how SharedPreferences works behind the scenes, implement basic operations, compare the old API with the new async API, design clean and secure storage architecture, and integrate it with state management.
Introduction & Internal Working #
To understand SharedPreferences well, you must see what happens behind the scenes when you call read or write methods in your Flutter code. The shared_preferences library in Flutter doesn’t create a new storage engine; it acts as a bridge through the Method Channel mechanism to communicate with the built-in storage libraries present on each platform’s operating system:
- Android: On the Android platform, SharedPreferences uses the
android.content.SharedPreferencesclass. The system writes data as raw XML (plaintext) files in the app’s internal storage directory, precisely at the path/data/data/<package_name>/shared_prefs/<package_name>_preferences.xml. - iOS & macOS: On Apple’s operating system family, SharedPreferences is wrapped on top of
NSUserDefaults. Data is stored as Property List (.plist) files inside the Library/Preferences folder within the app’s sandbox. - Web: For the Web platform, SharedPreferences maps read and write operations to the browser’s
window.localStorage. - Linux: Data is stored as local configuration files following the XDG base directory specification (usually under the
~/.configfolder). - Windows: This library uses a local JSON-based file storage placed in your app’s Roaming AppData folder.
In the legacy architecture (old API), one of SharedPreferences’ main characteristics is loading all data into memory (RAM caching). When you call SharedPreferences.getInstance(), the library performs a one-time asynchronous physical file read operation on storage memory (disk I/O) to retrieve all key-value pairs in the XML or plist file, then loads them all into the app’s RAM memory as a Dart Map data structure.
After that first instantiation succeeds, subsequent data read processes (using get(), getString(), getBool(), etc. methods) happen instantly and synchronously (without needing the await keyword). This happens because your Flutter code actually only reads data from the pre-built RAM cache, not performing direct reads to physical storage (disk). However, this approach has a negative impact: if your SharedPreferences file is very large (reaching hundreds of kilobytes or megabytes), the first-time initialization process when the app starts can take quite a long time and potentially hinder the app interface loading, trigger jank (frame rate drops), or even cause a crash if the operating system considers your app frozen.
Installation & Platform Configuration #
To start using SharedPreferences in your Flutter project, you need to add the official dependency managed directly by the Flutter team. Just add the following line to your project’s pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.3.4
After adding the library, run the flutter pub get command in the terminal to download and integrate it into the project.
In general, SharedPreferences doesn’t need special permission configuration on the Android manifest file (AndroidManifest.xml) or the iOS Info file (Info.plist). This storage fully runs within each app’s isolated sandbox space, so the operating system allows your app to read and write its own data without needing user consent.
However, there are several important platform configuration aspects you should note:
- Android Auto-Backup: By default, Android has an automatic backup feature (Auto Backup) that backs up app data (including SharedPreferences XML files) to the user’s Google Drive. If your app stores sensitive data like temporary access tokens or encryption status in SharedPreferences, that data will be uploaded unsafely. You can disable this automatic backup or limit which files may be backed up by configuring backup rules in
AndroidManifest.xmlusing theandroid:fullBackupContentattribute. - iOS Sandbox Cleansing: When users delete your app from their iOS devices, the operating system automatically removes all data associated with that app, including
NSUserDefaults(.plist) files. Data won’t linger on the device, unlike Android external storage which sometimes requires manual data deletion handling by users if not configured correctly.
Data Flow Architecture #
To have a clear visual picture of how data flows from your Flutter code to the physical storage on user devices, observe the data flow architecture diagram below. This diagram distinguishes the behavior between the Legacy API using full memory cache and the new Async API reading data directly as needed.
graph TD
Client["Flutter App (Dart)"] --> Service["Wrapper Service (Type-safe)"]
Service -->|Legacy API| Legacy["SharedPreferences (getInstance)"]
Service -->|Modern API| Async["SharedPreferencesAsync"]
Legacy -. "Reads all data into memory at startup" .-> Mem["Memory Cache (Synchronous Read)"]
Async -. "Reads asynchronously on demand" .-> Native["Platform Native Channel"]
Mem --> Client
Native --> Android["Android: SharedPreferences XML"]
Native --> iOS["iOS: NSUserDefaults"]
Native --> Web["Web: window.localStorage"]
Native --> Desktop["Desktop: JSON / Registry / Plist"]By understanding the diagram above, you can conclude that the Legacy API is faster for repeated reads because it doesn’t need to go through the Native Channel repeatedly (just reading from the Memory Cache). However, the price to pay is continuous RAM memory consumption to hold all that key-value data, plus heavy initialization load at app startup. Conversely, the modern Async API minimizes the initial memory footprint by only fetching the data truly requested through the native platform channel.
Basic CRUD Operations #
SharedPreferences is specifically designed to store primitive data types. This limitation aims to keep read-write performance fast. The data types supported by SharedPreferences include:
int(Integers)double(Fractional/decimal numbers)bool(Boolean true/false values)String(Text)List<String>(Text collections)
Here’s a complete implementation example for all basic CRUD (Create, Read, Update, Delete) operations using the conventional (Legacy) SharedPreferences API:
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(child: Text('SharedPreferences CRUD Demo')),
),
);
}
}
// Complete CRUD demonstration function
Future<void> runSharedPreferencesDemo() async {
// 1. Initialization and retrieval of the SharedPreferences instance
// This operation is async and takes disk I/O time on the first call.
final SharedPreferences prefs = await SharedPreferences.getInstance();
// 2. CREATE / UPDATE (Write Operations)
// Writing data to SharedPreferences returns a Future<bool> value
// indicating whether the data was successfully written persistently to disk.
await prefs.setString('user_username', 'budi_developer');
await prefs.setInt('user_login_count', 42);
await prefs.setDouble('user_app_volume', 0.85);
await prefs.setBool('user_is_premium', true);
await prefs.setStringList('user_pinned_folders', ['Inbox', 'Drafts', 'Sent']);
// 3. READ (Read Operations)
// Data reading is synchronous because it directly takes from the RAM cache.
// The returned value can be null if the key isn't found.
final String? username = prefs.getString('user_username');
final int? loginCount = prefs.getInt('user_login_count');
final double? appVolume = prefs.getDouble('user_app_volume');
final bool? isPremium = prefs.getBool('user_is_premium');
final List<String>? pinnedFolders = prefs.getStringList('user_pinned_folders');
// Displaying the read results to the console
debugPrint('Username: $username');
debugPrint('Login Count: $loginCount');
debugPrint('Volume: $appVolume');
debugPrint('Premium User: $isPremium');
debugPrint('Pinned Folders: $pinnedFolders');
// Reading data with a fallback (default value) if the key is null
final String activeTheme = prefs.getString('app_theme') ?? 'light';
debugPrint('Active Theme: $activeTheme');
// 4. CHECK (Key Existence Verification)
final bool hasUsername = prefs.containsKey('user_username');
debugPrint('Does the username key exist? $hasUsername');
// Getting all stored key lists
final Set<String> allKeys = prefs.getKeys();
debugPrint('All stored keys: $allKeys');
// 5. DELETE (Delete Operations)
// Permanently removes a specific key from physical storage and RAM cache.
await prefs.remove('user_app_volume');
// Removes all data inside the app's SharedPreferences
// IMPORTANT: Use clear() very carefully because it will delete all preferences.
// await prefs.clear();
}
Remember that writing methods like setString or setInt return a Future<bool> value. Although you often ignore this return value, in critical production apps you’re advised to verify the return value to ensure the user’s device storage space isn’t full and the data write truly succeeded to physical memory.
SharedPreferencesAsync vs SharedPreferences (Legacy) #
Since the release of shared_preferences version 2.3.0, the Flutter team introduced a new way to interact with local storage. This step was taken to solve the classic problem of startup overhead we discussed earlier. In this section, we’ll learn the fundamental differences between the two approaches.
Legacy API (SharedPreferences.getInstance) #
This API loads all data into RAM memory when the app calls getInstance().
- Advantages: After initialization, data reads are very fast because they’re synchronous. You don’t need to write the
awaitkeyword on every code line that wants to read data. - Disadvantages: The app’s RAM memory is wasted holding data that’s rarely read. If the data is very large, initialization at app startup will make the app feel slow when first opened.
Modern API (SharedPreferencesAsync) #
The modern API discards the concept of loading all data into RAM memory at startup. Instead, every time you want to read a value, you directly request that data from native asynchronously.
- Advantages: Significantly reduces app startup load. App RAM is clean of unused preference data storage.
- Disadvantages: All read operations are now asynchronous, meaning you must use the
awaitkeyword or use aFutureBuilderobject if you want to display them directly in the UI.
Here’s an example implementation using SharedPreferencesAsync:
import 'package:shared_preferences/shared_preferences.dart';
Future<void> demoSharedPreferencesAsync() async {
// We don't need to call getInstance() which loads all data.
// Just instantiate the SharedPreferencesAsync object directly.
final prefsAsync = SharedPreferencesAsync();
// Write Operations (still async as usual)
await prefsAsync.setString('auth_token', 'xyz123abc');
await prefsAsync.setBool('has_completed_tutorial', true);
// Read Operations (now must use await)
final String? token = await prefsAsync.getString('auth_token');
final bool? completedTutorial = await prefsAsync.getBool('has_completed_tutorial');
// Remove a specific key asynchronously
await prefsAsync.remove('auth_token');
}
Hybrid Solution: SharedPreferencesWithCache #
If you want the best of both worlds — the light startup of SharedPreferencesAsync plus the synchronous read speed of the Legacy API — you can use SharedPreferencesWithCache. This class lets you specifically define which keys you want to include in the app’s RAM cache, while other keys remain in physical storage.
Here’s an example of using SharedPreferencesWithCache:
import 'package:shared_preferences/shared_preferences.dart';
Future<void> demoSharedPreferencesWithCache() async {
// Create cache options to determine which keys will be stored in RAM
final options = const SharedPreferencesWithCacheOptions(
// We limit the cache only to UI settings frequently accessed instantly
allowList: <String>{'app_theme', 'app_language'},
);
// Initialize SharedPreferencesWithCache asynchronously
final prefsWithCache = await SharedPreferencesWithCache.create(
cacheOptions: options,
);
// Data Write Operations
await prefsWithCache.setString('app_theme', 'dark');
await prefsWithCache.setString('app_language', 'id');
await prefsWithCache.setString('api_endpoint_temp', 'https://api.example.com'); // this key isn't in the allowList
// Read Operations for keys in the allowList can be done SYNCHRONOUSLY (without await)
final String? theme = prefsWithCache.getString('app_theme');
final String? language = prefsWithCache.getString('app_language');
// However, if you try to read a key not registered in the allowList synchronously,
// its value will always produce null or trigger an exception, because the data isn't cached.
// To access keys outside the allowList, you must use SharedPreferencesAsync again.
}
By switching to SharedPreferencesAsync or SharedPreferencesWithCache on new Flutter projects, you write more memory-efficient code and provide a much smoother startup experience for your app users.
Implementing a Type-Safe Wrapper Service #
Writing key strings like 'user_username' or 'app_theme' repeatedly in various parts of your app code is a very bad practice. It’s prone to typos, complicates key renaming (refactoring) processes, and makes default value management messy.
The best way to handle SharedPreferences is creating a centralized, type-safe wrapper service class. Here’s the ideal wrapper class implementation pattern for production-scale Flutter apps:
// lib/core/storage/preferences_service.dart
import 'package:shared_preferences/shared_preferences.dart';
class PreferencesService {
// 1. Define all storage keys as private constants
static const String _keyTheme = 'preferences_app_theme';
static const String _keyLanguage = 'preferences_app_language';
static const String _keyIsUserLoggedIn = 'preferences_is_logged_in';
static const String _keyLastSyncTime = 'preferences_last_sync_time';
static const String _keyNotificationEnabled = 'preferences_notif_enabled';
// Legacy SharedPreferences instance used internally
final SharedPreferences _prefs;
// Private constructor to prevent wild direct instantiation
PreferencesService._(this._prefs);
// 2. Factory method to safely initialize the service
static Future<PreferencesService> init() async {
final sharedPrefs = await SharedPreferences.getInstance();
return PreferencesService._(sharedPrefs);
}
// ==========================================
// GETTERS & SETTERS (Type-Safe & Default Val)
// ==========================================
// App Theme Settings
// If the data is empty, we directly provide the default value 'system'
String get appTheme => _prefs.getString(_keyTheme) ?? 'system';
Future<bool> setAppTheme(String theme) async {
return await _prefs.setString(_keyTheme, theme);
}
// App Language Settings
String get appLanguage => _prefs.getString(_keyLanguage) ?? 'id';
Future<bool> setAppLanguage(String language) async {
return await _prefs.setString(_keyLanguage, language);
}
// User Login Status
bool get isUserLoggedIn => _prefs.getBool(_keyIsUserLoggedIn) ?? false;
Future<bool> setIsUserLoggedIn(bool value) async {
return await _prefs.setBool(_keyIsUserLoggedIn, value);
}
// Notification Settings
bool get isNotificationEnabled => _prefs.getBool(_keyNotificationEnabled) ?? true;
Future<bool> setNotificationEnabled(bool value) async {
return await _prefs.setBool(_keyNotificationEnabled, value);
}
// Last Synchronization Timestamp
// Example conversion from primitive int (milliseconds) to Dart DateTime objects
DateTime? get lastSyncTime {
final int? milliseconds = _prefs.getInt(_keyLastSyncTime);
if (milliseconds == null) return null;
return DateTime.fromMillisecondsSinceEpoch(milliseconds);
}
Future<bool> setLastSyncTime(DateTime time) async {
return await _prefs.setInt(_keyLastSyncTime, time.millisecondsSinceEpoch);
}
// ==========================================
// UTILITY METHODS
// ==========================================
// Remove specific data based on key
Future<bool> removeKey(String key) async {
return await _prefs.remove(key);
}
// Remove all stored preferences (e.g., when the user logs out)
Future<bool> clearAllPreferences() async {
return await _prefs.clear();
}
}
By using this PreferencesService, other parts of your app code no longer need to deal with raw data types, key strings, or null values manually. Just call the provided getter and setter methods structurally.
State Management Integration (Riverpod & BLoC) #
After creating a clean wrapper class, the next step is integrating that class into the state management system you use in the app. The goal is so that when a preference value changes, the UI components depending on that value immediately update automatically reactively.
Below, we’ll see how to integrate PreferencesService using Riverpod and BLoC/Cubit, the two most popular state management architectures in the Flutter community.
Riverpod Integration #
The async initialization pattern before the app runs is an industry standard in Flutter development. You initialize the service in the main() function, then override the Riverpod provider with the ready-to-use instance.
// lib/core/providers/preferences_provider.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../storage/preferences_service.dart';
// Basic provider for PreferencesService
// We let it throw an error by default because it will be overridden in main()
final preferencesServiceProvider = Provider<PreferencesService>((ref) {
throw UnimplementedError('PreferencesService has not been initialized in main()');
});
// Notifier to manage App Theme State reactively
class ThemeNotifier extends Notifier<ThemeMode> {
late PreferencesService _preferencesService;
@override
ThemeMode build() {
// Read the service from the provider
_preferencesService = ref.read(preferencesServiceProvider);
// Read the initial theme status from SharedPreferences
final String savedTheme = _preferencesService.appTheme;
switch (savedTheme) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
// Change the theme and store it persistently
Future<void> changeTheme(ThemeMode themeMode) async {
state = themeMode;
String themeString = 'system';
if (themeMode == ThemeMode.light) {
themeString = 'light';
} else if (themeMode == ThemeMode.dark) {
themeString = 'dark';
}
await _preferencesService.setAppTheme(themeString);
}
}
// Provider for ThemeNotifier
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(ThemeNotifier.new);
Then, in the main.dart file, you perform initialization before calling runApp():
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/storage/preferences_service.dart';
import 'core/providers/preferences_provider.dart';
void main() async {
// Must be called to ensure the Flutter native binding interaction is ready
WidgetsFlutterBinding.ensureInitialized();
// Initialize PreferencesService asynchronously before the app is rendered
final PreferencesService prefService = await PreferencesService.init();
runApp(
ProviderScope(
overrides: [
// Override the provider value with the successfully created instance
preferencesServiceProvider.overrideWithValue(prefService),
],
child: const MainApp(),
),
);
}
class MainApp extends ConsumerWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Monitor theme state changes reactively
final ThemeMode activeTheme = ref.watch(themeProvider);
return MaterialApp(
themeMode: activeTheme,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: const SettingsScreen(),
);
}
}
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ThemeMode themeMode = ref.watch(themeProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
ListTile(
title: const Text('Dark Theme'),
trailing: Switch(
value: themeMode == ThemeMode.dark,
onChanged: (bool value) {
ref.read(themeProvider.notifier).changeTheme(
value ? ThemeMode.dark : ThemeMode.light,
);
},
),
),
],
),
);
}
}
BLoC/Cubit Integration #
If you use the BLoC library, the pattern is very similar. You create a Cubit that receives the PreferencesService dependency through its constructor, loads initial data at creation, and triggers new data writes when events are fired.
// lib/features/settings/presentation/cubit/settings_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/storage/preferences_service.dart';
// State class to hold app settings
class SettingsState {
final String theme;
final String language;
const SettingsState({required this.theme, required this.language});
}
class SettingsCubit extends Cubit<SettingsState> {
final PreferencesService _preferencesService;
SettingsCubit(this._preferencesService)
: super(SettingsState(
theme: _preferencesService.appTheme,
language: _preferencesService.appLanguage,
));
// Action to update the app language
Future<void> updateLanguage(String newLanguage) async {
final success = await _preferencesService.setAppLanguage(newLanguage);
if (success) {
emit(SettingsState(
theme: state.theme,
language: newLanguage,
));
}
}
// Action to update the app theme
Future<void> updateTheme(String newTheme) async {
final success = await _preferencesService.setAppTheme(newTheme);
if (success) {
emit(SettingsState(
theme: newTheme,
language: state.language,
));
}
}
}
By separating UI logic from physical storage through state management and service layers like this, your app has a clear separation of concerns. The code becomes much easier to test and maintain in the long term.
Testing Strategy #
Testing code with dependencies on native libraries like SharedPreferences is often challenging. Without special handling, unit tests will fail because there’s no native engine processing the Flutter Method Channel requests when run in your local computer environment.
Fortunately, the shared_preferences library provides a built-in mechanism to create initial data simulations (mocking) very easily without needing external mocking libraries like Mockito or Mocktail. You can use the static SharedPreferences.setMockInitialValues(...) method.
Here’s an example of creating a complete unit test file for testing the PreferencesService we designed earlier:
// test/core/storage/preferences_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_app/core/storage/preferences_service.dart'; // Adjust to your project path
void main() {
// Ensure the Flutter testing environment initialization
TestWidgetsFlutterBinding.ensureInitialized();
group('PreferencesService Testing', () {
// Initial setup run before each test
setUp(() {
// Fill the initial simulation values into SharedPreferences storage.
// This replaces the real native calls with temporary memory storage.
SharedPreferences.setMockInitialValues({
'preferences_app_theme': 'dark',
'preferences_app_language': 'en',
'preferences_is_logged_in': false,
});
});
test('Must load the initial default values correctly when initialized', () async {
// Arrange & Act
final PreferencesService service = await PreferencesService.init();
// Assert
expect(service.appTheme, equals('dark'));
expect(service.appLanguage, equals('en'));
expect(service.isUserLoggedIn, isFalse);
// Testing keys not set in the mock must produce the default fallback value
expect(service.isNotificationEnabled, isTrue);
});
test('Must successfully change the theme value and store it persistently', () async {
// Arrange
final PreferencesService service = await PreferencesService.init();
// Act
final bool writeStatus = await service.setAppTheme('light');
// Assert
expect(writeStatus, isTrue);
expect(service.appTheme, equals('light'));
});
test('Must return a valid DateTime value from the millisecond representation', () async {
// Arrange
final DateTime nowTime = DateTime.now();
// Set additional mock values directly using integer millisecond representation
SharedPreferences.setMockInitialValues({
'preferences_last_sync_time': nowTime.millisecondsSinceEpoch,
});
final PreferencesService service = await PreferencesService.init();
// Act
final DateTime? storedTime = service.lastSyncTime;
// Assert
expect(storedTime, isNotNull);
// Compare millisecond values to avoid nano-second format differences during object initialization
expect(storedTime!.millisecondsSinceEpoch, equals(nowTime.millisecondsSinceEpoch));
});
test('Must delete all data when the clear method is called', () async {
// Arrange
final PreferencesService service = await PreferencesService.init();
// Act
final bool clearStatus = await service.clearAllPreferences();
// Assert
expect(clearStatus, isTrue);
// After clearing, all getters must return their default fallback values
expect(service.appTheme, equals('system'));
expect(service.isUserLoggedIn, isFalse);
});
});
}
Through this setMockInitialValues feature, unit tests can run very fast on local computers (CI/CD servers) without needing Android emulators or iOS simulators, while ensuring all business flows involving user preferences maintain code correctness.
Storing Complex Data (JSON Serialization) #
As we already know, SharedPreferences natively can’t recognize your own custom objects (custom Dart classes) like User or Product models. If you try to put those objects directly into SharedPreferences, the compiler will throw an error because those data types aren’t compatible with native primitive types.
The alternative solution to this problem is using JSON Serialization techniques. You convert your Dart object into a JSON string (using the built-in dart:convert library), store it as a String data type, and when reading it back, you parse that JSON string to return it to its original Dart object form.
Here’s a complete implementation guide:
// lib/core/models/user_session.dart
import 'dart:convert';
class UserSession {
final String userId;
final String name;
final String email;
final String token;
final DateTime expiresAt;
UserSession({
required this.userId,
required this.name,
required this.email,
required this.token,
required this.expiresAt,
});
// Conversion from JSON Map to UserSession object
factory UserSession.fromMap(Map<String, dynamic> map) {
return UserSession(
userId: map['userId'] as String,
name: map['name'] as String,
email: map['email'] as String,
token: map['token'] as String,
expiresAt: DateTime.fromMillisecondsSinceEpoch(map['expiresAt'] as int),
);
}
// Conversion from UserSession object to JSON Map
Map<String, dynamic> toMap() {
return <String, dynamic>{
'userId': userId,
'name': name,
'email': email,
'token': token,
'expiresAt': expiresAt.millisecondsSinceEpoch,
};
}
// Converting an encoded JSON string into a UserSession object
factory UserSession.fromJson(String source) =>
UserSession.fromMap(json.decode(source) as Map<String, dynamic>);
// Converting a UserSession object into an encoded JSON string
String toJson() => json.encode(toMap());
}
Now you can add methods to write and read this UserSession model into your PreferencesService class:
// Add these methods inside your PreferencesService class
static const String _keyUserSession = 'preferences_user_session';
// Save a UserSession object
Future<bool> saveUserSession(UserSession session) async {
// Convert the object to a JSON string first
final String jsonString = session.toJson();
return await _prefs.setString(_keyUserSession, jsonString);
}
// Read a UserSession object
UserSession? getUserSession() {
final String? jsonString = _prefs.getString(_keyUserSession);
if (jsonString == null) return null;
try {
// Parse the JSON string back into a Dart object
return UserSession.fromJson(jsonString);
} catch (e) {
// Handle possible parsing failures if the JSON format is corrupted
debugPrint('Failed to decode UserSession: $e');
return null;
}
}
// Delete a user session
Future<bool> deleteUserSession() async {
return await _prefs.remove(_keyUserSession);
}
[!WARNING] Important Warning About Limitations: Using JSON serialization techniques to store object data is an acceptable practice if the data amount is small and the structure is simple (e.g., a single user profile object). However, if you start storing long list data (like shopping cart product lists, or favorite article lists that can grow indefinitely), this is a strong signal that you must stop using SharedPreferences. Serializing and deserializing long JSON strings on the main thread will drastically hinder app performance. For such scenarios, switch to object-dedicated databases like Hive, ObjectBox, or Drift.
Data Security & Usage Limitations #
The most important thing you must understand as a professional Flutter app developer is: SharedPreferences has absolutely no data encryption security layer.
Data is stored in the device’s local storage in plaintext form. On rooted Android devices or jailbroken iOS devices, these preference XML/plist files can be opened and read very easily by other apps or outside parties using simple file explorer apps.
Therefore, there’s a golden rule you must obey: Never store sensitive data in SharedPreferences. Examples of sensitive data include:
- User passwords.
- Transaction PINs or OTP codes.
- Long-term access tokens (like JWT auth tokens).
- Credit card or bank account information data.
- Personal information that can specifically identify users (PII - Personally Identifiable Information).
Security Alternative: Flutter Secure Storage #
To store sensitive data, you must use the flutter_secure_storage library. This library leverages the operating system-level secure credential storage APIs that are hardware-encrypted: Keychain on iOS/macOS platforms and Keystore with the AES encryption algorithm on Android.
Here’s a quick comparison table to help you choose the right local storage technology according to your app needs:
| Comparison Parameter | SharedPreferences | Flutter Secure Storage | Hive / ObjectBox | Drift (SQLite) |
|---|---|---|---|---|
| Storage Category | Key-Value (Simple) | Key-Value (Secure) | NoSQL Object Database | RDBMS (Relational) |
| Security (Encryption) | None (Plaintext) | Yes (Hardware Encryption) | Yes (Optional Encryption) | Yes (Via SQLCipher) |
| Read-Write Speed | Very Fast (RAM Cache) | Slow (Hardware Call) | Very Fast (Binary) | Moderate (Disk I/O) |
| Complex Queries | Not Supported | Not Supported | Supported (Query Builder) | Very Strong (SQL/Join) |
| Data Suitability | Preferences, Onboarding | Credentials, JWT Tokens | API Data Cache, Offline | Transactional Data, Relations |
When Should You Avoid SharedPreferences? #
To summarize SharedPreferences usage limitations, avoid using this library in the following conditions:
- Complex Data Structures: If the data has one-to-many or many-to-many relations.
- Dynamic Data Queries: If you need to search data dynamically like filtering data by certain value ranges, partial text searches, or complex data sorting.
- Large Data Sizes: If the total data size exceeds several tens of kilobytes. Storing large data in SharedPreferences will significantly slow down app startup and waste RAM memory pointlessly.
- Intensive Write Operations: If your app continuously writes new data (e.g., recording user GPS coordinates every few seconds). This will trigger bottlenecks on the slow physical disk writing process.
Summary #
- SharedPreferences is specifically designed to store simple primitive key-value formatted data (
int,double,bool,String,List<String>).- Built-in Mechanism: This library works on top of operating system built-in APIs, namely
SharedPreferenceson Android (stored as XML files) andNSUserDefaultson iOS (stored as.plistfiles).- Modern API: Starting version 2.3.0, use
SharedPreferencesAsyncorSharedPreferencesWithCacheto avoid the heavy memory startup overhead of the legacy API.- Best Practice: Always wrap SharedPreferences access into a type-safe wrapper class to centralize key strings and avoid typos across the app code.
- State Integration: Use state management like Riverpod or BLoC to flow preference data changes reactively to the app UI.
- Fast Testing: Leverage
SharedPreferences.setMockInitialValuesto do independent unit tests without touching the physical storage of emulators or real devices.- Security Is Paramount: Never put secret data like authentication tokens or passwords in SharedPreferences. Use
flutter_secure_storageinstead.