If you've already committed to offline-first field forms, you've solved the easy part. The hard part isn't caching data on the device — it's what happens when three technicians edit overlapping records on flaky LTE, one of them uploads a 40MB photo that dies at 60%, and your server has to figure out which version of the truth wins.
Most offline specs stop at "we support conflict resolution" and hand-wave the rest. That gap is exactly where field data corruption lives. A dispatcher sees a job marked complete that a tech later reopened. A part gets deducted twice from inventory because the same "consume part" op replayed after a reconnect. These aren't rare edge cases — in real operations they're the normal result of intermittent connectivity plus underspecified sync.
This post is the concrete layer underneath the higher-level guidance in designing offline-first mobile workflows for reliable field data capture. Exact JSON, a local schema you can migrate to, ordering rules, idempotency semantics, an attachment upload protocol, and a QA test matrix your testers can run without reinterpreting anything. If your engineers and QA are arguing about what "should" happen in a given scenario, this is meant to end that argument.
The core model: everything is an operation, not a state
The single biggest mistake teams make is syncing records instead of operations. They push the whole form object up, the server overwrites, and the last writer wins by accident. Fine until two people touch the same job.
The reliable pattern is an append-only op log. The device never says "here's the current state of job 4471." It says "here are the ordered changes I made." The server applies those changes and tells the device what it now believes. This makes idempotency, conflict detection, and replay-after-crash tractable instead of magical.
-
Every op has a stable client-generated
opIdso replays are safe. -
Every op carries a per-field snapshot so the server can detect field-level conflicts, not just record-level.
-
Ops are ordered per device via a monotonic sequence number, and causally ordered across devices via server sequence assignment.
The rest of this spec fills in those three ideas precisely.
Local schema
Here's the schema we'd recommend on the device (SQLite columns, simplified). It's opinionated on purpose — vagueness is where bugs breed.
Eliminate field service chaos with Romrly.
Romrly helps you assign, track, and complete service jobs efficiently and on time.
- Unified job scheduling
- Technician dispatch & tracking
- Customer notifications & updates
No credit card required
records
| column | type | notes |
|---|---|---|
record_id | TEXT (UUID) | client-generated at create time |
type | TEXT | e.g. job, inspectionline, partusage |
payload | JSON | current field values |
server_seq | INTEGER | last server sequence this record reflects; null if never synced |
dirty | INTEGER | 1 if unsynced local ops exist |
deleted | INTEGER | soft-delete flag (tombstone) |
updated_at | INTEGER | epoch ms, device clock (display only, never for conflict logic) |
The current materialized state the UI reads from.
op_log (the outbox)
| column | type | notes |
|---|---|---|
op_id | TEXT (UUID) | idempotency key |
record_id | TEXT | target record |
type | TEXT | create, update, delete |
client_seq | INTEGER | monotonic per-device counter |
baseserverseq | INTEGER | the server_seq the record was at when this op was made |
fields | JSON | changed fields + their new values + prior values |
depends_on | JSON | array of op_ids or attachment ids this op requires |
status | TEXT | pending, acked, rejected |
created_at | INTEGER | device clock |
Append-only. Nothing here gets mutated except the status field.
tombstones
| column | type | notes |
|---|---|---|
record_id | TEXT | |
deletedserverseq | INTEGER | server seq at which delete was confirmed |
purge_after | INTEGER | when it's safe to garbage-collect locally |
Deletes need to survive so a stale device can't resurrect a record.
per-field metadata
Store, per field, the serverseq and opid that last set it. This is what lets you merge non-overlapping edits instead of throwing away a whole record. In practice this lives inside the record payload as a shadow map, e.g. _meta: { "status": {"seq": 812, "opId": "..."} }.
The reason to keep prior values inside each op's fields block is conflict payloads. When the server rejects a field, it needs to tell the client both what the client thought the old value was and what the server actually has. Without the prior value you can't build a useful merge UI.
Op ordering and causal rules
Two ordering systems run at once, and confusing them is a classic source of "why did my newer edit disappear" bugs.
-
client_seq— a per-device counter that only increases. It guarantees ops from one device apply in the order they were made. Never reset it, even across app reinstalls if you can persist it. -
serverseq— assigned by the server when an op is accepted. It's the global truth ordering. Every record and every accepted op gets a server seq. Delta pulls are "give me everything withserverseq >my last cursor."
> An op's baseserverseq declares what the client saw before making the change. If the server's current server_seq for that record is higher, the change happened against stale data — a potential conflict.
Note potential. If the newer server change touched different fields, you merge silently. Only overlapping fields become real conflicts. Record-level "reject the whole thing" is lazy and generates false conflicts that techs learn to click through blindly, which defeats the whole point.
Idempotency and opId semantics
The opId is a UUID generated on the device before the op is ever sent. This one detail eliminates an entire class of double-application bugs.
-
If the server has never seen this
opId, process it, assign aserverseq, store(opId → serverseq, result). -
If the server has seen this
opId, do not reprocess. Return the original stored result (the same ack, same server_seq). -
The dedup record must live at least as long as any client could plausibly retry — 30 days is a reasonable floor for field apps that go dark on job sites for extended periods.
The subtle failure here: teams implement dedup at the HTTP layer (idempotency-key header on the batch request) instead of per-op. Then a batch of 12 ops partially succeeds, the client retries the whole batch, and 8 already-applied ops get reprocessed because the batch key was different on retry. Dedup belongs on the individual opId, always.
Delta sync: the actual JSON
Push request (client → server)
{ "deviceId": "dev-8842", "clientSeqRange": { "from": 4101, "to": 4104 }, "sinceServerSeq": 20713, "ops": [ { "opId": "b3f1e0c2-0a1d-4c7e-9f21-2a6d5b1c8e40", "recordId": "job-4471", "type": "update", "clientSeq": 4102, "baseServerSeq": 20701, "fields": { "status": { "old": "inprogress", "new": "complete" }, "laborminutes": { "old": 90, "new": 135 } }, "dependsOn": [] }, { "opId": "a71c9d55-6b2e-49aa-8f0c-1d2e3f405162", "recordId": "part-usage-9920", "type": "create", "clientSeq": 4103, "baseServerSeq": null, "fields": { "job_id": { "old": null, "new": "job-4471" }, "sku": { "old": null, "new": "CAP-47uf" }, "qty": { "old": null, "new": 2 } }, "dependsOn": ["att-5501"] } ] }
Push response (server → client)
{ "results": [ { "opId": "b3f1e0c2-0a1d-4c7e-9f21-2a6d5b1c8e40", "status": "applied", "serverSeq": 20714, "recordId": "job-4471" }, { "opId": "a71c9d55-6b2e-49aa-8f0c-1d2e3f405162", "status": "deferred", "reason": "dependency_pending", "missing": ["att-5501"] } ], "newServerSeq": 20714 }
deferred matters here: the part-usage op depends on an attachment (att-5501, maybe a photo of the installed part) that hasn't finished uploading. The server holds it rather than rejecting, and the client retries once the attachment acks.
Delta pull response
{ "changes": [ { "recordId": "job-4471", "serverSeq": 20714, "type": "update", "payload": { "status": "complete", "laborminutes": 135 }, "fieldMeta": { "status": { "seq": 20714 }, "laborminutes": { "seq": 20714 } } }, { "recordId": "job-4409", "serverSeq": 20715, "type": "delete", "tombstone": { "deletedServerSeq": 20715 } } ], "newServerSeq": 20715, "hasMore": false }
When the client pulls with sinceServerSeq, it gets forward-only changes.
Conflict payloads and deterministic tie-breakers
When two devices edit the same field against the same base, the server must reject one deterministically. Determinism is the whole point — the same conflict must resolve the same way regardless of arrival order, or you get split-brain across devices.
{ "opId": "c9d2...", "status": "conflict", "recordId": "job-4471", "conflicts": [ { "field": "status", "clientOld": "inprogress", "clientNew": "complete", "serverCurrent": "onhold", "serverSeq": 20720, "winner": "server" } ] }
Tie-breaker rules, in strict order
-
Field priority class. Some fields have domain rules. A
statusofcancelledbeatscomplete(you don't complete a cancelled job). Encode these as explicit precedence tables per field, not general logic. -
Higher
server_seqwins for fields with no domain rule. The change the server already committed stays; the incoming stale change loses. -
Deterministic tiebreak on
opId(lexicographic) only when seqs are genuinely equal, which is rare but must not deadlock.
The mistake is mixing "newest wins" with "server wins" inconsistently. Pick server-seq as the spine, layer domain precedence on top, and never fall through to timestamps.
Resumable attachment upload protocol
Photos and signatures are where offline sync quietly dies. A 30MB inspection photo over a 200kbps connection at a rural site will fail mid-upload constantly. The protocol has to resume, not restart.
The flow
-
Register the attachment. Client generates
attId, computes a whole-file SHA-256, declares total size and chunk size (256KB works well for field networks). -
Server responds with an upload session and, critically, which chunks it already has (for resume):
-
Client uploads only missing chunks. Each chunk carries its own checksum so a corrupted chunk fails alone:
-
Finalize. Once all chunks land, client calls finalize. Server reassembles, verifies the whole-file
sha256. Mismatch → server names the suspect chunks and asks for re-upload of just those. -
Dependency release. Only after finalize succeeds does any op with
dependsOn: ["att-5501"]become eligible to apply. This is why the part-usage op earlier came backdeferred.
Diagram of the resumable attachment upload workflow.
{ "attId": "att-5501", "recordId": "part-usage-9920", "totalBytes": 5242880, "chunkBytes": 262144, "sha256": "9f2c...ab", "chunkCount": 20 }
{ "attId": "att-5501", "uploadUrl": "/attachments/att-5501/chunks", "receivedChunks": [0, 1, 2, 3, 4], "expiresAt": 1730000000 }
{ "attId": "att-5501", "chunkIndex": 5, "chunkSha256": "1a4f...", "bytes": "<base64 or binary>" }
Once all chunks land, client calls finalize. Server reassembles, verifies the whole-file sha256. Mismatch → server names the suspect chunks and asks for re-upload of just those.
Only after finalize succeeds does any op with dependsOn: ["att-5501"] become eligible to apply. This is why the part-usage op earlier came back deferred.
Dependency rules that matter
-
An op referencing an attachment must never apply before the attachment finalizes. Half-committed records pointing at nonexistent files are genuinely painful to debug.
-
Attachments must be content-addressed by hash so the same photo captured and retried isn't stored twice.
-
Chunk sessions need an expiry, but make it generous — 72 hours minimum. Techs finish uploads days later from the shop parking lot.
These three rules aren't optional. Skip any one of them and you'll eventually end up with records in a state that's nearly impossible to reconstruct after the fact.
Server behavior for duplicate ops (explicit)
Because this is where people cut corners, here it is spelled out:
-
Duplicate
opId, already applied → return the originalappliedresult with the originalserver_seq. Never a new seq. -
Duplicate
opId, was previouslyrejected/conflict→ return the same rejection. Don't re-evaluate; the record may have moved on and you'd produce a different answer for the same op, breaking determinism. -
Duplicate
opId, currentlydeferred→ returndeferredagain with current missing dependencies. -
Unknown
opIdreferencing arecordidthat was tombstoned → returnrejectedwith reasonrecorddeleted, include the tombstone seq so the client can drop its local copy.
Unknown opId referencing a recordid that was tombstoned → return rejected with reason recorddeleted, include the tombstone seq so the client can drop its local copy.
One end-to-end example
Walk the whole loop for a single realistic scenario: a tech marks job 4471 complete and logs extra labor, while a dispatcher put it on hold from the office.
-
1. Client op (offline). Tech taps Complete. Device writes an op:
-
2. Connectivity returns, client pushes. The batch goes up with
baseServerSeq: 20701. -
3. Server processing. Server looks up
job-4471. Its currentserverseqis 20720 — the dispatcher's on-hold change landed at 20720 withstatus = onhold. The incoming op's base is 20701. Overlap onstatus. Domain precedence table sayson_holdblockscomplete. Server rejects the field. -
4. Ack (conflict).
-
5. Client reconcile. Device marks the op
rejected, pulls delta fromsinceServerSeq, receivesjob-4471at seq 20720 withstatus = onhold. It overwrites localstatus, setsdirty = 0, and surfaces a non-blocking notice to the tech: "This job was placed on hold by dispatch — your completion wasn't recorded." The tech'slaborminuteschange, if it had been in the op and didn't conflict, would have merged silently.
{ "opId": "b3f1e0c2-...-8e40", "recordId": "job-4471", "type": "update", "clientSeq": 4102, "baseServerSeq": 20701, "fields": { "status": { "old": "in_progress", "new": "complete" } } }
Local records row updates immediately (status = complete, dirty = 1) so the UI is responsive. Op sits in op_log as pending.
The incoming ack is a conflict and the device reconciles by pulling the newer server_seq, applying non-conflicting merges, and surfacing a non-blocking notice to the tech.
QA test matrix
Prioritized, with exact expected server responses and pass/fail lines. Run top-down; the early tests catch the failures that actually corrupt data.
P0 — data integrity
-
Replay of an applied op. Push op, get
appliedat seq N, push identical op again. - Expected: second responseapplied, same seq N. - Pass: no new seq, no duplicate record change. Fail: new seq issued. -
Double part consumption. Create
part_usageop, ack it, kill app before marking acked locally, relaunch, resync. - Expected: server dedups onopId, inventory decremented once. - Pass: single deduction. Fail: qty deducted twice. -
Op against tombstoned record. Delete record on device A (server acks), device B (stale) pushes update to same record. - Expected:
rejected, reasonrecord_deleted, tombstone seq returned. - Pass: device B drops record. Fail: record resurrected.
P1 — conflict correctness
-
Overlapping-field conflict. Two devices set
statusfrom same base. - Expected: deterministic winner by domain rule / higher server_seq; loser getsconflictpayload with both values. - Pass: same winner regardless of push order. Fail: winner depends on arrival order. -
Non-overlapping merge. Device A edits
labor_minutes, device B editsnotes, same base. - Expected: bothapplied, both fields present in final record. - Pass: no false conflict raised. Fail: one edit lost.
P1 — attachments
-
Resume after 60% failure. Upload 20 chunks, kill network at chunk 12, reconnect. - Expected: session returns
receivedChunks[0–11], client sends 12–19 only. - Pass: no re-upload of 0–11. Fail: full restart. -
Corrupt chunk. Flip a byte in chunk 7 before send. - Expected: finalize fails whole-file hash, server names chunk 7 (or all suspect), client re-sends only that. - Pass: targeted re-send. Fail: silent corruption accepted.
-
Dependency ordering. Push op with
dependsOnon an unfinalized attachment. - Expected: opdeferred, applies only after finalize. - Pass: record never points at missing file. Fail: op applied early.
P2 — ordering & cursors
-
Out-of-order clientseq. Force ops to arrive 4103 before 4102. - Expected: server applies per clientseq order for that device. - Pass: final state matches in-order application.
-
Delta cursor gap. Pull with
sinceServerSeqfar behind, verifyhasMorepaging. - Expected: forward-only changes, no gaps, cursor advances monotonically. - Pass: no missed or duplicated records across pages.
These ten tests won't catch everything, but they'll catch the bugs that actually show up in production and are hardest to reproduce after the fact.
Platform notes (Android / iOS)
Short, because the protocol is platform-agnostic, but a few patterns save real grief:
-
Background upload On iOS use
URLSessionbackground configuration for attachment chunks so uploads survive app suspension. On Android useWorkManagerwith a unique work name perattIdand a network constraint — don't roll your own retry loop. -
The op log must be crash-durable. Write ops to SQLite in the same transaction that updates the
recordsrow. If those two writes can diverge, you'll ship the "UI shows saved, server never got it" bug. -
Persist
client_seqoutside app storage that gets wiped on reinstall where feasible (Keychain / Keystore-backed). A reset counter causes ordering ambiguity. -
Never use the device clock for conflict logic — only for display. Field devices drift, techs manually change time, and you'll get non-deterministic tie-breaks.
-
Batch size keep push batches small (10–20 ops) so a flaky connection commits something instead of timing out on a giant payload.
Most of these are easy to get right once and forget about. The dangerous ones are the op log durability and the clock dependency — both tend to look fine in testing and only surface under real field conditions.
Where this pays off
The value of nailing this spec isn't theoretical cleanliness. A technician on a bad connection in a basement mechanical room can finish a job, log parts, attach photos, and drive off — and everything reconciles correctly hours later without a dispatcher manually fixing records or an inventory count going sideways.
Teams that get burned are the ones who treated sync as "we'll push the JSON and sort out conflicts later." Later is a support ticket about a job that completed itself twice. Specify the op log, the idempotency keys, the deterministic tie-breakers, and the attachment dependency rules up front, and that whole class of field-data corruption bugs mostly stops existing. Pair this with the workflow-level thinking in designing offline-first mobile workflows, hand your QA the matrix above, and you've got something both engineering and testing can build against without reinterpreting a single line.
Ready to optimize your field operations?
Join 2,000+ service teams using Romrly to boost productivity, reduce downtime, and enhance customer satisfaction.