A driver accepts a trip, drives under a flyover, and taps "Complete" in the dead zone. The request never leaves the handset.
What happens next is the entire design problem of a field app. If the answer is "a red toast appears and the tap is lost", the driver taps again, and again, and eventually calls the office — and now a human is reconciling trips by phone.
Connectivity is not an error condition on Indian roads. It is a normal operating state, and the app has to treat it that way.
Write locally, sync separately
The core inversion: a user action never calls the network. It writes to a local queue and returns immediately. A separate process drains that queue whenever the network allows.
tap -> write to local queue -> update UI immediately
|
v
sync worker (background)
|
online? -> POST -> on success, mark synced
|
offline/fail -> back off, retry later
The UI reflects local state, so it is instant and works with the radio off. The queue is the source of truth for "things that have not reached the server yet".
import AsyncStorage from '@react-native-async-storage/async-storage';
const QUEUE_KEY = 'sync:queue';
export async function enqueue(action) {
const queue = JSON.parse((await AsyncStorage.getItem(QUEUE_KEY)) || '[]');
queue.push({
...action,
// generated on the device, before any network attempt —
// this is what makes a retry safe
idempotencyKey: uuid(),
createdAt: Date.now(),
attempts: 0,
});
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
For anything beyond a few hundred queued items, move to SQLite — AsyncStorage rewrites the whole blob on every change, which gets slow and can corrupt under a hard kill mid-write.
Idempotency keys are the whole trick
The dangerous case is not the request that fails. It is the request that succeeds and whose response is lost. The server completed the trip; the app never heard back; the app retries; without protection, the trip completes twice and the driver is paid twice.
Generate the key on the device, before the first attempt, and reuse it for every retry of that action. The server deduplicates on it:
# Django — the same key returns the original result rather than re-running
class CompleteTripView(APIView):
def post(self, request):
key = request.headers.get("Idempotency-Key")
if not key:
return Response({"detail": "Idempotency-Key required"}, status=400)
record, created = IdempotentRequest.objects.get_or_create(
key=key, defaults={"user": request.user, "state": "processing"},
)
if not created:
# a replay: return what we returned the first time
if record.state == "done":
return Response(record.response, status=200)
return Response({"detail": "in progress"}, status=409)
result = complete_trip(request.user, request.data)
record.response, record.state = result, "done"
record.save(update_fields=["response", "state"])
return Response(result, status=200)
Store the original response, not just a flag. A replay should be indistinguishable from the first call, so the app can treat both identically.
Order matters, so drain serially
Trip events have a causal order: accepted, started, completed. Fire them in parallel when the network returns and they can arrive out of order, and the server sees a completion for a trip that has not started.
let draining = false;
export async function drain() {
if (draining) return; // one drain at a time
draining = true;
try {
let queue = await readQueue();
while (queue.length) {
const item = queue[0]; // strictly head-first
try {
await post(item.endpoint, item.payload, {
'Idempotency-Key': item.idempotencyKey,
});
queue.shift();
} catch (err) {
if (isPermanent(err)) {
// 4xx: this will never succeed. Park it for review
// instead of blocking everything behind it forever.
await quarantine(queue.shift());
continue;
}
item.attempts += 1; // network or 5xx: stop, retry later
break;
} finally {
await writeQueue(queue);
}
}
} finally {
draining = false;
}
}
The permanent-versus-transient distinction is essential. A 422 from a malformed payload will fail identically forever, and a strict FIFO queue with no quarantine will wedge behind it — every later action blocked by one bad row.
Trigger on the right events
import NetInfo from '@react-native-community/netinfo';
import { AppState } from 'react-native';
NetInfo.addEventListener(state => {
// isInternetReachable, not isConnected: captive portals and
// "connected to a tower with no backhaul" both report connected
if (state.isInternetReachable) drain();
});
AppState.addEventListener('change', s => { if (s === 'active') drain(); });
Add a periodic attempt with backoff as a safety net. Event-driven triggers miss cases — notably the app being backgrounded during a long drive.
Tell the user the truth
Hiding the queue produces a worse experience than exposing it. A driver who can see "2 trips waiting to sync" understands the state of the world. One who sees a silently optimistic UI assumes everything is fine until the office calls.
Show a small persistent indicator with the pending count, make it tappable to list what is waiting, and let a manual retry exist. It costs an afternoon and removes most of the support calls.
Clock skew is real
Do not trust device timestamps for ordering on the server. Handset clocks are wrong, sometimes by hours, and users change them. Send the device time as metadata and let the server assign authoritative ordering on receipt, using the queue sequence for intra-device ordering.
Test it properly
Airplane mode is the easy case and the one everyone tests. The hard cases are the ones that actually happen:
- Request sent, response lost — kill the network mid-flight, not before it.
- App killed with a full queue — confirm nothing is lost on relaunch.
- Two devices, same account, both queued offline, both draining at once.
- A permanently failing item sitting at the head of the queue.
If those four pass, the flyover is not a problem any more.