You've handed your car keys to a stranger. That's what a valet is. They drive off to park it, and the only thing between you and mild panic is a dot on a map, moving.
Then the valet drives into a basement. Or under a flyover. Or into one of the many parts of Bangalore where 4G just... isn't.
The dot stops.
Yesterday I asked what happens next. Here's the answer, and it's the most careful forty lines in the whole app.
Why the obvious version loses data
The valet app reports a location fix every few seconds, from a background task. The obvious code is: on each fix, send it to the server.
That works right up until the send fails. The background task runs in a headless context where the connection may not even be up. A fire-and-forget send from there doesn't throw anywhere you can see. The fix is just gone, and the route on the driver's map has a hole in it exactly where they were most worried.
Saved first, sent second
So nothing is ever sent before it's stored. Every fix goes into a small on-phone queue, and then the queue tries to drain:
async enqueue(fix) {
const queued = ordered([...(await read()), fix]).slice(-maxFixes);
await write(queued);
It still sends immediately. My first version enqueued and waited, which meant nothing reached the server between going online and going offline, and the valet's "last seen" went stale within a minute. The queue is for when a send fails, not a substitute for trying.
The queue has a ceiling, because a phone in a long tunnel shouldn't fill its storage with coordinates:
/** Roughly 15 minutes at the configured interval. */
export const MAX_QUEUED_FIXES = 200;
Past that, the oldest fixes go and the newest always stay. Where the car is now matters more than where it was a quarter of an hour ago.
Offline waits. Refused is dropped.
Here's the subtle part. A send can fail for two very different reasons:
- Offline. Worth trying again. Stop here, keep everything, in order.
- Rejected. The server said no: say, a fix for a job this valet isn't assigned to. That will be a no forever. Retrying it would block every good fix queued behind it.
if (result.ok) continue;
// A refused fix is consumed; an unsendable one halts the drain so the
// rest keep their order for the next attempt.
if (result.reason === 'rejected') continue;
break;
}
await write(queued.slice(index));
And when the signal comes back, the backlog replays oldest first, so the driver's map redraws the route in the order it was actually driven:
/** Oldest first, so a drain replays the route in the order it was travelled. */
function ordered(fixes: readonly LocationFix[]): LocationFix[] {
return [...fixes].sort((a, b) => a.recordedAt - b.recordedAt);
Two more things the queue survives: two drains racing each other (only one runs at a time, or the same backlog gets sent twice), and a corrupted stored value (the queue starts empty instead of crashing a background task nobody is watching).
Ten tests
Every one of those decisions has a test: send on capture, hold on failure, oldest first, the bound, refused-versus-offline, stop at the first unsendable fix, a corrupt store, a single malformed entry. Ten tests, all passing, and none of them need a phone.
What I'd tell myself before starting
"Offline" isn't an error to handle. It's a state the app spends real time in. Design for it first, and the happy path falls out for free.
Tomorrow: you've seen what happens in the tunnel. Tomorrow, the valet's side of the screen: taking a job, every step one tap, and the photo of the parked car that has to exist before the job can be confirmed. Follow the series.
ParkEase is a peer-to-peer parking marketplace for India, built solo and launching soon on Android.
Code: https://github.com/Deonkar/parkease ยท Music: ende.app (CC BY 4.0)

Top comments (0)