- Travel
- GPS
- Offline-first
- PostGIS
- Android
Offline-first GPS for travel claims: how Ritwik One records trips
A 22 km trip was paid as 18.9 km because the phone stopped recording for 150 minutes. How Ritwik One Travel now spools GPS points on the phone, uploads them in batches that are safe to repeat, and measures the stretches the phone still missed.
I designed and built Ritwik One Travel and the Ritwik One Android app. This post walks through how they record a field trip, from the first GPS fix to the kilometres on a travel claim.
Ritwik Finance's field staff ride to customers every working day, mostly on two-wheelers, and the company pays them for every kilometre. Ritwik One Travel is the part of our staff super app that records those trips and turns them into travel claims. It sounds simple: record the route, add up the distance, pay for it.
In October 2026 a real trip came back at 18.9 km. The odometer said 22 km. Nothing was wrong with the bike or the employee. The phone's screen had locked, the browser had stopped recording, and for 150 minutes there were no GPS points at all. When recording started again, the distance calculation joined the last point before the gap and the first point after it with a straight line. The trip came out 3.1 km short: about 14% of what the employee actually rode.
A straight line is the shortest possible distance between two points, so a recording gap can only ever reduce a claim. The error always lands on the employee, and it hurts most the people who ride furthest. Fixing it meant solving four problems at once:
- Never lose a point, even with no network for hours or the app killed in the background.
- Never count a point twice, even when an upload is retried or two recorders overlap.
- Be honest about what the phone missed, and estimate it only when the estimate is believable.
- Show the approver how every number was made, so nobody has to trust a single figure.
Part 1Capturing a fix, and keeping every one
A trip can be recorded in two places. In a phone's browser, the page uses the standard geolocation API with high accuracy and no cached fixes (enableHighAccuracy: true, maximumAge: 0). Inside the Ritwik One Android app, a native foreground service records instead, which is what keeps working with the screen off. It asks Android for a high-accuracy fix every 5 seconds and lets the system deliver them in groups every 10 seconds to save battery.
The app keeps a point every 15 metres of movement, or every 60 seconds while the rider is standing still at a customer's door. It drops only fixes that come without a usable accuracy figure. Everything else is kept, including fixes we will later decide not to count.
That last choice is deliberate. We filter at the end of the trip, not at capture. If the phone threw away "bad" fixes as they arrived, the rules could never be changed or checked afterwards, and an approver could never see what was discarded and why. Keeping the raw track makes the distance reproducible: run the same rules on the same points and you get the same kilometres.
Part 2The spool: store first, send later
Field staff ride through places with no network for long stretches, so the recorder never sends a fix straight to the server. Every point is first written to a spool on the phone, and only then uploaded. The spool lives in SQLite (in write-ahead-log mode) inside the app, and in IndexedDB in the browser. Both survive the app being killed and the page being reloaded.
Before a point is stored, it gets a sequence number: 1 for the start of the trip, then 2, 3, 4 and so on. The number is allocated inside a database transaction, so two fixes can never get the same number, and the spool's key is the pair (trip, seq). That number is what makes everything after it safe.
Part 3Batches that are safe to send twice
Uploading one point per request would drain the battery and choke on a weak signal, so points travel in batches. The browser uploads when 20 points are waiting, every 30 seconds, as soon as the phone comes back online, and when the page becomes visible again. The app sends larger batches, when enough points are waiting or every 30 seconds. Only one upload runs at a time on each side.
Networks fail halfway. A request can reach the server, get saved, and lose its response on the way back. The phone then has no way to know whether the batch arrived, so it sends it again. That is only safe because the server stores points with a unique key on (trip_id, seq) and ignores any number it already has:
-- simplified
INSERT INTO trip_points (trip_id, seq, point, accuracy_m, captured_at, received_at)
VALUES ...
ON CONFLICT (trip_id, seq) DO NOTHING
RETURNING seq;
The response says how many points were new, how many were duplicates, the highest number it has, and which numbers are still missing. The phone deletes a batch from its spool only after the server has answered. What happens on an error depends on what the error means:
| The server says | Meaning | The recorder |
|---|---|---|
| No answer, 429 or 5xx | Try later | Keeps the batch and tries again on the next 30-second beat |
| 409 | The trip is no longer active | Stops recording and clears the spool |
| 401 (app) | The app's trip token is no longer valid | Keeps the points; the page takes over recording |
| 400, 413 or 422 (app) | This batch can never be accepted | Drops that batch so it can't block the rest |
Before a trip can end, every point must be uploaded. The end-trip form asks the recorder to drain its spool, including a flush request to the app, and refuses to end the trip while anything is still waiting.
Part 4One recorder per trip
The browser cannot record with the screen off, so inside the app the page hands the trip over to the native recorder. For a moment there are two possible writers for one trip, and with sequence numbers, two writers are dangerous: if both numbered their points from 120, the server would keep whichever arrived first and silently drop the other's points as duplicates.
So there is exactly one recorder per trip. The page asks the server for a trip token, then tells the app to start numbering from the highest number anyone has seen: the server's, the page's and the app's own.
The trip token is built to be useless anywhere else. The server keeps only a hash of it. It works only for uploading points to that one trip, it expires, and it stops working the moment the employee signs out, the trip ends or the account is disabled. On the phone it sits in Android's encrypted secure storage, which is left out of device backups.
If the app can't record, for example because location access isn't set to "Allow all the time", the page keeps recording and tells the employee why. A trip is never left without a recorder.
Part 5What the server accepts
The server is the last line of defence for the data, so it checks every batch before storing it, and then stores everything that passes:
- Shape. A limited number of points per request, valid coordinates, a sequence number within range. Anything else is refused as a whole.
- State. Points are accepted only while the trip is active. After that the answer is 409, which tells the recorder to stop.
- Clocks. A point stamped in the future, or from before the trip started, is rejected with a reason. Each point keeps two times: when the phone captured it and when the server received it.
- Volume. Every trip has a ceiling on how many points it can upload and keep: far above a long working day, and a hard stop for anything runaway.
Points arrive in any order: a batch from the spool can land after a newer one. The server doesn't care, because the distance is always computed in sequence order at the end. Points are stored as PostGIS geography points with a spatial index, which keeps the map queries fast.
Part 6When the phone stops anyway
Android phones, especially those from some makers, are aggressive about killing background apps to save battery. A spool can't save a point that was never recorded, so the app works hard at not being stopped, and at noticing when it was:
- Phone setup before a trip. The app checks for itself, without trusting the page, that location is allowed all the time and battery optimisation is off. Precise location and notifications are recommended. The page won't start a trip until the required ones pass, and the app shows maker-specific tips for Xiaomi, Oppo, Vivo and Samsung.
- A dead-man's switch. Every batch of fixes pushes a local notification a few minutes into the future. As long as GPS keeps arriving, the alarm keeps moving and never rings. If fixes stop, it fires: "GPS stopped", with a tap that opens the trip.
- Restart on return. When the app comes back to the front and no point has arrived for a while, it restarts recording.
Even so, gaps happen: a battery dies, a phone restarts, a cheap phone kills the service anyway. So the last step has to be honest about them.
Part 7Turning points into kilometres
When the trip ends, the server reads every point in sequence order and runs the same filters every time. A point is set aside if:
| Filter | Rule | Why |
|---|---|---|
| Low accuracy | Accuracy worse than the policy allows | A fix that could be anywhere in a wide circle adds noise |
| Time going backwards | No time has passed since the last kept point | Device clocks jump |
| Impossible speed | Faster than the vehicle could go | A GPS "jump" across town |
| Jitter | Too close to the last kept point to be real movement | Standing still makes GPS wander and adds fake metres |
Then come the gaps. Any two kept points too far apart to be consecutive fixes are a stretch the phone didn't record. For each one, the server asks Google's Routes API for the road distance between the two points, by two-wheeler or by car depending on the vehicle. Only those two points go to Google, never the trip. But a road route is a guess, so it has to pass a check before it counts:
keep the straight line if
there is no road route
or road < straight -- a road can't be shorter than a straight line
or the gap took no time
or road / time > what the vehicle could do
otherwise count the road distance
- Recorded GPS
- Straight line: what used to be counted
- Road route: counted when it fits the time
distance = recorded track − straight-line gaps + road-measured gaps
The road answers are stored with the trip, so if Accounts later corrects an odometer reading, nothing is sent to Google again. It costs about US$5 per 1,000 gaps.
Finally, the GPS distance is checked against the odometer readings the employee enters at the start and end of the trip:
variance = |odometer − GPS| ÷ the larger of the two
Above the policy's tolerance, the trip is flagged for the approver. If fewer than two points survive the filters, the claim falls back to the odometer and says so.
Part 8Visits: kilometres only matter if they lead to customers
A lending company doesn't send people out to ride; it sends them to meet customers. So the trip also records each customer visit. When an employee taps Punch visit, a timer starts. The visit ends when they tap End visit, punch the next visit, end the trip, or move more than 1 km away. That last one needs care, because a single bad fix can jump hundreds of metres.
The check runs after every upload, so late points from the spool are handled the same way as fresh ones. Each visit on the trip shows how long it took and why it ended, and approvers have a team live map of active trips that refreshes every 30 seconds.
Part 9What the approver sees
All of this would be wasted if the approver saw one number. So the map draws recorded GPS as a solid line, gaps measured along the road as a dashed blue line on the actual roads, and gaps that couldn't be measured as a thin dashed grey line. The trip carries flags in plain words: how many stretches were not recorded and the longest one, how many kilometres were measured along the road ("check it against the odometer"), whether GPS jumps were removed, and whether the GPS and odometer disagree by more than the tolerance.
The approver isn't asked to trust the system. They're shown exactly where to look.
What's next
Background recording runs in the Android app. On other phones the browser records, and any gaps are measured and flagged exactly as described above, so every trip is judged by the same rules.
What I'd tell anyone building this
Missing data must never quietly turn into a number.
- Look at which way the error goes. A straight line across a gap can only under-count, so the error always landed on the employee.
- Number every point before you store it. A sequence number turns retries, late batches and a second recorder from risks into non-events.
- Store first, send later. A spool on the phone makes "offline" a normal state, not an error.
- Keep the raw track; filter at the end. Then the distance is reproducible and every discarded point can be explained.
- Check an estimate before it counts. A road route is better than a straight line only when the time fits.
- Show how the number was made. The approver sees which kilometres were recorded and which were estimated.
Published By Nikhil Kumar, AI/ML EngineerRitwik Finance Enterprises Private Limited