Mobile Core Concepts
Persistence
Why "where does this state live when the process dies" is a design question, and the persistence decision tree that keeps data safe, fast, and recoverable.
Every Android app has the same baseline need: keep some state across process death. The interesting question is not "should we persist?" but "which substrate, for which kind of state?" - a single boolean is a different problem from a million chat messages, and the wrong pick shows up later as ANRs, lost data on crash, or a "why is this view re-querying every rotation" thread on the team chat. This page walks the persistence landscape on modern Android: each option's mechanics, the migration story between them, and the decision table interviewers expect when a problem starts with "where would you store this." The Repository pattern that consumes these stores lives in Architecture; we won't redraw it here.
The persistence options
Five axes separate the modern options - everything below the table is a refinement of one of these:
| Axis | One side | The other |
|---|---|---|
| Schema | Typed (compile-checked) | Untyped (string keys) |
| Reads | Reactive (Flow) | Imperative (read-once) |
| Storage | SQLite (relational) | Key-value (flat) |
| Encryption | Encrypted at rest | Plaintext on disk |
| Durability | Transactional (fsync) | Best-effort (mmap) |
All five files live under /data/data/<pkg>/; the relative path tells you which subsystem owns them.
| Option | Location on disk | File type | Content structure | Reactive | Encrypted | Best for |
|---|---|---|---|---|---|---|
| Room | databases/<name>.db | SQLite binary | Tables, rows, indexes, FTS | Flow<T> | No (use SQLCipher) | Relational data, single source of truth |
| Preferences DataStore | files/datastore/<name>.preferences_pb | Protobuf binary | Typed-key → Any map | Flow<Preferences> | No | Simple flags, single typed values |
| Proto DataStore | files/datastore/<name>.pb | Protobuf binary | One generated message | Flow<T> | No | Structured config that evolves |
| EncryptedSharedPreferences | shared_prefs/<name>.xml | XML, encrypted values | Key-value (AEAD per entry) | Listener-based | Yes (Tink + Keystore) | Auth tokens, session IDs |
| MMKV | files/mmkv/<id> | mmap-backed binary | Append-only key-value log | No | Optional (AES) | High-throughput logs, analytics, breadcrumbs |
SharedPreferences - superseded by DataStore since 2020 (not formally deprecated, but Google's own docs say "use DataStore"). Its fundamental flaw was durability: apply() queues a write in memory and the process can die before it flushes to disk, losing data on crash. It also offered no reactive reads - only a listener callback - which makes it incompatible with the Flow-based pipeline every modern Android app is built on. Migrate via SharedPreferencesMigration (see DataStore below).A working mental model - one row per modern store, what it stands in for, and what belongs in it:
| Tool | Think of it as | What lives there |
|---|---|---|
| Room | The local database | Data with shape - tables, joins, queries |
| DataStore | The settings file | Flags and structured config |
| EncryptedSharedPreferences | The keyring | Secrets - tokens, session IDs |
| MMKV | The append-only log | Firehose writes - breadcrumbs, analytics |
They are not interchangeable. A team that uses Room for everything ends up running queries to read a boolean; a team that uses Preferences for everything ends up serializing JSON arrays into a string key and hand-rolling concurrency.
Room
Room is a compile-time-verified SQLite wrapper. You declare entities, DAOs, and a database class; KSP generates the implementations at build time. The compile-time check is the whole point - a malformed SQL string in a @Query annotation fails the build, not the user's launch.
@Entity(tableName = "posts")
data class PostEntity(
@PrimaryKey val id: String,
val body: String,
val createdAt: Long,
)
@Dao
interface PostDao {
@Query("SELECT * FROM posts ORDER BY createdAt DESC")
fun observeAll(): Flow<List<PostEntity>> // reactive
@Upsert suspend fun upsertAll(posts: List<PostEntity>)
}Three DAO return-type categories matter:
suspend fun ... : T- one-shot read or write. Runs on Room's IO executor; safe from any dispatcher.fun ... : Flow<T>- reactive query. Re-emits whenever any row in the queried tables changes. The substrate for the Repository observe contract.fun ... : PagingSource<Int, T>- integration with Paging 3 for large lists.
A few conventions every Android system-design problem assumes:
- Use
@Upsert, not@Insert(onConflict = REPLACE).REPLACEdeletes then re-inserts - it resetsrowid, breaks foreign keys, and fires delete callbacks.@Upsertissues a trueINSERT ... ON CONFLICT DO UPDATE. - Wrap multi-write operations in
withTransaction { ... }. Writing a page of posts plus the pagination cursor in one transaction means the UI never sees "new posts, old cursor." - Type converters for non-primitive columns. A
List<String>becomes a JSON string via a@TypeConverter. Don't store CSV by hand. @Relationfor joins - adata class PostWithCommentswith@Embeddedpost and@Relationcomments, queried as one Flow.
@Transaction
suspend fun writeFeedPage(posts: List<PostEntity>, cursor: FeedCursor) =
db.withTransaction {
postDao.upsertAll(posts)
cursorDao.upsert(cursor)
}The reactive contract is the part that interviewers test. A Room Flow is cold (see Concurrency); collecting it twice runs the query twice. The Repository converts it to a hot Flow via stateIn so multiple ViewModels share one query.
Sources: Room persistence library
Room and the write-ahead log
Every withTransaction above ends in a commit, and what a commit costs is set by SQLite's journal mode. Room defaults to the write-ahead log (WAL).
In SQLite's classic rollback-journal mode, every commit writes its changed pages into the main database file and flushes them on the way - small, frequent transactions pay random writes plus a flush each. In WAL mode a commit instead appends the changed pages to a <name>.db-wal sidecar; readers see the latest version of each page through the -shm index file, and a periodic checkpoint folds the accumulated pages back into the main file. On Android the expensive fsync happens at that checkpoint, not per commit.
That buys the two things Room's usage patterns rely on:
- Writes are cheap enough to be frequent. A burst of small transactions - a keystroke, a breadcrumb, a queue insert - costs sequential appends, so "one transaction per event" is an affordable write path.
- Readers don't block the writer. A reader sees a consistent snapshot while a write transaction is mid-flight, which is what keeps reactive
Flowqueries re-running during a write burst. Still one writer at a time.
The durability fine print mirrors the MMKV discussion below: a committed transaction survives process death, because the append already reached the kernel's page cache, but a power loss or kernel panic can lose commits made since the last checkpoint. The database file itself stays consistent either way - on the next open SQLite replays or discards the WAL, never half-applies it.
Configuration is the part you mostly leave alone: the builder's setJournalMode() defaults to AUTOMATIC, which resolves to WAL everywhere except low-RAM devices, where it falls back to TRUNCATE. Override only with a measurement in hand.
Sources: Write-Ahead Logging
Room migrations
Schema changes are the part of Room that bites in production. The database file has a version number; the runtime compares the file's version to the code's @Database(version = N) and runs every Migration(a, b) in between.
@Database(entities = [PostEntity::class], version = 3, exportSchema = true)
abstract class AppDatabase : RoomDatabase() { ... }
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE posts ADD COLUMN edited_at INTEGER")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_2_3)
.build()Three things to know:
ALTER TABLE ADD COLUMNis the easy case. It's a metadata-only change; safe and fast. SQLite supports it directly.- Drop / rename / change-type require a full table recreation. The pattern is create-new-table, copy-data, drop-old, rename-new. Room generates this for you via auto-migrations when the change is unambiguous.
- Auto-migrations (Room 2.4+) read the previous schema JSON in
schemas/and the current schema, then synthesize the migration. Add@AutoMigration(from = 2, to = 3)to the@Databaseannotation. The author still has to specify renames via@RenameColumnbecause Room can't infer them from schema diff.
exportSchema = true writes a JSON snapshot per version into schemas/. Commit those to git. MigrationTestHelper uses them to instantiate the old schema, run your migration, and assert the result schema matches - a proper migration test, not a guess.
The rule that matters in production: destructive migration in dev only, manual migration in prod. Setting fallbackToDestructiveMigration() in the builder is fine for early development; shipping it means users lose all local data on every version bump. Strip it before release.
// Dev:
.fallbackToDestructiveMigration()
// Prod:
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)Sources: Migrate Room databases
DataStore
DataStore is Jetpack's coroutine-native key-value store. Two flavors share an API surface but differ in what they store.
Preferences DataStore is untyped - you read and write via typed keys (booleanPreferencesKey("dark_mode")), and the store itself is a Map<Key<*>, Any>. Good for single flags and isolated settings.
Proto DataStore is fully typed - you define a .proto schema, the build generates a Kotlin data class, and the store holds one instance of it. Good for structured config that evolves over time.
// Preferences flavor:
private val Context.prefs by preferencesDataStore("settings")
private val DARK_MODE = booleanPreferencesKey("dark_mode")
val isDark: Flow<Boolean> = context.prefs.data
.map { it[DARK_MODE] ?: false }
suspend fun setDark(v: Boolean) {
context.prefs.edit { it[DARK_MODE] = v }
}// user_prefs.proto - the Proto flavor
message UserPrefs {
bool dark_mode = 1;
string locale = 2;
int32 font_scale = 3;
}Both flavors share the properties that make DataStore the right replacement for SharedPreferences:
- Coroutine-native. Reads are
Flow; writes aresuspend. Noapply()vscommit()confusion. - Atomic writes. Each
editblock is a transaction over the file; partial writes never leak. - Serialized via mutex. Concurrent writes are queued, not interleaved. No torn state.
- Flow-based observation. The UI subscribes to
dataand recomposes when anything changes. - Crash recovery. A corrupt file surfaces as
IOExceptionon the Flow; catch and re-emit a default rather than crashing the app.
val prefs: Flow<UserPrefs> = context.userPrefsStore.data
.catch { e -> if (e is IOException) emit(UserPrefs.getDefaultInstance()) else throw e }The SharedPreferences-to-DataStore migration is well-supported. SharedPreferencesMigration reads the old xml file on first access, writes the values into DataStore, then deletes the old file:
val Context.prefs by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "legacy_prefs"))
},
)One nuance for cold-start UX: dark-mode-style settings that gate setContent should be a hot StateFlow started Eagerly, not WhileSubscribed. Otherwise you flash the wrong theme for one frame on launch.
Sources: DataStore guide
Encrypted storage
Auth tokens, session IDs, refresh tokens, anything a rooted device or a pulled backup should not reveal - these go through EncryptedSharedPreferences or EncryptedFile. The substrate is Tink (Google's crypto library) wrapping a master key stored in the Android Keystore, where cryptographic keys live in hardware and can't be extracted.
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val secure = EncryptedSharedPreferences.create(
context, "secure_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)EncryptedSharedPreferences is API-compatible with SharedPreferences - same getString / putString / listener model - but every value is encrypted with AES-256-GCM and every key with AES-256-SIV (a deterministic mode so the same key always maps to the same ciphertext, otherwise you couldn't look anything up). The actual encryption key never leaves the TEE.
For larger sensitive blobs (a downloaded credential bundle, a PDF), use EncryptedFile - same key-management story, file API instead of preferences.
Whole-DB encryption versus per-record encryption is a real choice for Room:
- Whole-DB (SQLCipher). Replace SQLite with SQLCipher; Room sees the same
SupportSQLiteOpenHelper. Every page is encrypted on disk. Indexes, query plans, FTS internals - all encrypted. The cost is a measurable read-path overhead (5-15% in practice). - Per-record. Encrypt one sensitive column with Tink before insert, decrypt on read. Cheap when only a few columns are sensitive. Breaks
WHERE encryptedCol = ?(every query would have to scan and decrypt) - use only for fields you read whole.
For "the whole user database is sensitive" (messengers, password managers), whole-DB. For "this one column holds the IBAN," per-record.
The mistake to avoid: writing your own AES wrapper around SharedPreferences. EncryptedSharedPreferences exists because everyone who rolled their own got the IV reuse, key storage, or AAD wrong.
MMKV
MMKV is WeChat's open-source key-value store. It's not a Jetpack library; it's a C++ implementation that uses mmap to map the storage file directly into process memory. Reads are pointer dereferences. Writes are appends to the mapped region, flushed by the OS.
MMKV.initialize(context)
val mmkv = MMKV.defaultMMKV()
mmkv.encode("crash_count", 42)
mmkv.encode("last_session_id", "abc123")
val count = mmkv.decodeInt("crash_count")The properties that matter:
- Single-digit-microsecond writes. Faster than
SharedPreferences.apply()(which is async) and orders of magnitude faster than DataStore (which serializes through a mutex and a Flow). - Append-only on the same key. MMKV writes new values to the end of the file; the latest write wins on read. A background
trimreclaims space. - Cross-process safe. Optional multi-process mode coordinates via flock.
- Optional AES encryption. A passphrase wraps every value; the encryption key still has to come from somewhere safer (Keystore, via
EncryptedSharedPreferences).
The cost is durability semantics. MMKV's writes hit memory-mapped pages, which the kernel flushes lazily. Because those pages are kernel-owned, a process crash does not lose them - the kernel still flushes eventually. An OS-level crash (kernel panic, power loss) before that flush is what loses unflushed bytes. DataStore writes through fsync; a crash leaves the file consistent up to the last completed edit. For UI settings, you'd never notice. For ten thousand crash breadcrumbs a minute, MMKV's throughput is the reason it exists.
Where MMKV earns its keep:
- Analytics SDKs and breadcrumb buffers - high write rate, durability of any one event is not critical.
- Feature-flag caches - read on the hot path, written once per fetch.
- Per-screen state caches that need to survive process death but not a kernel panic.
Where it doesn't:
- Anything you'd be sued over losing. Payment confirmations, message drafts, sync cursors. Use Room with a transaction.
- Relational queries. It's a key-value store; there's no "join" or "where."
- Reactive UI. No Flow integration; you'd wrap it manually, badly.
Sources: MMKV (WeChat)
Model per layer
The instinct to reuse one data class across the network, the database, and the UI is one of the most common architecture smells in mobile. The canonical shape is three models, one per layer:
- Network DTO (e.g.,
NetworkAuthor) - mirrors the API contract verbatim. Carries the serialization library's annotations, fields the server returns that the app never reads, and nullable types wherever the server might omit a field. - Local entity (e.g.,
AuthorEntity) - mirrors the Room schema. Carries@PrimaryKey,@ColumnInfofor indexes and defaults, table-shape constraints. Annotations are Room's, not Retrofit's. - Domain model (e.g.,
Author) - plain Kotlin. The shape the business logic and UI want. No framework annotations on this layer; it survives an API rewrite or a database swap untouched.
Conversion between layers is one extension function per direction (fun NetworkAuthor.asEntity(), fun AuthorEntity.asExternalModel()). This mapper pattern is the named convention an interview panel listens for - "I'd add a mapper function" beats "I'd add a method" by a step on the architecture-vocabulary rubric.
Why three not one: each layer changes for different reasons. A renamed JSON field at the server, a new index in Room, a new field exposed to the UI - in the three-model layout, each of those is a one-file change that doesn't ripple. With one shared model, every change ripples everywhere, and either the database fights the server schema or the UI sees raw API shapes.
The decision rule on whether to collapse layers: collapse only when the model would be byte-identical anyway (a single boolean in DataStore). Whenever schemas differ in any way - field naming, null shape, derived fields - keep them separate. Mappers are cheap; coupling the layers is not.
The same layer discipline applies to auth: keep DAOs free of subscription or requiresPremium-style checks. Paywall and persistence are different layers; the Repository or use case combines them. A DAO that reads user auth state to gate a row forces every test to stand up a fake auth provider just to read data.
Sources: Data layer (model per layer is in the "Represent business models" section)
Picking the right one
The decision table interviewers expect, by workload:
| Workload | Pick | Why |
|---|---|---|
| A single keyed value (theme, locale, "has-onboarded") | Preferences DataStore | Flow-native, atomic, no schema. |
| A structured typed config (user settings with 6+ fields) | Proto DataStore | Schema evolves; generated types prevent string-key drift. |
| Relational data with reactive UI (posts, messages, queue rows) | Room | Single source of truth; Flow DAOs power Repository observe. |
| Large dataset with paging | Room + Paging 3 | PagingSource<Int, T> integrates natively. |
| Auth token, refresh token, session ID | EncryptedSharedPreferences | Keystore-backed; correct AEAD; standard practice. |
| Whole DB sensitive (messenger, password vault) | Room + SQLCipher | Encrypts pages, indexes, FTS - everything on disk. |
| High-throughput append (breadcrumbs, analytics, JS console logs) | MMKV | Mmap throughput; lossy crash semantics acceptable. |
| Sync cursor / pagination state / queue head pointer | Room (in withTransaction with the data) | Atomic with the data it indexes. |
| Cached HTTP responses | OkHttp cache, not persistence layer | The HTTP layer owns its cache; see Caching strategies. |
A pattern that matters once apps get large: multiple stores, one per concern. A messenger might have Room for messages and queues, Proto DataStore for user preferences, EncryptedSharedPreferences for the auth token, and MMKV for the trace buffer. They don't fight each other - they live in different files, with different durability and access patterns. The Repository layer hides which one any given piece of state lives in.
The hard-won rule: pick the cheapest tool that satisfies the durability and reactivity needs. DataStore for "a setting." Room for "data that has shape." EncryptedSharedPreferences for "a secret." MMKV for "a firehose." SharedPreferences for "an existing codebase I'm migrating off of."
See also
- Architecture - the Repository observe/refresh contract that consumes all of these stores; the
Flow<List<T>>DAO is what makes it work. - Concurrency - suspend DAOs, cold Room Flows,
stateIn(WhileSubscribed(5_000))for screen state. - Security and Crypto - Android Keystore, TEE-backed keys, biometric-gated key use; the layer under
EncryptedSharedPreferences. - Background Work and Scheduling - WorkManager persists its own queue to Room; long-running uploads write progress back to your Room tables.
- Caching strategies - in-memory caches sit in front of Room; HTTP caching is OkHttp's job, not the persistence layer's.
- Offline-first and sync - the design playbook above Room: local DB as source of truth, write strategies, pull vs push sync, conflict resolution.
Used in
- Crash Reporter SDK - breadcrumbs go to MMKV (high write rate, lossy on hard crash acceptable); the upload queue and session metadata go to Room with a transaction so the uploader can resume after process death.
- Photo Gallery App -
MediaEntityandUploadRecordlive in Room with@Relationjoins; theUploadWorkerobserves aFlow<UploadRecord>for progress; user preferences (sort order, grid density) live in Proto DataStore. - Messenger App - messages and conversations in Room (with SQLCipher for whole-DB encryption); FTS5 virtual table for search; auth tokens in EncryptedSharedPreferences; typing-debounce config in Preferences DataStore.
- Newsfeed App - Room is the source of truth feeding Paging 3; the feed cursor lives in the same transaction as the page; "seen post" set in Proto DataStore.
- Analytics SDK - event ring buffer in MMKV for write throughput; flushed in batches to Room when the network worker runs, so a queued batch survives a kernel kill.
- Doc Editor App - the per-keystroke write path commits an op plus the CRDT tail in one Room transaction, affordable only because WAL turns each commit into an append (Deep Dive 2).
Done reading? Mark it so it sticks in your dashboard.