· 7 min read
An offline outbox for photo evidence in React Native, and the bugs it taught me
Roof inspectors work where there is no signal. This is the capture-first, sync-later design behind ClaimLayer's field app, the status codes that decide a retry, and three contract bugs worth avoiding.

An insurance claim for storm damage lives or dies on its photos. The adjuster wants the overview, every slope, the ridge and hips, the damage close up, the gutters and a measurement or two. If one required shot is missing, someone has to go back up the ladder, and after a storm the roof may already be tarped or repaired.
The people taking those photos are on roofs in rural areas, often right after the weather that took out the cell towers. ClaimLayer's field app is built around one rule: the phone never needs signal to do its job. Below: how that works, what the server does with the queue, and the bugs that showed up once I got strict about it.
Why "upload when you press the shutter" fails
The obvious version uploads each photo as it is taken. With no signal the request fails, and depending on how the code is written the photo is lost or the user stares at a spinner. Every fix for that leads to the same place: write locally first, send later, and make sending safe to repeat.
Capture writes two operations
When the inspector takes a photo, the app resizes it, hashes it and queues two operations: one for the file and one for the evidence record it belongs to.
const photo = await camera.current.takePictureAsync({ quality: 0.85, exif: true })
const resize = photo.width >= photo.height ? { width: 1600 } : { height: 1600 }
const resized = await ImageManipulator.manipulateAsync(photo.uri, [{ resize }],
{ compress: 0.82, format: ImageManipulator.SaveFormat.JPEG })
const contents = await FileSystem.readAsStringAsync(resized.uri, { encoding: FileSystem.EncodingType.Base64 })
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, contents)
const base = `${Date.now()}-${digest.slice(0, 8)}`
await enqueue(storage, { id: `media-${base}`, claimId, operation: 'media.upload',
idempotencyKey: `media-${base}-offline`, occurredAt: capturedAt, payload: metadata })
await enqueue(storage, { id: `evidence-${base}`, claimId, operation: 'evidence.save',
idempotencyKey: `evidence-${base}-offline`, occurredAt: capturedAt,
payload: { inspectionId, itemKey: ITEMS[activeItem], sha256: digest, caption: caption.trim() } })A few details are worth copying:
- Resize on the device. 1600 px on the long edge at JPEG 0.82 is plenty for an adjuster and keeps a day of photos small.
- Store the file path, not the bytes. The queue holds the URI and metadata, so the queue itself stays small.
- Record where and when. Capture time, device time, EXIF, GPS and accuracy (when permitted) and a SHA-256 travel with every photo.
- Every operation gets its own idempotency key, built from time and content hash, minted on the device before any network call.
- One capture at a time. A busy flag stops two captures racing on the same queue key.
The screen tells the inspector exactly that: "Capture locally first. The original remains on this device until the server confirms upload."
Sync: the status code decides
The outbox envelope is defined once in a shared contracts package, so the app and the server agree on its shape:
type OutboxOperation = {
id: string
claimId: string
operation: 'media.upload' | 'evidence.save'
idempotencyKey: string // 16 to 128 chars
occurredAt: string
payload: Record<string, unknown>
state: 'pending' | 'in_flight' | 'synced' | 'refused' | 'abandoned'
}Sync walks the queue one item at a time, in capture order, and the response decides what happens to each item.
try {
const response = await fetcher(`${baseUrl}/api/trpc/inspections.saveEvidence`, {
method: 'POST', headers: headers(token, deviceId),
body: JSON.stringify({ claimId: op.claimId, inspectionId: p.inspectionId, itemKey: p.itemKey,
photos: 1, note: p.caption || undefined, idempotencyKey: op.idempotencyKey }),
})
next.push({ ...op, state: response.ok ? 'synced'
: response.status >= 400 && response.status < 500 ? 'refused' : 'pending' })
} catch {
next.push({ ...op, state: 'pending' }) // network error: keep it, try next time
}A 5xx or a dropped connection is temporary, so the item stays pending. A 4xx means the server understood and said no, and retrying the same request forever would just burn battery. Refused items are never deleted. The Sync Center shows queued, refused and synced counts and says "Nothing is discarded silently. Refused items stay here until they are resolved."
The function takes fetch as a parameter, which made it easy to test without a network. A test feeds it a fake fetch that returns 503 and checks the evidence is still pending afterwards.
The server side: replay, then merge
On the server, each evidence save is idempotent per tenant. A key that was seen before returns the stored response. A new one merges into the inspection's evidence in a single statement:
const key = `${tenantId}:${idempotencyKey}`
const prior = await sql`select response_json from idempotency_keys where key = ${key}`
if (prior[0]) return prior[0].response_json
const evidence = sql.json({ [itemKey]: input.evidence })
const rows = await sql`
insert into inspections (id, tenant_id, claim_id, evidence_json)
values (${inspectionId}, ${tenantId}, ${claimId}, ${evidence})
on conflict (id) do update set evidence_json = inspections.evidence_json || ${evidence}, updated_at = now()
where inspections.tenant_id = ${tenantId}
returning *`
if (!rows[0]) throw new Error('NOT_FOUND') // another tenant's inspection looks like "not found"
await sql`insert into idempotency_keys (key, response_json) values (${key}, ${sql.json(rows[0])})`The tenant check sits in the WHERE of the upsert itself, so a forged inspection ID from another tenant cannot be written to, and the error does not reveal that it exists.
Two honest notes on this code. First, the replay check and the receipt insert are separate statements. Two truly simultaneous retries could both pass the check, and the second would fail on the unique key. The phone treats that as a 5xx, keeps the item pending, and the next sync gets the stored replay, so it heals itself. It is still better written as insert ... on conflict do nothing in the same transaction, which is what other mutations in the codebase do. Second, || on JSONB is a shallow merge, so each evidence item is last-write-wins as a whole object.
Reading offline, too
Writes are only half of it. The assignment list loads from the device cache first and refreshes behind it:
export async function refreshAssignments(cached, load) {
try { return { assignments: await load() } }
catch { return { assignments: cached, error: 'Showing cached assignments. Pull to retry.' } }
}On launch the app reads the session, assignments, outbox and device ID from storage in parallel and paints immediately. Sign-in is the only step that genuinely needs signal. Sessions are bound to the device: the server stores a hash of the token and refuses a field session whose x-device-id header does not match.
Three bugs worth knowing about before you build this
Being strict about "what does the server actually have" surfaced problems that a happy-path demo hides.
One item, three names. The phone sent the UI label as the item key ("Ridge & hip"). The seed data used ridge-hip. The rule packs that decide whether a claim is complete checked component_photos. Each layer worked on its own, and the rule check could not see evidence captured on the phone. The fix is boring and permanent: one enum of item keys in the shared contracts package, imported by the app, the seed and the rule packs, with a test that fails on drift.
Counts that do not add up. The app sends photos: 1 with each save, and the server's shallow merge replaces the whole item. Three photos of the ridge end up recorded as one, and a rule needing two can never be met from the phone. A later photo without a caption also wipes the earlier caption. The fix is to make the server count: increment per unique media hash instead of trusting the client's number.
Metadata ahead of the file. Evidence records can reach the server while the photo itself is still on the phone. Right now photo uploads are refused on purpose, because resumable object storage is not set up yet, and the Sync Center shows those as refused instead of pretending. If you build this, decide early which of the two is the source of truth and show the gap to the user.
What is still on the list
This is a pilot, and these are the gaps:
- Sync is manual. Nothing happens until the inspector taps Sync. Triggering it when the app comes to the foreground or connectivity returns is the obvious next step.
- There is no backoff, timeout or batching yet. One request at a time is fine for dozens of items, not thousands.
- The queue is one JSON array in AsyncStorage, rewritten on every change. SQLite or per-item keys would scale better.
- Resized photos live in a cache directory the OS may clear. They should be copied into the app's document directory.
- The server exposes a time endpoint for clock offset, but the app does not use it yet, so the phone's clock is trusted.
The short version
Capture locally, always
Write the file and a queue entry before touching the network. Tell the user the original stays on the device until confirmed.
Mint idempotency keys on the device
Every operation gets a key before its first attempt, and the server replays the stored answer for a key it has seen.
Let the status code decide
2xx is done, 4xx is refused and visible, 5xx and network errors stay pending.
Never drop silently
A screen that shows queued, refused and synced counts is worth more than any retry algorithm.
Share the contract
Item keys, operation shapes and states belong in one package used by every layer.