Before EduCore, I thought offline-first was a checkbox. An architecture decision you made early, documented in the spec, and then moved on from. Add WatermelonDB, handle sync conflicts, done.
I was wrong. Offline-first is a mindset that rewires how you think about every interaction in your application. It forces you to answer questions that online-first apps never have to ask.
The question offline-first forces you to answer
In an online-first application, the question is always "what do we show while we're loading?" In an offline-first application, the question becomes: what does this action mean when no one is watching?
When a student taps "save" on their assignment at 11pm with no signal, what exactly happens? Where does the data go? How confident can you make them feel that it won't disappear? What do you tell them when sync runs in the morning and there was a conflict with a teacher's update?
These aren't UX questions. They're product philosophy questions that have to be answered before the schema is designed.
The three questions that changed how I designed EduCore
What is the source of truth?
In a naive offline-first implementation, the server is always the source of truth and local is just a cache. This breaks immediately when two users edit the same record offline. You need a conflict resolution strategy for every entity type, defined before you write a line of sync code. For EduCore:
- Course content: teacher's write wins on conflict
- Student quiz attempts: student's record is immutable — teacher edits to the quiz never overwrite an in-progress attempt
- Attendance records: merge — the union of all marked attendance across both sides
- Progress percentage: take the maximum across conflicting versions
Getting this right requires understanding the semantics of each entity, not just the technical sync mechanism.
What does "saving" mean?
In an online app, saving means writing to the server. The operation either succeeded or it failed. In an offline-first app, saving means writing locally and queuing for sync. Users understand "saved" as a guarantee, not a queue position. The UX has to close this gap — showing not just whether the local write succeeded, but what the sync state is.
EduCore shows three states: saved locally, synced, and conflict. Most users will only ever see the first two. The third is rare but cannot be hidden when it occurs.
What does "current" mean?
If a student is viewing a course outline that was updated by the teacher two hours ago but hasn't synced, is their view stale? Do you show a staleness warning? EduCore's answer: show the last synced version without a warning for content (staleness tolerance is acceptable), but show a warning for assessments (taking a quiz on an outdated version would be unfair).
WatermelonDB: the right choice for different reasons than I expected
I chose WatermelonDB because of its observable query model — components re-render automatically when underlying data changes, whether from a user action or a background sync. What I underestimated was how much complexity the migration system would add.
WatermelonDB migrations are manual. Every schema change requires a numbered migration file. In a project where the schema evolves rapidly during development, this is significant overhead. Budget for it early. We didn't, and it cost three days of cleanup.
// Sync implementation — EduCore (simplified)
await synchronize({
database,
pullChanges: async ({ lastPulledAt }) => {
const { changes, timestamp } = await api.sync.pull(lastPulledAt);
return { changes, timestamp };
},
pushChanges: async ({ changes }) => {
await api.sync.push(changes);
},
conflictResolver: customConflictResolver, // per-model logic
});
What I'd do differently
Three things I'd change building EduCore from scratch:
- Model the conflict matrix before touching the schema. Document every entity, every actor, every conflict scenario in a spreadsheet. It sounds bureaucratic. It saves weeks of reactive debugging.
- Design sync state indicators into the first prototype. Users need to know their data is queued. A subtle syncing indicator in the nav reduces support confusion dramatically.
- Test on real devices from day one. SQLite performance on a budget Android device is dramatically different from the simulator. We caught this because we were testing on real hardware — but it should have been the default from the start.
Offline-first isn't harder than online-first. It's differently hard. The complexity doesn't disappear — it moves from scattered loading states and error handlers in the UI into a single, well-designed sync layer and conflict resolution strategy. That trade is almost always worth it when you're building for users whose connectivity you cannot control.