Local Storage
Where data lives on the device — key-value settings, a real database, files, and the secure store that credentials belong in.
Four Places, Four Jobs
| Store | For | Not for |
|---|---|---|
| Key-value preferences | Settings, flags, small primitives | Anything you query, anything secret |
| Database | Structured data you filter, sort or page | Large binaries |
| Files | Images, video, documents, caches | Anything needing queries |
| Secure store | Tokens, keys, credentials | Bulk data — it is small and slow |
On-Device Databases
Both platforms wrap SQLite: Room on Android, Core Data or SwiftData on iOS. You get typed queries, compile-time checking of SQL in Room's case, and observable results — a query that emits again whenever the underlying rows change, which is what makes an offline-first UI live.
@Dao
interface MessageDao {
// returns a stream: the UI re-renders whenever these rows change
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sentAt DESC")
fun observeChat(chatId: String): Flow<List<Message>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(messages: List<Message>)
}
Migrations are not optional. Users update from any previous version, and a schema change without a migration path either crashes on launch or wipes the database. Write the migration with the schema change, and test upgrading from a real old build rather than a fresh install.
Key-Value Storage
Every platform has a small key-value store — DataStore on Android (SharedPreferences before it), UserDefaults on iOS. They are for settings and small flags: the chosen theme, whether onboarding was seen, a last-sync timestamp.
They are plain files. Nothing in them is encrypted, they are not designed for size, and reading a large one on the main thread at launch is a classic startup stall — DataStore exists partly to make that read asynchronous.
Secure Storage & Keychain
Credentials belong in the platform's hardware-backed store: the Keychain on iOS, the Keystore with EncryptedSharedPreferences or DataStore on Android. Keys are held by secure hardware where available, so the material never sits in your process memory or in a readable file.
| Practice | Why |
|---|---|
| Short-lived access tokens | Limits the value of a stolen one |
| Refresh token in the secure store | The one long-lived secret, in the safest place |
| Biometric gate for sensitive reads | The OS proves the user is present |
| Exclude from backups | Otherwise the secret leaves the device |
| Clear on logout | Stale credentials are somebody else's session |
Assume the device may be compromised. Secure storage raises the cost of extraction considerably; it does not make a token that never expires safe.
Interview Questions
When do you use preferences versus a database?
Preferences for small primitives you read whole — settings, flags, timestamps. A database as soon as you need to query, filter, sort or page, or once the data grows beyond a handful of values.
Why are schema migrations mandatory?
Users upgrade from arbitrary older versions. A schema change without a migration either crashes at launch or silently destroys their data, and you cannot fix it after the fact.
Where do auth tokens belong?
The Keychain on iOS or the Keystore-backed encrypted store on Android — hardware-backed where available. Never in preferences, which are unencrypted files that often end up in backups.
What makes an offline-first UI feel live?
Observable database queries. The screen subscribes to a query rather than fetching once, so any write — including one from a background sync — re-renders it automatically.
Why index local database columns?
Because your test device has fifty rows and a real user has twenty thousand. Filters and sorts without an index turn into visible freezes on exactly the users you care most about.
Why exclude secrets from device backups?
A backup copies data off the device, into a cloud account or a desktop, where the platform's hardware protection no longer applies.