A field technician closes a work order on the truck, in a parking garage, with no signal. An hour later they hit a cell tower and the queued changes sync. Meanwhile the dispatcher in the office reassigned the same work order to someone else twenty minutes ago. Two valid sources of truth, two divergent records, one bad afternoon for whoever has to reconcile them. The fix is conflict rules — designed before the first sync, not after the first incident.
The three conflict shapes
Every offline-sync conflict reduces to one of three shapes:
- Last writer wins — the safe default for fields where order does not matter and the most recent value is acceptable.
- Field merge — different sub-fields of the same record were modified on each side; the union is acceptable.
- Hard conflict — the same field was modified on each side with incompatible values; a human must decide.
Most fields fall into shape 1 or 2. Shape 3 is rare in volume but high in impact, and it is the only one that requires a UX.
Per-field policy, not per-record
The wrong design treats a whole record as a conflict unit. “The work order is in conflict; choose mobile version or server version.” This forces a binary choice that loses information, because the mobile version is correct on some fields and the server version is correct on others.
The right design declares a policy per field. Some examples:
state— last writer wins, but only if both writers were valid transitions. Otherwise hard conflict.work_notes— append both. Order them by their respective timestamps. Never overwrite.actual_end— mobile wins. The truck is the source of truth for “when was the work physically completed.”assigned_to— server wins. The dispatcher’s view of who is doing what is authoritative.parts_used— append both. Field tech adds parts mid-job; back office may have added a part from the warehouse.customer_signature_capture— mobile wins, period. The signature is captured on the device.
Write these rules down in a table and refer to them from the sync engine.
The policy table
Table: u_mobile_field_policy
u_target_table (String)
u_field_name (String)
u_policy (Choice: mobile_wins, server_wins, last_writer_wins,
append, hard_conflict, sum, max)
u_notes (String)
u_owner (Reference to user)
Every reachable field on every offline-eligible table needs a row. Yes, this is tedious. The alternative is a sync engine whose conflict behavior is buried in code that nobody can audit and nobody can change without redeploying the mobile app.
A working sync resolver
The resolver runs on the server when a queued mobile change arrives. It looks at the server’s current state, the mobile’s known-base state at the time of edit, and the mobile’s proposed new state.
// Server-side resolver pseudocode
var MobileSyncResolver = Class.create();
MobileSyncResolver.prototype = {
initialize: function() {},
resolve: function(table, sysId, baseState, mobileNewState) {
var server = new GlideRecord(table);
if (!server.get(sysId)) return { status: 'not_found' };
var policies = this._policiesFor(table);
var decisions = {};
var hardConflicts = [];
Object.keys(mobileNewState).forEach(function(field) {
var mobileChanged = mobileNewState[field] !== baseState[field];
var serverChanged = server.getValue(field) !== baseState[field];
if (!mobileChanged) return; // mobile didn't change this field
if (!serverChanged) {
decisions[field] = mobileNewState[field]; // no conflict
return;
}
var policy = (policies[field] || 'hard_conflict');
switch (policy) {
case 'mobile_wins':
decisions[field] = mobileNewState[field]; break;
case 'server_wins':
break; // do nothing
case 'last_writer_wins':
if (mobileNewState.__edited_at > server.sys_updated_on.getNumericValue()) {
decisions[field] = mobileNewState[field];
}
break;
case 'append':
decisions[field] = server.getValue(field) + '\n---\n' + mobileNewState[field];
break;
default:
hardConflicts.push({
field: field,
server: server.getValue(field),
mobile: mobileNewState[field],
base: baseState[field]
});
}
});
if (hardConflicts.length > 0) {
this._queueForReview(table, sysId, hardConflicts);
return { status: 'review_required', conflicts: hardConflicts };
}
Object.keys(decisions).forEach(function(f) { server.setValue(f, decisions[f]); });
server.update();
return { status: 'merged', applied: Object.keys(decisions) };
},
_policiesFor: function(table) {
var out = {};
var p = new GlideRecord('u_mobile_field_policy');
p.addQuery('u_target_table', table);
p.query();
while (p.next()) out[p.u_field_name.toString()] = p.u_policy.toString();
return out;
},
_queueForReview: function(table, sysId, conflicts) {
var q = new GlideRecord('u_sync_review_queue');
q.initialize();
q.u_table = table;
q.u_sys_id = sysId;
q.u_conflict_payload = JSON.stringify(conflicts);
q.u_state = 'pending';
q.insert();
},
type: 'MobileSyncResolver'
};
The base state is the critical piece. The mobile client must send what it thought the record looked like before its edits, not just the new values. Without the base state, the resolver cannot tell whether the server’s value changed since the device went offline.
The hard-conflict UX
When the resolver produces a hard conflict, somebody has to decide. Three patterns work, in order of preference:
- Field tech decides at next sync — when the device re-syncs, the app surfaces the conflict and the tech picks. This works when the tech is the right decider (parts list, work notes).
- Dispatcher decides — a queue on the dispatcher’s workspace surfaces conflicts that need an office decision. This works when the tech is no longer relevant (assignment changes, schedule changes).
- Escalate to manager — last resort, used only when the conflict implies a real disagreement about what happened.
Whichever path, do not silently apply a default. The lost edit is worse than the friction of asking.
Detecting stale sessions
A device that has been offline for a week and then syncs is a different problem than a device that has been offline for an hour. The longer the offline window, the more likely the server-side state has moved in ways the field tech could not have anticipated.
Apply a staleness threshold:
var STALE_HOURS = 24;
var hoursOffline = (now - mobileNewState.__queued_at) / 1000 / 3600;
if (hoursOffline > STALE_HOURS) {
// Force every changed field through hard-conflict path, even if policy
// says otherwise. The tech should re-confirm before we apply.
forceReview = true;
}
This costs the tech a few extra taps on syncs after long offline windows. It prevents data corruption from edits made under assumptions that no longer hold.
What not to sync
Some fields should never be in the offline schema. Audit fields, system-managed timestamps, computed values, and cross-scope references whose targets may not be on the device are all categories where syncing creates more problems than it solves.
Be explicit. The mobile app config should list, by table, exactly which fields are offline-eligible. Anything not on the list is server-only and the mobile UI either reads it live (when online) or shows a placeholder (when offline).
Idempotency on the wire
Network conditions on a truck are nobody’s friend. The sync protocol must be idempotent — if the same change reaches the server twice, the second one is a no-op, not a duplicate apply. The mechanism is a client-generated change ID per record edit:
POST /api/now/mobile/sync
{
"change_id": "uuid-from-device",
"table": "wm_task",
"sys_id": "...",
"base_state": { ... },
"new_state": { ... },
"queued_at": "2026-05-14T15:32:11Z"
}
The server stores processed change IDs for a retention window. Re-receiving a known ID returns the original resolution result, not a re-application.
UI for the field tech
When the resolver produces a conflict that needs the tech’s input, the mobile UI must show the conflict plainly: what they tried to save, what the server has, and what they want to do. Two buttons, no third option. The tap target should be large enough for a gloved hand on a tablet in cold weather — this is field service, not a desk app.
For the broader pattern of routing field service work, see our skill-based assignment routing piece.
Tradeoffs to be honest about
A per-field policy table is more setup than a record-level rule. It is also the only design that survives contact with real field work, where the failure mode “tech’s parts list got overwritten” is a worse outcome than the friction of writing down 40 rows of policy.
You will get the policies wrong on the first pass. Plan for a policy review at 30, 60, and 90 days post-launch. The dispatch and tech teams will tell you which fields are mis-classified; what they will not tell you is the ones that work fine but should not.
Bottom line
- Per-field policy beats per-record policy. Write down which side wins for each field; refuse to ship without it.
- The mobile client must send its base state, not just the new values. Without base state, conflict detection is guesswork.
- Hard conflicts need a human decision; never apply a silent default.
- Staleness threshold: long offline windows force review even when the policy says otherwise.
- Idempotency on the wire with client-generated change IDs. Network conditions in the field require it.