Skip to content
Farhad Nazir
  • Flutter
  • SaaS
  • Offline-first
  • POS

Offline-first POS: a sale that cannot wait for the network

How I made Asaan Optics record sales with no internet: one write path, idempotency keys end to end, a dead-letter list, stock as deltas and a per-till ledger.

Farhad Nazir

7 min read

The customer at the counter does not care that the shop’s Wi-Fi just dropped. They are holding a new pair of glasses, they want a receipt, and they are not going to wait for a router to restart.

That constraint shaped more of Asaan Optics than any framework choice. Asaan Optics is the optical shop ERP and POS I build and run: prescriptions, orders, repairs, stock and counter sales, with a Flutter app for Android phones, a Flutter app for Windows PCs, and a Node.js API that both share. Shops lose their connection at the worst moments, so recording a sale offline is a core requirement, not an edge case.

This article walks through how I made that work in practice: one write path, idempotency keys from the device to the database, a dead-letter list for writes that will never succeed, stock recorded as changes, a local ledger per till, and serial numbers that cannot repeat across devices. It also covers the trade-offs I accepted on purpose.

One write path for everything

Every write in the app goes through one service. It tries the API first. If the device is offline, or the call fails for a reason that might fix itself, the request is saved to a durable queue on the device instead of being lost. No screen has its own offline logic, which means there is exactly one place to get this right and one place to test it.

Each queued write is tagged with the shop it belongs to, so replay always goes to the right account. The queue replays when the connection comes back and again when the app starts, and a lock stops two triggers from replaying it at the same time. Replay runs in the order things happened, because a sale that references a new customer has to arrive after that customer.

Here is a simplified illustration of the shape of it (not the production code):

Future<WriteResult> submit(PendingWrite write) async {
  // A new write gets a key once; every retry reuses the same key.
  final request = write.withKey(write.key ?? newIdempotencyKey());
  try {
    return WriteResult.sent(await api.send(request));
  } on TransientFailure {
    await queue.persist(request); // saved to disk before we return
    return WriteResult.queued();
  }
}

Idempotency keys, end to end

A queue on its own creates a new problem. The dangerous case is not “no internet”. It is the request that reached the server, was saved, and then lost its response on the way back. The app sees a timeout, queues the sale, and replays it later. Without protection, the shop now has two sales for one pair of glasses, and the stock and the day’s cash total are both wrong.

I solved this on both sides. The client attaches an idempotency key to the write once, and that same key travels with the queued request through every retry. On the server, a middleware stores the first successful response for each key. If the same key arrives again, the server does not run the operation a second time: it returns the stored response and marks it with an Idempotent-Replayed header, so the app can tell a replay from a fresh write.

There is one more case. If a retry arrives while the first request is still being processed, the server answers with a 409 meaning “still processing”. That specific 409 is the only conflict the client retries. Any other conflict is treated as a real answer.

// Simplified illustration of an idempotency middleware
async function idempotency(req, res, next) {
  const key = req.get("Idempotency-Key");
  if (!key) return next();

  const saved = await store.find(key);
  if (saved?.state === "done") {
    res.set("Idempotent-Replayed", "true");
    return res.status(saved.status).json(saved.body);
  }
  if (saved?.state === "processing") {
    return res.status(409).json({ error: "still_processing" });
  }

  await store.claim(key);
  const send = res.json.bind(res);
  res.json = (body) => {
    if (res.statusCode < 300) store.complete(key, res.statusCode, body);
    else store.release(key);
    return send(body);
  };
  next();
}

The write that will never succeed

In-order replay has a cost I learned the hard way. The replay loop stopped at the first failure, which is correct when the failure is “server unreachable”. It is wrong when the server is saying “I will never accept this”, for example a queued record that points at an ID that no longer exists. That request failed, the loop stopped, and on the next attempt it failed again. Every healthy write queued behind it was stuck.

My first fix was blunt: the app detected a queue the server kept rejecting, told the owner plainly what had happened, and let them clear it. It unblocked the shop, but it was not the design I wanted.

The design now separates two outcomes. “Could not reach the server” is retried later, and the queue keeps its order. “The server checked this and said no” (a validation error in the 4xx range) moves the write to a dead-letter list for review, and the queue keeps moving. Nothing is silently thrown away, and one bad record no longer blocks everything queued after it.

Stock as changes, not numbers

One decision I would make again: stock is queued as a change, never as a final value. The queued write says “minus one frame”, not “stock is now 4”.

The reason is replay. If a phone was offline for an hour and then sends “stock is now 4”, it overwrites every sale and delivery recorded on other devices during that hour. A delta can be applied late and still give the right answer, because it describes what happened rather than what the device last believed.

For other records, such as editing a customer’s details on two devices at once, I accepted last-write-wins. For a small shop with a few devices, two people editing the same record offline at the same moment is rare, so I accepted the simpler rule. For a large chain with many tills I would not make that trade, and I would add proper conflict detection.

A ledger per till

Money needs stronger guarantees than a general queue. On the POS, every money event (a sale, a payment, a cash movement) is written to a local SQLite ledger on that terminal, together with a per-terminal sequence number, in the same database transaction. Either both are saved or neither is. There is never an event without a number or a number without an event, so when the ledger is reconciled with the server, a gap in the sequence would show up instead of going unnoticed.

Amounts in the ledger are stored as whole numbers in the smallest currency unit (paisa), which avoids floating-point rounding in totals. Shifts are tracked per terminal with X and Z reports, and the shift numbering survives a logout, so closing the app does not reset the count staff use to balance the drawer.

This local ledger is deliberately narrow. An earlier desktop version kept the shop’s data in a local SQLite database with “synced” flags and a background push. It worked, but it caused “database is locked” errors, every schema change had to be made twice, and it could not follow the product to the web. I moved to an API-first design where the server is the source of truth, and kept local storage for what a shop genuinely depends on when offline: taking the sale and accounting for the cash.

Numbers that never repeat across devices

A sale serial identifies the sale on its receipt, so a duplicate is a real problem at the counter. A phone and a PC in the same shop cannot both count from their own local state without eventually issuing the same number. In Asaan Optics, sale serials come from the server, so numbers never repeat across phones and PCs.

Prescription serials taught me a related lesson. My first version kept the next number in its own counter table, and it drifted from the real prescriptions after failed saves, retries and sync. The fix was to stop storing the answer: the next serial is derived from the highest serial that actually exists. Serials are stored as text, so the query casts them to numbers first, because “0028” and 28 do not sort the same way. A device with no connection cannot know the highest number, so it issues a clearly marked temporary serial instead of guessing.

Practical takeaways

If you are a business owner, the question to ask a developer is simple: what happens to a sale when the internet drops halfway through it. If you are a developer, these are the rules I would apply again:

  • Route every write through one function that either sends it or persists it. Offline handling scattered across screens will be inconsistent.
  • Create the idempotency key with the write, keep it through every retry, and have the server replay the first successful response.
  • Separate “unreachable” from “rejected”. Retry the first, dead-letter the second, and give someone a way to review it.
  • Queue changes (deltas) for anything that is counted, such as stock and cash, never absolute values.
  • Write a money event and its sequence number in one transaction, and store amounts as integers.
  • Let one authority issue numbers that customers see, and derive values from real data instead of keeping a second copy.
  • Decide your conflict policy on purpose. Last-write-wins is a valid choice for a small shop, as long as you know you made it.

Have a software idea or a business process that needs a proper system?

Tell me what you are trying to fix. I will reply with honest questions, a practical plan and a realistic estimate.