Home / Blog / Offline-first

Offline-first: why the app owns the data.

Picture the person Soil Cub is built for: a grower standing in the middle of a field, phone in one hand, a soil meter pushed into the ground with the other. There is no Wi-Fi. Cellular is one bar, or none. And they still expect every reading they take to be captured, charted, and safe.

That single constraint — assume there is no network — shaped the entire architecture of the app. This is how I approached it, and why "offline-first" is a decision you make on day one, not a feature you bolt on later.

The wrong mental model

The tempting design is to treat the app as a thin client: read from the sensor, POST to a server, read the data back for display. It's familiar, and it's exactly wrong here. The moment the network is the source of truth, every screen depends on connectivity the user doesn't have. In a field, that app is a blank screen.

So I flipped it. In Soil Cub, the local database is the single source of truth. The network, when it exists, is an optional downstream — never something the core experience waits on.

The data flow

Every reading takes the same one-way path:

  1. The meter takes a measurement and sends it over Bluetooth LE.
  2. A BLE service layer parses the payload and hands it to a repository.
  3. The repository writes it into Room (SQLite).
  4. The UI observes Room — never the BLE layer directly — and updates itself.

The important rule is the last one: the UI never reads from Bluetooth. It reads from the database. BLE's only job is to get data into the database. This one boundary is what makes the app feel instant and behave predictably, because the screen is always rendering local state that is already on disk.

In practice the DAO exposes a reactive stream, and the screen collects it:

@Dao
interface ReadingDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(reading: ReadingEntity)

    // The UI subscribes to this. New rows appear automatically.
    @Query("SELECT * FROM readings WHERE sessionId = :id ORDER BY takenAt")
    fun observeSession(id: Long): Flow<List<ReadingEntity>>
}

When a BLE packet arrives, the repository just writes it. The chart is already listening, so it redraws on its own — no manual refresh, no round-trip:

// BLE callback → parse → persist. That's the whole job.
fun onReadingReceived(raw: ByteArray) {
    val reading = ReadingParser.parse(raw)
    scope.launch { readingDao.insert(reading) }
}

BLE is messier than it looks

Bluetooth LE in the real world is not a tidy pipe. Connections drop when the phone goes in a pocket. Devices sleep to save battery. Packets can arrive out of the order you expect. If any of that could lose a reading, the app would fail the one promise that matters.

So the BLE layer owns the mess: pairing, reconnection, and retries live in one place, behind an interface the rest of the app doesn't have to think about. When the link drops mid-session, it reconnects and resumes. A reading is only ever considered "taken" once it is committed to Room — not when it's received over the air.

Persist first, display second. If it isn't in the database, it didn't happen.

The payoff: export and backup come for free

Here's the part I like most. Because the database is already the source of truth, two features growers genuinely need became almost trivial:

  • Export — "give me this season's data" is just a query over rows that already exist locally.
  • Backup & restore — a season of field data is irreplaceable, and backing it up is fundamentally about moving a database that's already on disk, rather than reconstructing state from a server.

None of that required a special subsystem. It fell out of the original decision to let the app own its data.

The takeaway

Offline-first isn't a toggle. It's the choice to make local state authoritative and treat the network as a bonus. Get that boundary right on day one and the hard things — reliability with no signal, instant UI, export, backup — stop being hard. Get it wrong, and no amount of later patching fully fixes it.