DEV Community

Cover image for Building an offline-first guard app for Indian housing society gates
gatekeeperzero
gatekeeperzero

Posted on

Building an offline-first guard app for Indian housing society gates

A security guard at a housing society gate in India deals with a visitor every few minutes: guests, delivery riders, cabs, house help. The gate is often at the edge of the plot, under a concrete canopy, on a low-cost Android phone with patchy mobile data. If the app stops logging when the network drops, the guard goes back to the paper register, and the society loses its record of who came in.

So when we built the guard app for GateKeeperZero, "keeps working offline" was a hard requirement, not a feature. Here is how it works.

The rule: write locally first, sync later

Every gate action (a visitor check-in, a delivery, a house-help entry) goes through one function: submit directly when online, queue when offline or when the request fails for a temporary reason. The guard sees the same "done" either way. The difference is only where the record waits.

The queue is a small Zustand store, persisted to MMKV so it survives the app being killed. Connectivity comes from @react-native-community/netinfo.

export interface QueuedItem {
  id: string
  type: string            // e.g. 'visitor-checkin', 'delivery-checkout'
  payload: unknown
  idempotency_key: string // generated once, when the guard taps the button
  queuedAt: number
  retryCount: number
  lastAttempt?: number
}
Enter fullscreen mode Exit fullscreen mode

Idempotency keys stop double entries

The hardest bug in offline sync is not losing data. It is duplicating it. A request can reach the server, get saved, and the response can still be lost on the way back. The app thinks it failed and retries.

Each queued item therefore carries an idempotency key created at the moment the guard acted, not at send time. The server keeps each key for 24 hours (the longest an item is allowed to wait offline) and answers a repeat with the original result instead of creating a second entry; the batch response lists the items it saw as duplicates. Retrying becomes safe, so the client can retry freely.

Not every failure deserves a retry

Retries are capped (five attempts), and failures are split in two:

  • Temporary: no network, timeouts, server errors. The item stays in the queue.
  • Permanent: the server says the request itself is wrong, or the app version is too old for the API. Retrying won't help, so the item moves to a dead-letter queue and the guard is told it needs attention.

Draining the queue in the background

Guards don't open the app just to sync. A background task (expo-task-manager + expo-background-fetch) drains the queue when the OS wakes the app, and the queue also drains when NetInfo reports the connection is back. Items that map to the same API call go to a batch sync endpoint. A few flows that need two steps (verify a code, then check in) are handled one by one.

Personal data on a shared phone

A gate phone is shared between shifts, so the queue holds visitor names and phone numbers for as short a time as possible:

  • Dead-letter items auto-expire, and after the first day their payload is stripped down to the type and the failure reason.
  • Logging out clears the queue. A cancel token stops a drain that is already running from writing the queue back to disk after the clear, which was a real race we had to close.

What we would tell anyone building the same thing

  1. Decide the idempotency strategy before you write the first queue item.
  2. Classify failures early; "retry everything" fills phones with requests that will never succeed.
  3. Treat the on-device queue as personal data, with an expiry and a clear-on-logout path.
  4. Test with the network off at the moment the response is coming back, not just before the request.

If you work on software for Indian housing societies, or just like offline-first problems, we'd like to hear how you handle sync conflicts. More on what the guard app does at the gate: gatekeeperzero.com.

Top comments (0)