The brief was a field-survey app for health workers in rural districts. Records are collected offline all day and synced in the evening, often over a 2G connection.
Why full sync failed
Our first version posted every record as one JSON payload. At 3,000 records that was 4.2 MB, and on a throttled connection the request timed out after 40 seconds with nothing saved.
Send only what changed
We kept a local change log and sent compact diffs in batches of 200, each batch acknowledged before the next. If the connection dropped, the queue resumed from the last acknowledged batch.
for (const batch of chunk(changes, 200)) {
await retry(() => api.push(gzip(batch)));
await log.ack(batch.lastId);
}
The same 3,000 records synced in about six seconds. We placed first, and the client later took the approach into production.
Why batching alone wasn't the fix
Smaller requests reduce the odds of a single timeout, but on a connection that drops mid-sync they just mean you fail at batch 14 instead of batch 1. The part that actually mattered was making each batch resumable: the device never resends a batch that was already acknowledged, so a dropped connection picks up exactly where it left off instead of starting the whole sync over.
Choosing a batch size
200 records was the right number for this payload, not a universal constant. Too large and you reintroduce the timeout risk you were trying to avoid; too small and the per-batch round-trip overhead starts to dominate on a high-latency connection. The only way to pick it honestly is to test against the worst connection your users actually have, not the one in your office.
Beyond surveys
Any offline-first mobile app — field data collection, inventory counts, delivery confirmations — benefits from the same shape: keep a local append-only change log instead of a mutable snapshot, sync diffs instead of full state, and treat every batch as safe to retry.
