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.
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: Longinstead ofDouble. Floats and currency don’t mix; a single rounding bug will cost you a week. - Use
InstantandLocalDatefromkotlinx-datetimewith aTypeConverter. The JDKjava.time.*types work too, but kotlinx-datetime is friendlier to multiplatform if you ever go that way. Floweverywhere 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:
exportSchema = true+ commit the generated JSON files inschemas/to git. Then add aRoomDatabaseSchemaTestthat loads each historical schema and migrates forward. This is the cheapest insurance you’ll ever buy.- 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:
- It builds trust. “Your data is yours” stops being marketing the moment a user has a CSV on their phone.
- It’s free portability. No more “but how do I move to another app?” support tickets.
- 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:
- UI is Compose, observes Flows from the repository.
- Repository wraps the DAO with mapping to/from domain types.
- DAO is the only thing that touches Room.
- WorkManager does periodic local jobs (notification scheduling, daily total recompute) — never network.
- 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.
// Related reading
More from the journal
Privacy-first OCR on Android: how Subly reads bills without the cloud
A practical 2026 guide to on-device OCR on Android with ML Kit Text Recognition — the setup, the code, and the field extraction tricks that keep user data off your servers.
Reliable Android reminders in 2026: WorkManager, exact alarms, and the new battery rules
How to ship reminders on Android in 2026 that actually fire — WorkManager vs AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS, and the OEM quirks that still bite.
The privacy advantage of local-first apps
The most private data is the data you never collect. Local-first isn't just an architecture choice — it's the simplest privacy policy there is.