When Sync Isn't the Bug: a Day-Card Write Race
I filed a bug that looked like a sync problem.
On Migrainular’s Today screen, you can type sleep hours on the day card, then tap the water control. Sometimes the sleep field snapped back to empty. My first note in the issue was basically: server sync is too eager — maybe add a Save button like diary entries.
That diagnosis was half right about the feeling, and wrong about the mechanism.
The setup
Migrainular is local-first. Days and diary entries live in IndexedDB (Dexie). Edits mark a row pending, then a background sync pushes and pulls against the server using last-write-wins on client_updated_at.
The day card is meant to be fast when you are not feeling well: bump water, chip caffeine, toggle a preventive. Text fields like sleep and food auto-save on change. Diary attacks are different — you edit a form and hit Save. That difference matters.
We already had a guard so background sync would not overwrite an input while you were still focused in it. That helped with “typing while weather or sync refreshes.” It did not help with this bug.
The repro that lied
- Type a sleep value.
- Tap + water.
- Sleep resets.
From the outside: you edited, something synced, the UI reverted. Classic “eager sync” story.
What actually happens on that tap:
- Focus leaves sleep →
changefires →patchDay({ sleep_hours }). - Click fires →
patchDay({ water_intake }). - Both paths do a read-merge-write on the same day row.
- The UI hydrates inputs from whatever lands in IndexedDB after the write.
Those two patchDay calls race.
The real bug
saveDay looked innocent:
- Load the current day from IndexedDB.
- Merge in a partial patch (
sleep_hoursorwater_intake). - Write the whole row back with a new
client_updated_at.
If sleep and water both start from a snapshot where sleep is still null:
- Sleep write wins first →
{ sleep: 8, water: 0 } - Water write finishes second from the stale snapshot →
{ sleep: null, water: 8 }
Sleep is gone. Sync then helpfully ships that loser to the server, and the hydrate effect paints the empty field, because by then you are no longer “typing” — you tapped a button. The typing guard correctly stood down. The data underneath was already wrong.
So yes, sync made the bad state visible and durable. It did not invent the bad state. The race lived on one device, in one database, between two partial writes.
The fix
I did not turn the day card into a Save button. Diary entries earn an explicit Save. Water taps should stay one-handed and cheap. The product constraint was: keep the fast path, stop losing drafts.
Two boring changes:
- Serialize
saveDayper day. Concurrent callers queue. Each write reads the latest row after the previous write finishes, so partial patches compose instead of clobbering. - Flush draft inputs before chip and button patches. Water, caffeine chips, preventives, and cycle toggles commit whatever is sitting in the sleep/food/etc. fields first, then apply their own update from that fresh day. Increments use a callback on the post-flush record so “+coffee” does not compute against a stale
daywhile the input still shows a draft.
Sync still runs after saves. Hydrate still refreshes the form from the day record. Those are fine once the local write path stops racing.
What I’d look for next time
If a form field “resets after sync,” ask:
- Did two writes hit the same document with partial patches?
- Does blur plus click fire two async saves in the same gesture?
- Does the UI rehydrate from storage as soon as focus leaves the field?
- Is the “sync” step mostly amplifying a local loser?
Local-first apps blur the line between UI state, disk state, and server state. That blur is a feature until a race makes all three agree on the wrong answer.
Sync was not the bug. It was the messenger.
Try Migrainular at migrainular.com — pain, save, done.