Offline-First Field Sales App: Sync Design for ERP Orders

How to design an offline-first field sales app: a local database, data split by owner, a sync outbox with idempotency keys and duplicate-safe ERP order posting.

· 6 min

A sales rep walks into a hardware store on the edge of town, the signal drops to one bar, and the owner wants to order now. If the app shows a spinner, the rep goes back to pen and paper, and someone at head office retypes the order into the ERP the next day.

BernSFA is the field sales (SFA) platform we build at BernSoftware for that rep: an iOS and Android app for routes, visits, orders, collections and van sales, a role-based web panel for managers, and two-way sync with the customer's ERP, which in Turkey usually means Logo, Mikro, Netsis or SAP. These are the architecture decisions that mattered most.

Offline is the normal case, not the error case

The first decision shapes everything else: the app never waits for the server. Order entry, customer cards and field forms (surveys, shelf and price checks, photos, signatures) all work without a connection and sync when it returns.

So the device keeps a real local database (SQLite in our case), not a cache. The UI reads and writes only locally; sync is a background process the screens never wait on. Design for the worst stretch without signal you see in the field, not the average one.

Split the data by who owns it

Most sync conflicts disappear once ownership is clear. We split data three ways:

  1. Master data the ERP owns: products, price lists, accounts, balances, stock and campaigns. The device holds a read-only copy, so there is nothing to merge.
  2. Transactions the device owns: orders, collections, check-ins and forms. Created on the device, never edited in place, sent upstream exactly once. A correction is a new record (a return, a cancellation), not an edit.
  3. Shared records: mainly customer card edits, like a new phone number. These are the only real conflict risk, so they travel as field-level change requests against the version the rep last saw. If the record has changed since, the request goes to review instead of overwriting newer data.

The rule that protects you: never let the device overwrite master data, and never let the server silently rewrite a transaction.

A sync queue that survives bad signal

Every transaction is written to an outbox table in the same local database transaction that saves it. A background worker drains it. Simplified:

type OutboxItem = {
  id: string;            // client-generated UUID = idempotency key
  kind: "order" | "collection" | "visit" | "form";
  payload: unknown;
  attempts: number;
  nextAttemptAt: number; // epoch ms
  status: "pending" | "sent" | "failed";
};

async function drainOutbox(db: LocalDb, api: Api) {
  const due = await db.outbox.due(Date.now()); // pending, ordered by creation time
  for (const item of due) {
    try {
      await api.post(`/sync/${item.kind}`, item.payload, {
        headers: { "Idempotency-Key": item.id },
      });
      await db.outbox.markSent(item.id);
    } catch (err) {
      if (isPermanent(err)) {           // validation error: needs a human
        await db.outbox.markFailed(item.id, err);
        continue;
      }
      const delay = Math.min(2 ** item.attempts * 5_000, 15 * 60_000);
      await db.outbox.reschedule(item.id, item.attempts + 1, Date.now() + delay);
      break; // network is likely down; stop and retry later
    }
  }
}

Details that matter more than they look:

  • The ID is created on the device. It becomes the idempotency key for every later step.
  • Process in order. A collection that refers to an order must not arrive before the order.
  • Separate temporary from permanent failures. A timeout gets backoff; a validation error, such as a closed account, goes to a "needs attention" list the rep can see. Retrying it forever just hides it.
  • Show the sync state. A small "3 waiting to send" badge builds more trust than silence.

ERP integration: idempotent, asynchronous and boring

Our backend (Node.js with PostgreSQL) sits between devices and the ERP; devices never talk to the ERP directly. The ERP is often reachable only inside the customer's network, interfaces vary widely (REST, SOAP/XML, Logo's own API layer), and the ERP goes down for maintenance just like a phone loses signal. So the backend acts as a second outbox:

  1. The order arrives with its idempotency key, is saved in PostgreSQL and acknowledged immediately.
  2. An integration worker posts it through a per-ERP adapter (Logo, Mikro, Netsis, SAP, or generic REST/SOAP).
  3. The adapter checks before inserting. Our order ID goes into a reference field on the ERP document and is looked up first, so a retry after an ambiguous timeout never creates a duplicate.
  4. The ERP document number is stored and pushed back, so the rep sees "posted", not just "sent".

Master data flows the other way: stock, products, accounts, price lists and campaigns are pulled from the ERP and sent to devices as deltas, not full downloads. Orders go up immediately; bulk data such as stock and prices syncs at set intervals that follow how fast each dataset changes. Each order records the price-list version it was priced with; if a newer one exists on arrival, the order is flagged for approval rather than silently repriced.

Field UX: one hand, bright sun, few taps

Architecture won't save a slow order screen. Our principles:

  • One-handed. Reps often hold a catalogue or a box, so main actions sit within thumb reach at the bottom.
  • Readable in sunlight. High contrast, large numbers for quantities and totals, never colour alone to carry meaning.
  • An order in a few taps. Start from recent items, use steppers for quantity, apply discount rules automatically.
  • Verify location at check-in. Visits use location-verified check-in rather than constant tracking, which also saves battery.

Discovery, prototype, pilot, then rollout

Every deployment follows four steps: discovery (sales process, roles, discount and approval rules, ERP setup), a clickable prototype of the rep's screens before any sync code, a pilot with one region or team, then rollout with training to the whole field force.

The pilot is where offline-first designs prove themselves. In one rollout, orders that used to wait a business day to be retyped into Logo started reaching the ERP as soon as the phone had signal, and tuning the location logic cut battery use by about 45%. Starting small means a mistake reaches only one region instead of the whole field team.

Data stays with the customer

Customer names, addresses, balances and signatures are personal data under KVKK, Turkey's data protection law. The platform runs on the customer's servers or a cloud they choose, with encrypted traffic, role-based permissions, audit logs and backups. Limiting each device to the data its rep needs also reduces exposure if a phone is lost.

Takeaways

  • The local database is the UI's source of truth; sync runs in the background.
  • Split data by owner; only shared records need conflict handling.
  • Use client-generated IDs as idempotency keys from phone to backend to ERP.
  • Queue in front of the ERP too. It has bad days just like a rep's phone.
  • Pilot with one team before rolling out to everyone.

Planning a project like this?

Plan it in 10 steps