Prep
Engineering · May 19, 2026

Offline sync without losing your sanity

The Drift schema, the conflict-resolution rules, and the one design decision that kept everything calm.

K
A. Krishnan
Co-founder
13 min read May 19, 2026

A surprising number of students prep on the train. On Mumbai locals, on Tokyo subway, on the Bengaluru–Mysuru highway in a Volvo with patchy 4G. If our app silently drops their attempts when the network goes away, we have failed at the only job that matters.

This post is about how the mobile app handles offline — the Drift (sqlite) schema, the sync rules, and the one design decision that kept the whole thing calm.

1 · The local-first principle

Every write the app makes — answering an MCQ, bookmarking a note, marking a topic as "stop suggesting" — goes to the local sqlite database FIRST. Always. Synchronously. The UI updates from the local DB.

A background task drains a queue of "pending attempts" to the API whenever there's connectivity. The server's response updates the local DB; if the network is down or 500's, the row stays in the queue. The user doesn't see any of this.

2 · The Drift schema (mobile/lib/data/database.dart)

@DataClassName('PendingAttempt')
class PendingAttempts extends Table {
  TextColumn   get id           => text().clientDefault(() => _uuid())();
  TextColumn   get userId       => text()();
  TextColumn   get questionId   => text()();
  TextColumn   get chosen       => text()();    // 'A'..'D' (null = skipped)
  BoolColumn   get correct      => boolean()();
  IntColumn    get timeMs       => integer()();
  DateTimeColumn get attemptedAt => dateTime()();
  BoolColumn   get synced       => boolean().withDefault(const Constant(false))();
  @override
  Set<Column> get primaryKey => {id};
}

The id is client-generated (UUIDv4). The server accepts that id verbatim — so a retry that re-uploads the same attempt is a no-op, not a duplicate.

3 · The conflict rule that kept us sane

Almost every offline-sync horror story is the same: device A and device B make conflicting writes, both go online, neither side knows who wins. The fix is to design conflicts out, not resolve them.

Our rule: <b>attempts are immutable</b>. Once a student has answered Q42, that answer is final. Different devices can attempt different questions; the same question on two devices in the same session is treated as two attempts (the server keeps both and the analytics use the first one for mastery). No conflict to resolve.

For bookmarks: last-write-wins with a server timestamp. For chat messages: client-generated id, server keeps the first arrival. For notes (editable): full CRDT (Y.js) — but we don't have collaborative notes in v0, so this is for P2.

4 · What gets prefetched, when

On Wi-Fi, the app silently prefetches up to 7 days of upcoming content: the next MCQs the AI plans to surface, the study-plan week, the bookmarked notes, the today/tomorrow tournament rules.

On metered connections (cellular flagged metered), prefetch is off. The user can override in Settings → Storage if they have a generous plan. We respect data plans by default — what a "free" prefetch costs the student varies wildly by country and carrier.

5 · The cache-miss UX

When the app needs content it doesn't have offline (e.g., a video lesson outside the prefetch window), the screen shows a small "downloading" pill instead of a spinner. Three seconds in, the pill expands: "still downloading… here's a related cached note while you wait". A real progressive enhancement.

The worst UX in offline-aware apps is the blank-screen-then-error pattern. We engineered around it by always having SOMETHING to show — even if it's a different topic from the same subject.

6 · Sync visibility

There's a tiny dot in the top-right of the home screen: green = synced, amber = pending queue, red = sync failed > 1 hour. Tapping it opens a one-screen view of what's in the queue and the last sync time. Users who care about it will look; everyone else can ignore it.

We deliberately don't show a "you're offline!" banner. Users on flaky networks already know. Pop-ups telling them what they can already see are insulting.

7 · Tests we ended up needing

Three tests caught real bugs: (a) flush 10k pending attempts in one batch (memory bound); (b) sync mid-flight when the app is killed (transaction must commit or roll back, never half); (c) clock skew between client and server (server timestamps win for ordering).

The 10k-batch test was the most important. Real users come back from a 2-week study camp with 8,000 queued attempts. The naive "iterate + http per attempt" approach took 14 minutes. Batched POST of 200 at a time: 22 seconds.

"Offline-first is not a network feature. It's a UX choice: assume the user is offline and let the network be a happy accident."