Back to blog
Article

Resolving conflicts when field staff sync after signal loss

Resolving conflicts when field staff sync after signal loss
S

StriveBit

4 min readMobile Development

Resolving conflicts when field staff sync after signal loss

A maintenance technician opens a work order on a budget Android phone, fills in three hours of observations, walks into a basement, and loses signal. Another technician on the same site gets assigned the same work order through a dispatcher, marks it in-progress, and syncs. When the first technician resurfaces, both edits exist. Deciding which one wins is not something you can hand-wave with "last write wins" — not when the data describes physical work that already happened.

We ran into this pattern on a field-operations app we maintain for a facilities client. The first version did naive last-write-wins on a per-record basis, and it silently dropped the first technician's observations because the second technician's shorter update had a newer server timestamp. The field lead noticed a week later when a report came through missing labor notes.

The fix has two parts: track edits at the field level, not the record level, and give the server enough context to make a real decision.

On the client, every form write goes to a local SQLite table that stores the record ID, the field name, the new value, the timestamp from the device clock, and the user ID. We use WatermelonDB for this because it handles the local queue and gives us a sync engine to hook into. When signal returns, the client sends the pending changes as a batch of field-level operations, not a full record PUT.

The server receives the batch and applies each operation against the current record state. For each field it checks three things: whether the incoming timestamp is newer than the stored field timestamp, whether the stored value has changed since the client last pulled it, and whether the field is one we have marked as mergeable.

Non-mergeable fields — status, assignment, completion flags — use a deterministic priority. A "completed" status always wins over "in-progress" regardless of timestamp, because a completed state represents physical reality. This is a business rule, not a database mechanism, and it lives in the server's conflict resolver.

Mergeable fields — notes, photos, labor hours — append rather than replace. If two technicians both added notes, the server stores both with author attribution and timestamps. The client renders them as a threaded list rather than a single text field.

Here is the core of the server-side resolver we use:

def resolve_field(record, op):
    current = record.fields.get(op.field_name)
    if op.field_name in MERGEABLE_FIELDS:
        record.append_value(op.field_name, {
            'value': op.value,
            'author': op.user_id,
            'at': op.timestamp,
        })
        return
    if op.field_name in PRIORITY_FIELDS:
        if PRIORITY[op.value] > PRIORITY.get(current, -1):
            record.set_value(op.field_name, op.value, op.timestamp)
        return
    if op.timestamp > current.timestamp:
        record.set_value(op.field_name, op.value, op.timestamp)

The client receives the resolved state and a list of conflicts that required merging. For merged fields, it shows a small banner: "2 notes were combined from offline edits." For priority overrides, it shows nothing — the field simply reflects the resolved value. We chose not to surface a conflict-review screen because the field staff do not have time to adjudicate, and the dispatcher can review merged notes from the dashboard later.

One tradeoff worth naming: this design requires every write to carry a client timestamp, and client clocks on cheap Android hardware drift. We accept timestamps from the device but compare them against the server's record of when that client last synced. If the device clock is more than ten minutes off from server time at sync point, we log it and use the server's receive time instead. This is imperfect — it can misorder two edits that happened within the drift window — but the alternative of running an NTP client inside the app is not worth the complexity on devices that already struggle with background tasks.

We do not attempt three-way merges on free-text fields. Early on we tried diffing notes and interleaving them, and the result read like two people talking over each other. Sequential append with attribution is less clever and more useful.

The sync queue itself retries with exponential backoff and survives app restarts through the SQLite table. If a batch partially applies and the connection drops again, the client sends only the unacknowledged operations on the next attempt. We track acknowledgment at the operation level, not the batch level, because a 40-operation batch that fails on operation 31 should not re-send the 30 that succeeded.

For a three-person field crew sharing work orders across a site, this setup has held up through eighteen months of use. The conflicts that matter — status disagreements and lost notes — stopped appearing in support tickets within the first month after we shipped the field-level resolver.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch