Disclaimer: Independent product concept by Kaushal Khodifad. Not a live commercial product.return to portfolio
Disclaimer: Independent product concept by Kaushal Khodifad.
Offline

The queue is the product

No internet at the field. Sync only at office, home or town. A technician walks a whole Club of 15 to 25 farmers in one trip and uploads hours or days later, so the handset cannot be a cache of server state. It has to be the authority for work already done, and the upload has to be a replay of an ordered log rather than a diff of two snapshots. Everything below runs on the real queue in this browser: press the buttons, then reload the page and see what survives.

In the fixture world at its 2026-03-14 clock, 43 captures are still sitting on handsets, the median one waited 88 hours to reach a server and the worst waited 43.1 days. That gap is not a bug to be engineered away. It is the operating condition.

01The live console

One Club trip, one write-ahead log

Tap a farmer to capture their plot, or walk the whole Club at once. The link starts off, which is the field default. Drain with it off and the report says source "offline": every op rolls back to pending with its attempt count raised and nothing is lost. Switch the link on and the drain settles, one batch at a time, at a transfer speed computed from the payload you actually queued.

Queue storageOpeningReading the local queue. Nothing is claimed about durability until this resolves.
Ops in the log00 B of wire payload across every status
Sequence high-water0Monotonic and gap-free. Allocated inside the write transaction.
Page loads0Reload with work queued: this climbs, the log does not reset.
No service0 queuedOFF
Nagulapalle Stream SidePost-Harvest visit, 21 plots

Loading the club's committed captures...

Fixture replay, fixSource "fixture"
TechnicianRamulu M.
HandsetEntry Android (2020 class) (L1)
VillageNagulapalle Thanda
Coveragenone at field, none at hamlet
Device id-
Visit stage
Uplinksimulated link: offOffline
Simulated connectivity
Sync target
Link speed
Pending00 B
Inflight0handed to a drain
Synced0server acknowledged
Conflict0scope parked
Failed0reason attached
Drain reportsnone yet

Queue some captures, leave the link off, and press Drain. The report will say source "offline" and every op will go back to pending with its attempt count raised. Then switch the link on.

The in-browser uplink holds 0 accepted scope(s) and 0 acknowledged op id(s) across 0 batch(es), at revision 0. It reports source "fixture" because the contract has two values and nothing was persisted; the note on each report says where the batch actually went.

Write-ahead log0 op(s)

Empty. Tap a farmer on the handset, or walk the whole club.

02Conflict

Two technicians, one plot, one stage

9 of these are planted in the fixture world, all at germination, all within three days. The database refuses the second one with a partial unique index on (plot_id, stage), but the handset does not need the database to notice: a purely local pre-check pairs two unsynced captures on one scope from two technicians, which matters because on the day it happens there is no database within reach. Detection is the easy half. The other half is deciding, so each of the three resolutions is priced before it is taken.

Force the case9 planted in the fixturesnot detected

Two technicians captured PLT-0008 at Germination within 3 day(s). The two areas differ by 12.6%, which is inside measurement noise: this is a workflow duplicate, not a dispute.

Both ops are appended from this browser, so both carry this device id. In the field the second walk arrives from a second handset. The pairing rule does not change: two unsynced capture.create ops on one scopeKey from two different technician ids. Detection needs no server, which matters, because on the day it happens there is no server within reach.

03Architecture

Why it is built this way

Six questions the console above can answer by being pressed. Each one names the file that keeps the promise, because an architecture claim that cannot be traced to a line of code is a preference.

Captures still on handsets434.5% of 954 in the fixture world, at the 2026-03-14 clock
Median sync lag88 hp90 299 h. The gap between doing the work and uploading it is the design problem.
Worst backlog43.1 dOne technician, newest in post, oldest handset, fair-weather track.
Sessions carrying conflicts14of 99 upload sessions
The op lifecyclesrc/prototypes/traverse/lib/outbox.ts
enqueuedrainnot mentioned in the response, upload failed, or found inflight on the next initpendinginflightsyncedconflictfailedserverRev attachedboth sides preservedreason kept in the log

An op never leaves the log. It changes status. That single rule is what makes the whole thing recoverable: there is no state in which a technician's walk exists only inside an HTTP request.

Why a write-ahead log instead of last-write-wins on the row?

Last-write-wins needs a server present at write time to decide who won. There is no server present: No internet at the field. Sync only at office, home or town. A technician walks a whole Club, then uploads hours later, and the batch that arrives second is not the walk that happened second.

More to the point, the thing in dispute is an area that money settles on. Overwriting one of two disagreeing measurements is not conflict resolution, it is data loss with a timestamp for an excuse. So the log appends, the database keeps one accepted capture per plot and stage, and the second one is stored with status 'conflicted' rather than discarded. Both sides stay on the record and a person decides.

Try itForce the two-technician case below, then read what each resolution does to the losing capture.

outbox.ts enqueue / drain, and the partial unique index captures_active_stage_uq

Why a monotonic sequence rather than a timestamp?

Field handsets drift, get their clocks set by hand, and cross a timezone offset when the OS re-syncs after days offline. Ordering a log by a clock like that means a corrected walk can sort before the walk it corrects.

So seq is allocated inside the same IndexedDB transaction as the op write, which makes it monotonic and gap-free even with two tabs open, and ordering is by seq and only by seq. clear() deliberately does not reset it: reusing a sequence number would let a replayed old op look new.

Try itQueue work, clear the log, queue again. The sequence continues; it does not restart.

outbox.ts allocAndPut, inside the same IndexedDB transaction as the op write

What exactly is idempotent?

Three layers, because an upload on a village edge-of-coverage connection gets cut mid-flight and retried, and the retry has to be free.

Enqueue is idempotent on the op id, so re-queueing a known walk returns the existing op untouched. The server is idempotent on the same id: a replayed batch returns the same acceptance instead of writing a second boundary. The device log is unique on (device_id, seq), so the same device cannot claim one sequence number twice.

Try itPress Walk the club twice. The log does not grow the second time.

outbox.ts enqueue(id), captures.op_id unique, sync_log unique (op_id) and (device_id, seq)

What happens when only part of the batch succeeds?

Every op reports its own outcome and one bad op never sinks the trip. Accepted becomes synced with the server revision attached. A conflict becomes conflict with the conflict id. A rejection becomes failed with the reason kept in the log, visible to the technician, still there for a supervisor.

The fourth case is the one that is usually got wrong: an op the response never mentions. Silence is not an acknowledgement, so it goes back to pending and is sent again. The server deduplicates it on op id, so a resend costs nothing and losing a walk costs a re-visit.

Try itSwitch the sync target to In-browser and tick both fault switches, then drain.

outbox.ts drain, DrainReport { accepted, conflicted, rejected, deferred }

What if the app is killed mid-drain?

Ops are marked inflight and that mark is written to IndexedDB before the upload is attempted, so a page that dies mid-request leaves evidence rather than a gap.

On the next init, any op still sitting at inflight is restored to pending and will be sent again. The worst case is a duplicate arriving at the server, which the op id already handles. The alternative, deleting an op the moment it is handed to fetch, turns a dropped connection into a lost field visit.

Try itPress Kill the app mid-drain above, then reload this page and look at the op statuses.

outbox.ts hydrate(), the inflight recovery pass on init

Why does one stuck op not stall the whole upload?

Blocking is per scope, never global. A scope is one plot at one stage. If an op on that scope is unsettled, later ops on the same scope are deferred rather than sent, because applying a stage amendment on top of an unsettled capture is how a queue quietly corrupts a boundary. Every other plot in the Club keeps flowing.

The decision that settles a conflict deliberately carries its own scope, resolve:<scope>, so it can never be blocked by the capture it exists to settle. Without that one line the queue deadlocks itself on its own fix.

Try itReject an op, then press Queue an amendment on a blocked scope and drain again.

outbox.ts drain, the blocked-scope pass; conflict.resolve carries scopeKey resolve:<scope>

Where the numbers in this section come from: the lag, backlog and session counts are the fixture world's own aggregates at its 2026-03-14 clock. The queue statistics on the console above are read live from IndexedDB in this browser. Nothing on this tab is typed into a caption.

04Pre-load

What must be on the device before a no-connectivity day

A packing list with a size on it. Every byte below is measured at runtime: the bundle is assembled from the fixture world, counted with TextEncoder, and gzipped for real where the browser exposes CompressionStream. Anything that cannot justify being resident should not be on a phone that has to hold a technician's whole route.

Pick the clubthe default is the twenty-farmer oneRamulu M., none at field, none at hamlet
Farmers20
Plots21
Shared edges37
Prior captures...
Before the trip: what must be on the devicemeasuring

Measuring the bundle...

After the trip: what goes upmeasuring

Measuring the ops...

The arithmetic, in one lineand the honest caveat on it

What is real on this page, and what is not

Real. The queue is IndexedDB in this browser, opened by outbox.ts. The sequence numbers, statuses, attempt counts, byte sizes, drain reports, rollbacks, deferrals and the inflight recovery on reload are all the real implementation. The conflict pre-check is the real detectLocalConflicts. The reconciled boundary is the real least-squares reconciler from src/lib/traverse, called in the browser. The pre-load and upload sizes are measured, and the gzip figures come from CompressionStream.

Not real. Connectivity is a switch on this page, not your actual network. The boundaries are committed fixture captures replayed onto the wire, so every op carries fixSource "fixture" and none of them is a device GNSS fix. The in-browser sync target executes the wire contract inside the page and persists nothing. The whole world, 179 plots across 8 clubs, is synthetic and seeded; SYNTHETIC DATA.

↑ Independent product concept by Kaushal Khodifad. Traverse is an independent product concept by Kaushal Khodifad; it is not a real company or a commercial product. It explores the lifecycle farm-plot capture for contract farming space. Not a live commercial product. Data is illustrative.

Return to portfolioOpen the live demo