Skip to content
All posts

Local-first Android in 2026: SQLite, Room, and keeping user data on the device

A 2026 guide to building local-first Android apps with Room and SQLite — schema design, migrations, WAL, exports, and when (and when not) to add sync.

MFKAPPS 7 min read

The default Android app architecture in 2026 still assumes a backend. Auth, sync, push, a server to call when the network is up. For a lot of apps that’s the right shape. For a lot of others it’s just architecture you have to maintain forever — and a data flow your privacy policy has to defend.

I build the opposite: local-first Android apps where the device is the source of truth. Granyn (budget), Hydrame (hydration), Subly (subscriptions) all run that way. No accounts, no cloud database holding your data, no servers I have to keep up at 3 a.m. This post is the practical 2026 version of how I do it.

What “local-first” actually means

Local-first is a stronger claim than “works offline”:

  • The device owns the data. Reads and writes go to the local DB first; the UI never blocks on a network call.
  • The app is fully functional with airplane mode on. Including features that, in a cloud-first app, would silently disable.
  • Sync, if it exists, is end-to-end encrypted between the user’s own devices — the server is a relay, not a record.
  • Export is a feature, not a hack. Your data, in a format you can take elsewhere.

For a single-device productivity app — a tracker, a journal, a budget — that’s all you need. The server is a maintenance burden you choose not to take on.

Local-first is the architecture of “we can’t lose your data because we never had it.”

Room in 2026: the boring choice that aged well

For local storage on Android in 2026, Room is still the right answer. It’s a thin layer over SQLite with compile-time SQL checking, coroutines + Flow integration, and KSP (no more annotation-processor wait times). It has been stable enough that posts written years ago still apply, and the small additions since (auto-migrations, @MapColumn, easier multi-process support) are all in the right direction.

A minimal setup:

@Entity(tableName = "subscriptions")
data class SubscriptionEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val service: String,
    val amountMinor: Long,      // store cents, never floats
    val currency: String,        // "TRY", "EUR", "USD"
    val cycle: BillingCycle,
    val nextChargeDate: LocalDate,
    val createdAt: Instant = Instant.now(),
)

@Dao
interface SubscriptionDao {
    @Query("SELECT * FROM subscriptions ORDER BY nextChargeDate ASC")
    fun observeAll(): Flow<List<SubscriptionEntity>>

    @Query("SELECT SUM(amountMinor) FROM subscriptions WHERE currency = :currency")
    fun totalForCurrency(currency: String): Flow<Long?>

    @Insert
    suspend fun insert(s: SubscriptionEntity): Long

    @Delete
    suspend fun delete(s: SubscriptionEntity)
}

A few things I always do, and would argue for:

  • Store money as an integer minor unit. amountMinor: Long instead of Double. Floats and currency don’t mix; a single rounding bug will cost you a week.
  • Use Instant and LocalDate from kotlinx-datetime with a TypeConverter. The JDK java.time.* types work too, but kotlinx-datetime is friendlier to multiplatform if you ever go that way.
  • Flow everywhere from the DAO. The UI subscribes; the app reacts to writes automatically without you wiring listeners.

Migrations: think of the DB as a public API

The single most common reason indie apps lose data is a botched migration. Treat your schema like a public API: every change is versioned, tested, and irreversible.

Room makes this almost easy:

@Database(
    entities = [SubscriptionEntity::class],
    version = 3,
    autoMigrations = [
        AutoMigration(from = 1, to = 2),
        AutoMigration(from = 2, to = 3, spec = AddNotesField::class),
    ],
    exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()

@RenameColumn(tableName = "subscriptions", fromColumnName = "note", toColumnName = "notes")
class AddNotesField : AutoMigrationSpec

Two settings to lock in on day one:

  1. exportSchema = true + commit the generated JSON files in schemas/ to git. Then add a RoomDatabaseSchemaTest that loads each historical schema and migrates forward. This is the cheapest insurance you’ll ever buy.
  2. Never edit a published migration. If you find a bug, write a new migration that fixes it. The schema is a forward-only ledger.

The pattern that has saved me twice: do all destructive transformations as a MIGRATION with raw SQL, not auto-migration, so I can copy old rows into a temp table, rebuild, and confirm counts match before committing the migration.

WAL and other small SQLite wins

Room defaults to write-ahead logging (WAL) on Android, but it’s worth knowing what that means: writers don’t block readers, fsync is cheaper, and the app feels noticeably snappier on lists that re-query as the user types. A few small SQLite-level tweaks that are still worth a line:

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .setQueryExecutor(Dispatchers.IO.asExecutor())
    .setTransactionExecutor(Dispatchers.IO.asExecutor())
    .addCallback(object : RoomDatabase.Callback() {
        override fun onOpen(db: SupportSQLiteDatabase) {
            super.onOpen(db)
            db.query("PRAGMA journal_mode=WAL;").use { it.moveToFirst() }
            db.query("PRAGMA synchronous=NORMAL;").use { it.moveToFirst() }
        }
    })
    .build()

synchronous=NORMAL with WAL is a safe-enough default for an on-device app where you’re not running a bank ledger. If your data is literally a bank ledger, leave it on FULL.

For lists, index the columns you sort by. The number of indie apps I’ve seen with a WHERE created_at > query and no index on created_at is too high. It’s free performance you’re not taking.

Exports are a feature, not a panic button

Every local-first app should ship with a one-tap export. Three reasons:

  1. It builds trust. “Your data is yours” stops being marketing the moment a user has a CSV on their phone.
  2. It’s free portability. No more “but how do I move to another app?” support tickets.
  3. It’s a useful backup story without you running a backup service.

A minimal CSV export from a Flow-of-entities:

suspend fun exportCsv(uri: Uri, context: Context, dao: SubscriptionDao) {
    val rows = dao.observeAll().first()
    context.contentResolver.openOutputStream(uri)?.use { out ->
        out.bufferedWriter().use { w ->
            w.appendLine("service,amount,currency,cycle,nextDate")
            rows.forEach {
                w.appendLine("${it.service},${it.amountMinor / 100.0},${it.currency},${it.cycle},${it.nextChargeDate}")
            }
        }
    }
}

Pair it with import and the user can move between devices by emailing themselves a CSV. Not glamorous; works perfectly.

When sync starts to actually matter

Local-first doesn’t mean no sync. It means the device is the source of truth. Sync, when you add it, has to keep that invariant true.

The grown-up options in 2026:

  • CRDT-based sync (Yjs, Automerge) for collaborative or many-device cases. Heavy for a single-user app.
  • End-to-end encrypted blob sync for the “two devices, same user” case. Encrypt locally, the server stores opaque bytes, decrypt locally on the other device. Tailscale-style identity, iCloud / Drive APIs as transports, or your own tiny relay.
  • Just file-based for many cases. Export to Google Drive on a schedule, restore on the new device. Not as smooth as live sync, but no servers to run, no E2EE primitives to get wrong.

For Granyn and Hydrame I shipped without sync. Most users have one phone and they keep it for years; sync turns out to be far less important than people assume in product discovery interviews. I’d rather not have it than ship a half-broken version.

The shape of a local-first day

Every indie shop has its own variant, but mine looks like this:

  1. UI is Compose, observes Flows from the repository.
  2. Repository wraps the DAO with mapping to/from domain types.
  3. DAO is the only thing that touches Room.
  4. WorkManager does periodic local jobs (notification scheduling, daily total recompute) — never network.
  5. DataStore holds settings, never user data.

There’s a Repository, a single database instance, and the rest of the app talks to coroutines and StateFlow. No LiveData in new code, no RxJava anywhere. The dependency graph fits on a napkin.

What you give up, what you get

Things you give up by going local-first:

  • Cross-device sync without engineering work
  • Server-side analytics on the actual content (and the temptations that come with that)
  • Easy “share with a friend” features
  • The marketing buzzword of “AI on your data” if your AI is cloud-based

Things you get:

  • A privacy policy that practically writes itself
  • Zero server costs forever
  • Apps that work in airplane mode and feel instant
  • Users who trust you because the architecture earns it

For an indie app shipping in 2026, that’s the right trade. The cloud-first stack is impressive, but it’s also two extra jobs you didn’t sign up for: SRE and customer support for data loss. Local-first removes both. You become a person who ships apps, not a person who runs servers and also ships apps when they have time.

If you’ve been on the fence about an indie Android idea because the cloud part feels overwhelming, I’d just skip it. Start local. Add sync later, if you ever actually need it. Most of the time, you won’t.