• 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.
From a GPS fix to a travel claim Five steps from left to right. One, capture: a GPS fix every 5 seconds, numbered with a sequence number. Two, spool: the point waits in SQLite or IndexedDB on the phone. Three, upload: points travel in batches that are safe to send twice. Four, store: the server drops duplicates on trip and sequence number and keeps the track in PostGIS. Five, measure: at the end of the trip, filters, gap measurement and the odometer check produce the distance. 1 · Capture a GPS fixevery 5 s,numbered witha seq 2 · Spool waits on thephone in SQLiteor IndexedDB,offline or not 3 · Upload points travelin batches,safe to sendtwice 4 · Store duplicatesdropped on(trip, seq)PostGIS track 5 · Measure filters, gaps,road check,odometervariance
Figure 1. The path of a GPS point. Steps 1 to 3 run on the phone and keep working offline. Steps 4 and 5 run on our servers on Google Cloud in Mumbai.

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.

Store and forward While there is no network, GPS points drop one by one into the spool on the phone. When the network comes back, they leave together as one batch, numbered 41 to 46. The server inserts them, ignoring any sequence number it already has, and reports 6 saved and 0 duplicates. Phone GPS fix Spool points waiting No network Back online seq 41–46 Server INSERT … ON CONFLICT (trip_id, seq) DO NOTHING 6 saved · 0 duplicates trip track (PostGIS)
Figure 2. Store and forward. With no network, points pile up in the spool; when the network returns they leave as one numbered batch. Sending the same batch twice changes nothing on the server.

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 saysMeaningThe recorder
No answer, 429 or 5xxTry laterKeeps the batch and tries again on the next 30-second beat
409The trip is no longer activeStops recording and clears the spool
401 (app)The app's trip token is no longer validKeeps the points; the page takes over recording
400, 413 or 422 (app)This batch can never be acceptedDrops 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.

Handing a trip from the page to the app Three lanes: the page, the phone app and the server. The page asks the server for a trip token and gets the token and the next sequence number. It tells the app the trip has started, with the token and the highest next number. The app uploads points with the token, the server answers with what it accepted, and the app reports its status and next number back to the page. Web page Phone app Server Ask for a trip token token (this trip only) + next seq trip started next = max(server, page, app) points from 128 on, with the token accepted, duplicates, max seq tracking status
Figure 3. The hand-over. The page stops uploading its own points once the app confirms it is recording, and takes over again if the app can't.

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:

FilterRuleWhy
Low accuracyAccuracy worse than the policy allowsA fix that could be anywhere in a wide circle adds noise
Time going backwardsNo time has passed since the last kept pointDevice clocks jump
Impossible speedFaster than the vehicle could goA GPS "jump" across town
JitterToo close to the last kept point to be real movementStanding 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
Measuring a gap along the road The recorded track stops at point A and starts again at point B. A straight dashed line joins A and B through a row of houses. A dashed blue road route goes around the houses from A to B, and a dot travels along it. A B
  • Recorded GPS
  • Straight line: what used to be counted
  • Road route: counted when it fits the time
Figure 4. A gap. A is the last point before the phone stopped recording and B the first after. The straight line can only under-count; the road route is counted when the speed it implies is possible.

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.

When a visit ends by itself A 1 km circle around the customer. Fixes 1 and 2 are inside. Fix 3 is a single stray fix outside and is ignored. Fix 4 is back inside. Fixes 5 and 6 are two accurate fixes in a row outside, so the visit ends at the time of fix 4, the last one inside. 1 km around the customer Punch visit 1 2 3 4 5 6 Fix 3: one stray fixoutside, ignored. Fix 4: the last fixinside the circle. Fixes 5 and 6: twoaccurate fixes outsidein a row. Visit ends at 4.
Figure 5. A visit ends by itself only after two accurate fixes in a row beyond 1 km, and it ends at the time of the last fix still inside, so a recording gap during a visit never inflates it.

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

Written by

Nikhil Kumar

AI/ML Engineer

Digital Transformation and Alliances Department, Ritwik Finance Enterprises Private Limited