Offline inspections.
Keep the work. Explain the sync.
Let someone inspect equipment, complete a checklist, and attach photos in a building with no reception. Bring the work back when a connection returns, even if another inspector changed the same record while they were away.
The interesting part is the space between a local save and an accepted server version: interrupted uploads, expired sessions, conflicting edits, and an app that must say exactly what it knows.
A useful product with an unreliable connection
One checklist, two devices, a supervisor.
The first product loop
- Sign in online and download an assigned equipment inspection.
- Go offline, record checklist answers, write a note, and add a photo.
- Close and reopen the app. Find the locally saved work and its pending status.
- Reconnect, synchronize, and review a deliberate conflict from a second device.
- Submit the accepted inspection and finalized photos for supervisor review.
Keep the first version focused
Build a web app with a cached shell, IndexedDB for local records and photos, a server API, a relational database, object storage, and one thumbnail worker. Start with one fixed checklist type, small photos, and explicit conflict resolution for the whole inspection.
Use synthetic equipment and two isolated browser profiles for the demo. Leave live collaborative text, a generic form designer, and automatic field merging for later.
This page is a project brief and failure drill planner. The inspection app, sync protocol, and fault harness are what you would build.
The interface makes a promise
“Saved” needs a location.
Show these states against a specific draft or operation. One inspection can have an accepted server version and newer local work at the same time. Give photos their own upload status, and keep submission and supervisor approval separate from synchronization.
Saving…
The latest edit is still in memory. Local persistence has not completed.
Saved on this device
The local transaction completed. Changes are waiting to reach the server.
Syncing…
An operation is in flight. A timeout may leave its server outcome unknown.
Synced
The server acknowledged this version and its required attachments. Later edits need their own acknowledgement.
Needs review
The server changed since this draft began. Keep the draft and show the conflict.
Action needed
Explain the specific blocker: sign in, free storage, update the app, or resolve rejected access.
Write the promises before the sync loop
Local intent. Conditional acceptance. Explicit recovery.
Make local save an actual transaction
Bootstrap online: authenticate, download the assignment and checklist version, and cache the application shell. A first-ever offline visit cannot download missing application code or private records.
Commit the draft and pending sync intent in one local transaction. Display “Saved on this device” after completion, and report an aborted write as unsaved. IndexedDB supports transactional structured storage, including files and blobs; see the IndexedDB overview.
Local storage is not a backup. Browser eviction, user-cleared data, or a lost device can remove unsynchronized work. Handle quota errors and explain any persistence request’s actual outcome. MDN documents the storage limits and eviction behavior.
Separate a saved draft from an attempted operation
Give each operation a stable ID, inspection ID, expected server revision, payload schema version, and immutable body, scoped to an account and workspace. Once an attempt starts, retries keep that identity and body, including after an unknown network outcome.
Send one operation per inspection at a time. Keep later edits in a separately saved draft until the pending result is reconciled. Applying an old acknowledgement must preserve that newer intent. Coordinate sending tabs with an expiring ownership claim; server deduplication still handles overlapping attempts.
On the server, authorize first. Then atomically check for an existing receipt, compare the expected revision, and commit the change, receipt, and change-log entry. The same operation returns its recorded result; the same ID with different content is an error. Keep receipts for a declared offline retry window. Outside it, require reconciliation instead of silently treating an old operation as new.
Use revisions to prevent silent overwrites
Begin with inspection-level optimistic concurrency. A mutation based on revision 7 cannot overwrite revision 8. Preserve the original base and local draft, fetch the authorized current version, and let the user review the disagreement. Even edits to different fields require review in this first version.
This rule is deliberately coarser than the one in Local-first and sync, which follows an inspection too. There the device sends changes rather than whole records, each field carries the version it was edited from, fields such as notes merge, and a conflicting field keeps the server’s value and is marked for review. Here one revision covers the whole inspection, so the first build has a single conflict rule to prove and never merges on the inspector’s behalf. Field-level merging is the extension once that rule holds.
A resolution is new intent with a new operation ID against the current revision. If that revision changes during review, conflict again. Device timestamps can help tell the story, but they do not decide whose work wins. HTTP’s If-Match precondition is a standard way to express a conditional write when using strong entity tags.
Make catch-up preserve pending work
Pull bounded pages from an authorized, ordered server change stream. Apply each page and its cursor in the same local transaction. Use server ordering, not device clocks; a crash before commit should replay the page rather than skip it.
Keep pending drafts as an overlay on accepted records. Remote updates do not replace them. Carry deletion markers, and reject stale mutations to deleted or submitted inspections. An expired cursor needs a fresh authorized snapshot while local intent remains available for reconciliation. Membership changes also require revalidating the local set of assigned records.
Give photos a lifecycle of their own
Persist the bounded-size local blob and a stable attachment ID. Make upload initiation and finalization idempotent, and validate that repeated use of an ID refers to the same bytes. Start by retrying the entire small upload; resumable chunks are a later extension.
Finalize only after the server has durable, validated bytes and has rechecked access to the parent inspection. Incomplete uploads are never ready. Recoverably enqueue a thumbnail job at finalization, and clean abandoned uploads after a declared retention period. Keep originals private and authorize reads as well as writes.
Submission is its own conditional, idempotent operation. It requires accepted checklist answers and finalized required photos, and freezes that inspection for review. Thumbnail readiness remains a separate processing state.
Expect connectivity, permissions, and app versions to change
Run foreground sync on launch, explicit retry, and reconnection attempts. Use actual request results to determine reachability. Bound concurrency and retry duration, add jitter, and honor server retry guidance. Conflicts, invalid data, expired sessions, and revoked access need distinct recovery paths.
Offline use only covers previously downloaded work. Recheck permissions on reconnect, including receipt reads. Stop sending on sign-out, and make the handling of unsynced data explicit before switching accounts. Revoking access cannot erase an already cached record on a disconnected device.
Version local storage, server data, and operation payloads separately. Preserve queued work during upgrades, close old database connections on version changes, and show when another tab blocks migration. Reconcile attempted requests before converting their intent into a new operation. The IndexedDB usage guide covers transaction completion and upgrades with multiple tabs open.
The portfolio moment
Two devices. One inspection. Both edits matter.
Use this proposed sequence as the demo script. Both profiles start with “Pump 12 / seal condition: unchecked” at revision 7. The steps describe the behavior to implement and verify.
- 01 / Disconnect
Two local drafts
A records “pass.” B records “leaking” with a note. Both show saved on this device, pending sync.
- 02 / A reconnects
Revision 8
A’s operation expects 7 and commits “pass” as 8. B is still offline and knows only its own draft.
- 03 / B reconnects
Needs review
B expects 7; the server is at 8. Preserve “leaking,” show the base and current answer, and stop automatic retries.
- 04 / Resolve
A deliberate revision 9
After review, choose “leaking” with the note. A new operation against 8 commits as 9 if the server is unchanged.
Repeat with B reconnecting first, then edit again while the conflict screen is open. Arrival order can change the first committed answer. The promise is that a competing draft is never silently overwritten, and that every resolution checks the version it reviewed.
Make each capability earn its place
A capability and something that proves it.
Use these as acceptance checks, staged across the milestones below.
Authentication & offline sessions
Build
Sign in online before downloading assigned inspections. Scope local records and pending work to that account and workspace. Pause synchronization when the session expires.
Demonstrate
Expire a session while edits are queued. The app asks for sign-in without discarding them. Switching accounts neither reveals the previous account’s drafts nor sends its queue.
Authorization
Build
Define inspector and supervisor roles. Recheck assignment, workspace membership, and record state on every push, pull, receipt read, attachment operation, and submission.
Demonstrate
Revoke an assignment while a device is offline. Reconnection cannot publish its edits or retrieve new protected content; the local draft gets an explicit blocked outcome.
Local & server persistence
Build
Save drafts and pending intent together in IndexedDB. Store accepted inspections, operation receipts, change entries, and job intent transactionally on the server. Cache the app shell for offline reopening.
Demonstrate
Reload offline after a completed local save. The draft returns. Abort a local transaction and confirm no saved indicator appears; restart the server after acknowledgement and find the accepted version.
Background jobs
Build
Process finalized photos into bounded thumbnail variants with durable jobs and expiring worker claims. Make variant identity stable so a recovered job can finish safely.
Demonstrate
Stop a worker after writing a thumbnail but before recording completion. Restarting produces one logical variant and a recoverable job result. Inspection sync and thumbnail readiness have separate states.
Retries & interrupted uploads
Build
Use request deadlines, bounded concurrency, capped backoff with jitter, and server retry guidance. Persist retry state. Begin with bounded-size photos that can be retried as a whole.
Demonstrate
Drop a photo upload midway and return 429 from sync. The UI retains local work, retry traffic stays bounded, and an incomplete upload never appears as a ready attachment.
Idempotency
Build
Bind each operation ID to its account, workspace, immutable request, and durable result. Commit the receipt with the mutation. Give attachments a separate stable identity.
Demonstrate
Lose an acknowledgement and resend the same operation. It returns the original result without a second revision. Reusing its ID with different content fails explicitly.
Concurrent edits & catch-up
Build
Compare the expected inspection revision atomically. Preserve the base, local draft, and current server version on conflict. Pull ordered, bounded change pages without replacing pending local edits.
Demonstrate
Edit the same inspection on two offline devices. The second arrival requires review. Interrupt a pull page and replay it without skipping changes or resurrecting a deleted inspection.
Migrations & older clients
Build
Version the local database, server schema, and sync payload separately. Keep a declared compatibility window for offline clients. Handle open tabs that block a local database upgrade.
Demonstrate
Upgrade with existing drafts, queued operations, photos, and an old tab open. Unsupported work remains recoverable and visibly blocked; it is never silently dropped or marked synced.
Rate limiting & storage bounds
Build
Limit push batches, pull pages, photo sizes, upload concurrency, and per-workspace traffic. Bound local pending data and surface storage failures before inviting more work.
Demonstrate
Reconnect a bounded set of devices together. Record admitted and rejected requests, queue age, and another workspace’s latency. Exhaust local storage and show an honest unsaved state.
Observability
Build
Correlate operation, inspection, attachment, and job IDs in structured logs and traces. Show local base revision, pending status, last acknowledgement, and retry reason in a debug panel.
Demonstrate
Follow one edit from local intent through transport attempts to its receipt. Explain a conflict without logging note contents, photo bytes, credentials, or unbounded identifiers as metric labels.
Monitoring & unknown devices
Build
Track observed queue age, conflict and rejection rates, upload failures, sync latency, and worker lag. Define alerts with an owner and recovery steps for the demonstrated workload.
Demonstrate
Disconnect a device before it can report new local work. The dashboard shows last contact and unknown current status. Recover a stuck connected queue and show the alert clearing.
Build → break → explain
Take away the connection. Then change the world.
Build a local harness with isolated device profiles, controllable transport, and a storage adapter that can fail a transaction. Keep active faults visible. Use synthetic inspections, bounded photos, and a declared device count.
Local persistence & bootstrap
Reload without a network
- Inject this fault
- Download an assigned inspection online, disconnect, edit its checklist and note, wait for local save, then close and reopen the app offline.
- Predict before you run it
- Which work can the device recover, and what can it honestly claim about the server?
- Behavior to build
- The cached application opens the previously downloaded inspection and committed draft. It shows pending local changes. A device that never bootstrapped online has no such promise.
Evidence to collect
- The draft and pending intent survive an ordinary offline reload.
- Saved-on-device status stays distinct from server acknowledgement.
- Reconnection sends the preserved operation and records its accepted revision.
Recover & explain
Restore connectivity and observe foreground synchronization. Document the separate limits of browser eviction, user-cleared storage, and device failure.
This planner describes drills to implement in your project. Selecting a drill does not send traffic or inject a fault.
What the chaos controls should expose
Select a device and fault: disconnect, delay, drop one acknowledgement, interrupt an upload, reject a local save, or deny a server operation. Show each device’s base revision, pending operation, retry reason, and last observed acknowledgement beside server state.
Use two profiles with independent storage, not just two tabs sharing one IndexedDB. Reserve the two-tab case for sender coordination and upgrade tests. Provide stop and fixture-reset actions with a run ID, and preserve the previous run’s evidence. Label injected storage errors separately from experiments against the real browser.
A buildable sequence
Grow from a local draft to a recoverable workflow.
- 01
Finish one checklist offline
Create a seeded inspection, online sign-in and download, cached application shell, and local checklist, note, and draft persistence. Make save status explicit.
Done when: After a completed local save, an ordinary offline reload restores the work. Failed writes remain visibly unsaved. First-use offline limitations are documented.
- 02
Synchronize one revision safely
Add immutable operations, atomic server receipts, per-inspection send ordering, conditional writes, and a base/local/current conflict screen.
Done when: A lost response creates no duplicate revision, an old acknowledgement keeps newer local work, and two offline devices cannot silently overwrite one another.
- 03
Attach evidence and submit
Add bounded photo uploads, authorized finalization, recoverable thumbnail jobs, and a supervisor review flow. Give submission its own conditional, idempotent operation.
Done when: Interrupted uploads recover without duplicate attachments. Submission requires accepted answers and finalized required photos; later stale edits cannot mutate the submitted inspection.
- 04
Handle the long absence
Add paginated catch-up, deletion handling, permission changes, account isolation, local and server migrations, quotas, and operational telemetry.
Done when: An older offline client returns to changed data without losing pending work or bypassing permissions. Monitoring distinguishes observed problems from devices that have stopped reporting.
- 05
Package a failure someone can reproduce
Create isolated device fixtures, fault controls, a run identifier, reset and stop actions, and an incident report with before/after evidence.
Done when: A reviewer can replay the two-device conflict and lost-acknowledgement drills, trace an operation, and understand both the recovery and the limits of local storage.
The portfolio handoff
Let a reviewer follow one edit all the way home.
- Make the local promise visible. Disconnect, edit, wait for local save, and reopen the app.
- Create a disagreement. Use two isolated profiles and reconnect them in a controlled order.
- Inspect the boundary. Show the original base, both answers, server revision, and operation receipt.
- Lose an acknowledgement. Retry the same operation and explain why the server changes only once.
- Recover and qualify. Resolve, finish uploads, submit, and show what a disconnected device’s missing telemetry cannot tell you.
Include the sync contract, failure fixtures, populated migration rehearsal, access checks, and a short incident report. Publish measured recovery time with the device count, payload sizes, and environment. Document browser storage limits and the oldest client version you support. A working conflict screen is stronger evidence when a repeatable experiment explains why it appeared.
Lessons behind this brief
Related lessons
The sync contract rests on these lessons. Local-first and sync follows an inspection too, with a finer conflict rule than this brief starts with.
- Local-first and sync
An outbox on the device, a memory on the server, and field-level conflicts.
- Optimistic concurrency
Reject a write based on an old revision instead of silently overwriting.
- Idempotency & at-least-once delivery
Recognize a repeated request, even after a crash, so a resend changes nothing twice.
- Retry, backoff & idempotency
Bounded, jittered retries when many devices reconnect at once.