← All problems

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.

Free~21 min

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:

AxisOne sideThe other
SchemaTyped (compile-checked)Untyped (string keys)
ReadsReactive (Flow)Imperative (read-once)
StorageSQLite (relational)Key-value (flat)
EncryptionEncrypted at restPlaintext on disk
DurabilityTransactional (fsync)Best-effort (mmap)

All five files live under /data/data/<pkg>/; the relative path tells you which subsystem owns them.

OptionLocation on diskFile typeContent structureReactiveEncryptedBest for
Roomdatabases/<name>.dbSQLite binaryTables, rows, indexes, FTSFlow<T>No (use SQLCipher)Relational data, single source of truth
Preferences DataStorefiles/datastore/<name>.preferences_pbProtobuf binaryTyped-key → Any mapFlow<Preferences>NoSimple flags, single typed values
Proto DataStorefiles/datastore/<name>.pbProtobuf binaryOne generated messageFlow<T>NoStructured config that evolves
EncryptedSharedPreferencesshared_prefs/<name>.xmlXML, encrypted valuesKey-value (AEAD per entry)Listener-basedYes (Tink + Keystore)Auth tokens, session IDs
MMKVfiles/mmkv/<id>mmap-backed binaryAppend-only key-value logNoOptional (AES)High-throughput logs, analytics, breadcrumbs
Warning
Legacy: 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:

ToolThink of it asWhat lives there
RoomThe local databaseData with shape - tables, joins, queries
DataStoreThe settings fileFlags and structured config
EncryptedSharedPreferencesThe keyringSecrets - tokens, session IDs
MMKVThe append-only logFirehose 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). REPLACE deletes then re-inserts - it resets rowid, breaks foreign keys, and fires delete callbacks. @Upsert issues a true INSERT ... 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.
  • @Relation for joins - a data class PostWithComments with @Embedded post and @Relation comments, 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 Flow queries 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.

Note
One transaction per event is fine; one fsync per event is not - WAL is the reason the first doesn't imply the second. When a design needs per-event durability at typing speed (the doc editor's keystroke path, an analytics outbox), the answer is Room in WAL mode: the commit is an append, and the checkpoint amortizes the flush.

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 COLUMN is 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 @Database annotation. The author still has to specify renames via @RenameColumn because 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 are suspend. No apply() vs commit() confusion.
  • Atomic writes. Each edit block 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 data and recomposes when anything changes.
  • Crash recovery. A corrupt file surfaces as IOException on 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 trim reclaims 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, @ColumnInfo for 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:

WorkloadPickWhy
A single keyed value (theme, locale, "has-onboarded")Preferences DataStoreFlow-native, atomic, no schema.
A structured typed config (user settings with 6+ fields)Proto DataStoreSchema evolves; generated types prevent string-key drift.
Relational data with reactive UI (posts, messages, queue rows)RoomSingle source of truth; Flow DAOs power Repository observe.
Large dataset with pagingRoom + Paging 3PagingSource<Int, T> integrates natively.
Auth token, refresh token, session IDEncryptedSharedPreferencesKeystore-backed; correct AEAD; standard practice.
Whole DB sensitive (messenger, password vault)Room + SQLCipherEncrypts pages, indexes, FTS - everything on disk.
High-throughput append (breadcrumbs, analytics, JS console logs)MMKVMmap throughput; lossy crash semantics acceptable.
Sync cursor / pagination state / queue head pointerRoom (in withTransaction with the data)Atomic with the data it indexes.
Cached HTTP responsesOkHttp cache, not persistence layerThe 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 - MediaEntity and UploadRecord live in Room with @Relation joins; the UploadWorker observes a Flow<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.