Local Storage in Mobile Applications: Core Data, Room, Realm, Hive, Isar
Application loses data on network loss — and this isn't just a bug, it's a scenario failure. User filled in a form, clicked "Send," got timeout and lost everything. Or worse: data was sent twice due to incorrect retry logic. Properly chosen and configured storage layer solves this problem once and for all.
Database Choice — Not a Matter of Taste
In practice, storage choice is determined by two factors: data type and synchronization requirements, not library popularity.
Room (Android) — wrapper over SQLite with compile-time SQL query verification. If query is invalid, build fails — better than SQLiteException at runtime. Room integrates well with Kotlin Flow and LiveData, making reactive UI updates straightforward. Main complexity is schema migrations. @Database(version = N, exportSchema = true) with migration files in assets/databases/ — mandatory practice, otherwise on app update fallbackToDestructiveMigration() just wipes user data.
Core Data (iOS) — not a database, but object graph management framework on top of SQLite (or XML, or in-memory). NSPersistentContainer with viewContext for main thread reading and newBackgroundContext() for writing — basic pattern. Problems start when developer does save() in viewContext from background thread: EXC_BAD_ACCESS at random moment, reproduces once a week, crash log has little useful info. Must use performAndWait or perform for each context strictly in its own thread.
Realm wins where you need speed working with large object sets and built-in reactivity via Results + observe(). Realm stores objects directly, without ORM mapping, so reading doesn't require deserialization. On Flutter Realm SDK (ex-MongoDB Realm) supports Device Sync — but that's already managed service with separate infrastructure.
Hive and Isar — Flutter-specific solutions. Hive — key-value storage, fast, simple, suitable for settings and caches. Isar — full-featured document-oriented database written in Rust, compiles to native code. For Flutter apps with offline functionality, Isar is now preferred: built-in query builder with type-safe filters, transactions, watchObject/watchQuery for reactivity.
| Platform | Solution | Reactivity | Synchronization |
|---|---|---|---|
| Android | Room + Flow | LiveData/Flow | WorkManager |
| iOS | Core Data | NSFetchedResultsController | CloudKit |
| Flutter | Isar | Streams | Custom / Realm Sync |
| Cross-platform | Realm | RealmResults.observe | Device Sync |
| Flutter (simple) | Hive | ValueListenable | None |
Offline Synchronization — this is where real complexity begins
Local storage itself isn't complex. Complexity is in synchronization with server when conflicts exist.
Most common pattern — optimistic updates with rollback. User edits record, UI displays change immediately, background request goes to server. If server returns error — rollback local state. Sounds simple. In practice: if user managed to leave screen and return, and rollback happened 3 seconds later — UX is broken. Need explicit operation queue with state (PENDING, SYNCED, FAILED) in separate table.
On Android, for background sync use WorkManager with Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED). Important not to forget setInputMerger(ArrayCreatingInputMerger::class) when batching tasks — otherwise with multiple simultaneous runs data gets overwritten.
On iOS, analog is BGTaskScheduler with BGProcessingTaskRequest. iOS limitations on background execution time (~30 seconds for refresh tasks) mean synchronization must be incremental: not "sync everything," but "sync next N records, save cursor."
Conflicts in multi-device work are solved with one of three approaches:
- Last-write-wins by
updated_at(simplest, loses data with simultaneous editing) - Server-wins (client always accepts server version)
- Three-way merge (complex, need common ancestor — suitable for documents)
In most B2C applications, last-write-wins with time vector per user is enough, but with collaborative editing, CRDT approach is needed — then look at Automerge or Yjs with mobile bindings.
How to Build Storage Layer
Repository pattern is not optional but mandatory. UserRepository doesn't know where data comes from: Room, Realm, or network. ViewModel calls repository.getUser(id), gets Flow/Stream, displays data. Caching logic is inside repository.
For Flutter, typical architecture: Isar for persistence, Riverpod for state management, ConnectivityPlus for network state, custom SyncService with operation queue. Riverpod AsyncNotifier conveniently covers "show cache, update from network, show new data" logic.
Separate topic — encryption. If application stores medical data, payment cards, or corporate documents, SQLCipher (Android) and NSFileProtection (iOS) are not optional. Realm supports encryption natively via 64-byte key, which must be stored in Keychain/Keystore, not SharedPreferences.
Work Stages
Start with requirements audit: what data, what volume, need synchronization, conflicts possible. At this stage it becomes clear: Core Data or SQLite-based solution, need Realm Sync or REST-polling is enough.
Next — schema design considering migrations. Schema changes in any project — question isn't "will there be migrations," but "how painful will they be." Export schema to JSON, store in repository, write tests for each version migration.
Development proceeds with repository layer unit test coverage: mock network layer, real in-memory database for query testing. Before release — query profiling via Android Profiler (Database Inspector tab) or Core Data debug flags (-com.apple.CoreData.SQLDebug 1).
Timeline for storage layer implementation with basic offline sync — 2 to 6 weeks depending on schema complexity and conflict-resolution requirements.







