Commercial risk
In an offline field-capture app, the local store is the only copy of the data — there is no server to reconcile against. A read-modify-write race doesn't degrade the data; it deletes it, silently, and the user who saw “Saved” has no reason to suspect a day of interviews is gone.
The cost is total and unrecoverable for the affected records: no exception is thrown, no audit trail exists, and because the data never left the device there is nothing anywhere to restore from. The first sign of the loss is a researcher, or an auditor, noticing that entries that were definitely captured are simply not there.
Local-first and offline-first apps lean on simple key-value stores — SharedPreferences, UserDefaults, a Hive box — because they're synchronous-feeling, dependency-free, and more than fast enough. The trap is that these stores offer a single primitive: put a value at a key. To update one item in a collection, you load the whole collection, change it in memory, and write the whole thing back. That load-mutate-store sequence is a read-modify-write, and read-modify-write without coordination is the oldest race in computing.
It survives review because a single save works perfectly every time. The race needs two operations overlapping — a rapid sequence of captures, a save colliding with a delete, a write interrupted by the app being killed — which is exactly the messy reality of fieldwork and exactly what the developer's calm test never reproduces.
1. The lost update: two saves, one survivor
Picture a field app that stores sessions, till transactions, interview responses, and photos each as a JSON list under its own key. Adding a record means: read the list, insert the new item, write the list back. Now imagine two of these happening close together — the user taps save on one form while an autosave fires on another. Both read the list at version N. Both insert their item into their in-memory copy. Both write back. The second write wins completely, and the first item — fully captured, confirmed to the user — is gone, because it was never in the list the second writer serialised.
Nothing detects this. There is no version field to notice the conflict, no unique constraint to reject the overwrite, no error to log. The store did exactly what it was told twice; it's the application that lost track of the fact that the two operations shared state. The data loss is a direct, silent consequence of treating a whole-collection write as if it were an isolated update.
// Every save is a read-modify-write of the ENTIRE collection.
Future<void> addTill(TillTx tx) async {
final list = _decode(_prefs.getString(_kTill)); // read version N
list.insert(0, tx); // modify in memory
await _prefs.setString(_kTill, _encode(list)); // write — clobbers
// A concurrent addTill() also read N and will overwrite this write,
// silently dropping whichever item lost the race.
}2. The torn write: killed between mutate and store
The second failure needs only one operation. A multi-step capture — create an interview, then append each answer — is a sequence of independent writes with no transaction around them. If the device is backgrounded, the battery dies, or the app crashes partway through, you land in a state the app believes is impossible: an interview that claims fifteen answers with eight actually persisted, or a parent record deleted while its children survive as orphans.
Because these stores have no concept of a transaction spanning multiple keys, there is no atomic boundary to roll back to. Each `setString` either happened or didn't, independently, and the app has no record of how far it got. On next launch it reads a structurally inconsistent snapshot and treats it as ground truth.
3. Serialise writes, make updates atomic, add a version
The first fix removes the concurrency: funnel every mutation through a single writer so no two load-mutate-store cycles can interleave. A mutex or a serial queue around the store turns overlapping saves into ordered ones, and the lost update simply cannot occur because there is never more than one in-memory copy being edited at a time.
The second fix removes the whole-collection write. Use a store that supports per-record keys and atomic operations — a proper embedded database (SQLite, Isar, an indexed Hive box keyed by record ID) — so updating one item touches one row, not the entire list. Concurrent writes to different records no longer contend, and a crash can lose at most the single record being written, not the collection around it.
The third fix makes conflicts detectable: give each record a monotonically increasing version or a last-writer-wins timestamp, and on write, assert you're updating the version you read. When the assertion fails you've caught a concurrent modification instead of silently discarding one — and in a local-first app that later syncs, that same version field is what lets the server reconcile devices instead of blindly overwriting.
// One serial writer + per-record atomic upsert + version guard.
final _writes = <Future>[];
Future<void> upsertTill(TillTx tx) {
final op = () async {
final existing = await _db.get(tx.id); // one record, not the list
if (existing != null && existing.version != tx.baseVersion) {
throw ConcurrentModification(tx.id); // caught, not swallowed
}
await _db.put(tx.id, tx.copyWith(version: tx.baseVersion + 1));
}();
// Chain writes so load-mutate-store cycles never interleave.
final tail = (_writes.isEmpty ? Future.value() : _writes.last).then((_) => op);
_writes.add(tail);
return tail;
}A key-value store that only knows how to put a value at a key will let you build a collection out of whole-blob writes, and it will let those writes silently destroy each other. In an app with a server behind it that's a bug you eventually reconcile. In a local-first app where the device holds the only copy, it's permanent, invisible data loss reported to the user as success.
Serialise your writes so they can't interleave, store records individually so an update is atomic, and version every record so a conflict is something you catch rather than something you lose. The store is simple on purpose — the correctness has to be engineered on top of it, because it will not be handed to you.