Pluton.id
Notes

Why We Took the Offline Sync Layer Out of a Point-of-Sale App

We removed offline synchronization from a Flutter point-of-sale app because its conflict handling and operational risk outweighed the value it provided.


The decision looked backwards at first

We removed the offline sync layer from a Flutter point-of-sale app used by roughly a thousand people.

That can sound like a step away from resilience. In retail software, the usual instinct is offline-first: keep selling when connectivity disappears, save every local change, and synchronize later.

We agree with the goal. A cashier should not lose work because a connection is unstable. But after examining how the app actually handled sales, stock, payments, returns, and device recovery, we concluded that a general-purpose offline synchronization system was creating more risk than it removed.

Our decision was not to make the app careless about network failures. It was to replace broad, delayed synchronization with a smaller and more explicit set of failure-handling rules.

What the sync layer was responsible for

The app stored operational data locally and later reconciled it with the server. In principle, this gave the point of sale the ability to continue working while disconnected.

In practice, the layer had to answer difficult questions:

  • Which version of stock was correct when multiple stores sold the same item?
  • What should happen when a return was recorded on one device before the original sale had synchronized from another?
  • Could a locally completed payment be safely retried after the app restarted?
  • Which records could be edited after they had already been sent?
  • How should the app recover when local data, server data, and a payment provider each reported a different state?

Those are not only mobile synchronization questions. They are business-rule questions, and the rules vary by operation.

Treating all records as synchronizable documents made the implementation look uniform. It also hid the fact that a sale, a stock adjustment, a payment attempt, and a product update have very different consistency requirements.

Offline-first is not automatically safer

Offline-first is useful when a local device can make valid decisions independently for a meaningful period of time.

For this point-of-sale workflow, that independence was limited. The server remained the authority for important information such as active product data, pricing rules, stock movements, transaction status, and permissions. A device could preserve a customer interaction locally, but it could not always determine whether completing that interaction was still valid.

The difficult cases were not clean offline periods. They were partial failures:

  • A request reached the server, but the device did not receive the response.
  • A payment terminal reported success while the app was restarting.
  • A sale was queued locally while product or stock information changed elsewhere.
  • An operator retried an action because the screen appeared stuck.
  • Two devices continued with different assumptions and reconnected later.

A background sync layer can make these cases less visible to the user, but it cannot make the ambiguity disappear. It must eventually choose whether to retry, merge, reject, or ask for review.

We preferred to make those choices explicit at the time of the operation rather than defer them into a synchronization queue that could fail much later.

The cost was in the edge cases

The Flutter application itself was not the reason to remove synchronization. Flutter gave us a practical way to build the device interface and local state management. The issue was the amount of business state we had placed behind the offline abstraction.

Every new feature had to be evaluated across at least two timelines:

  1. What happens when the device is online and receives an immediate server response?
  2. What happens when the device creates a local change, waits, retries, restarts, and synchronizes later?

That second timeline affected more than the app. It affected API design, support procedures, audit trails, reporting, and how operators understood a transaction's status.

The result was a system where a feature could appear complete in the normal path but still require substantial work for queued writes, conflict resolution, replay ordering, migration of locally stored records, and recovery after interrupted synchronization.

We were spending complexity on preserving a broad offline capability without being able to guarantee that all offline actions were safe to complete.

Payments made the boundary clear

Payments were the clearest example of why generic synchronization was a poor fit.

A payment operation must not be duplicated just because a client retries after a timeout. At the same time, the user needs a clear answer about whether payment succeeded.

The solution is not simply "sync payment records later." The server and payment integration need an idempotency strategy.

We assigned an idempotency key to an operation before sending it. If the client needed to retry, it retried with the same key rather than creating a new operation.

POST /sales
Idempotency-Key: 4e58c8a2-transaction-attempt

The server could then recognize that repeated requests represented the same intent. It could return the existing result, continue processing it safely, or report a known final state.

Idempotency does not solve every payment problem. It does not reveal a result that no system received, and it does not replace reconciliation with a payment provider. But it gives retries a defined meaning, which is more useful than hoping a local queue will replay exactly once.

What we kept instead

Removing the offline sync layer did not mean removing all local storage or making every temporary network failure fatal.

We kept local state where it had a clear and bounded purpose:

  • In-progress cart data and form state
  • Cached read data with clear freshness expectations
  • A record of operation attempts needed for retry and recovery
  • Local diagnostics that helped us understand a failed request

We did not treat that local state as an independent replica of the business database.

For write operations, we moved toward a request model with explicit status handling. The app sends an operation, persists enough information to recover from interruption, and asks the server for the operation's status when the outcome is uncertain.

This changes the user experience in an important way. Instead of silently showing a sale as complete and hoping synchronization succeeds later, the app can show that the sale is processing, completed, rejected, or needs review.

That is sometimes less convenient than an optimistic offline confirmation. It is also more honest when the system cannot safely confirm the result.

The trade-off we accepted

We accepted that some workflows require connectivity to be completed with confidence.

When a device cannot reach the service, the app can preserve the operator's work where appropriate, explain the current limitation, and allow a controlled retry. It should not imply that a business-critical transaction is final when it cannot verify that finality.

This was a product and operational trade-off, not just an architectural preference. A fully offline point of sale can be the right choice when stores have unreliable connections and the business can accept local authority, later reconciliation, and occasional exceptions.

In our case, the application needed stronger central coordination than the sync model could safely provide. We chose to concentrate that coordination on the server and make network uncertainty visible in the client.

What we would decide earlier next time

If we were starting again, we would ask these questions before choosing offline-first architecture:

  • Which actions are valid without current server data?
  • Which actions create financial, inventory, or compliance consequences?
  • Can conflicting local changes be merged by rule, or do they require human review?
  • What is the source of truth for each operation?
  • Can every write be retried safely through idempotency?
  • What must the operator see when the final outcome is unknown?

We would also avoid describing offline support as a single feature. Reading cached data, preserving a cart, retrying a request, operating during an outage, and synchronizing a local database are different capabilities. Each deserves its own design and failure model.

The lesson

We did not remove synchronization because offline use is unimportant. We removed it because the synchronization layer claimed responsibility for business decisions it could not reliably make.

For this Flutter point-of-sale app, a smaller model was easier to reason about: local UI state, explicit server operations, durable retry information, and idempotency for requests that may be repeated.

That model still requires careful backend design and clear operational states. But it gives us fewer hidden conflicts, a more understandable failure path, and a clearer boundary between what the device knows and what only the central system can confirm.