Tukri is a point-of-sale app for small shops in Bangladesh, and its till keeps working without a connection. When the connection drops, each sale is saved on the device and sent to the server later. This post is about the one decision in that path that destroys money when it is wrong: when is it safe to delete a queued sale from the device?
We got it wrong once, in a way no test of ours noticed, and fixed it in October. This post describes the mechanism and the rule we use now. It does not claim how many sales, if any, were affected.
How the queue works
- A sale rung offline is written to IndexedDB with a client-generated
uuid. - When the device is back online, the app (and a service worker, through Background Sync) replays each queued sale with a
POSTto the same endpoint the online till uses. - If the server saved the sale, the device may forget it.
The server treats the uuid as an idempotency key: sending the same sale twice saves it once and answers with the saved sale. That makes retrying always safe. Deleting is the dangerous part.
The bug: a 200 that was not a save
The queue removed a sale when response.ok was true. And fetch() follows redirects on its own.
Our server redirects a signed-in account that has no shop yet to the onboarding page. Now take a till that still holds one shop's queued sales, and let a different account sign in on it: a brand-new sign-up, an invitee, a platform admin. Every replay went like this:
-
POST /pos/salesis answered with a302to the onboarding page. -
fetchfollows it with aGET, and the onboarding page answers200with HTML. -
response.okis true, so the sale is removed from the device.
Nothing was saved, and every waiting sale was deleted. In a shop, that is cash in the drawer with no sale record behind it.
No test caught it, because from the server's point of view nothing failed. It never saw a failed save, only a redirect, which is a normal answer.
The rule now: ask the response for a receipt
Treat the response as evidence, and require it to name the thing you are about to delete.
-
A redirect is never a save. Replays are sent with
redirect: 'manual'. A redirect (response.redirected, orresponse.type === 'opaqueredirect') means this session was sent somewhere else, so the sale is kept and the drain stops until an account that may post it signs in. -
A 2xx counts only if its JSON names this sale. The server's answer carries the saved sale's
idanduuid, and the client compares that uuid with the record it is about to delete. - Anything else keeps the sale. An empty or unrelated 200 (a captive portal, a proxy, a truncated body) is kept and retried later. Because of the idempotency key, a retry after a save that did land is harmless.
A simplified version of the decision:
type Answer = { status: number; redirected: boolean; body?: unknown };
function confirmsSale(body: unknown, uuid: string): boolean {
const sale = (body as { sale?: { id?: unknown; uuid?: unknown } } | null)?.sale;
return (
typeof sale?.id === 'number' && Number.isInteger(sale.id) && sale.id > 0 &&
typeof sale.uuid === 'string' && sale.uuid === uuid
);
}
function classify(answer: Answer, uuid: string) {
if (answer.redirected) return 'blocked'; // never a save
if (answer.status >= 200 && answer.status < 300) {
// Only a body that names this sale is a receipt. Otherwise keep it and retry.
return confirmsSale(answer.body, uuid) ? 'synced' : 'offline';
}
// 401/403 keep the sale; a 422 about this sale needs a person; 419 and 5xx are transient.
return classifyByStatusAndCode(answer);
}
The server half matters just as much: any endpoint a JSON caller can reach answers a coded 4xx (we use codes such as SHOP_REQUIRED), never a redirect. That also protects devices still running an older service worker, which you cannot update on demand.
Three answers, not two
A related mistake: we first had only synced and failed, and every 422 was failed. A failed sale's only button was delete. So a shop whose subscription froze overnight could come back to a queue of real sales, cash already taken at the counter, with nothing to do but bin them one at a time. The queue now tells three situations apart:
- failed: the server rejected this sale (a validation error, an oversell). A person has to look at it.
- blocked: the server rejected the shop or the account (a frozen subscription, a plan limit, a different account signed in). Nothing is wrong with the sale, and it syncs unchanged once that is fixed.
- offline: nobody rejected anything. Try again later.
The comment above our classifier says it plainly: nothing there ever deletes a sale because authorization failed.
Testing it without a real shop
The browser tests run against a throwaway database on their own local server, never against real shop data. Their router can be told to answer the replay with a redirect, an empty 200, an unrelated 200, or a refusal for a frozen subscription, so the real client and the real service worker meet each of them over the network. Two details that cost us time:
- Trigger the service worker's sync from a page that has none of the app's code, using the Chrome DevTools Protocol call
ServiceWorker.dispatchSyncEvent. Otherwise the app's own drain gets there first. - Prove that a redirect was not followed from the server's own request log, not from the browser's network view. Our test runner reported a phantom
/onboardingrequest that never reached the server.
Takeaways
- Never delete client-side data on
response.okalone. Ask the response to name what it saved. -
fetch()follows redirects unless you tell it not to. For an API call, a redirect is an error. - Make writes idempotent, so "when unsure, keep it and retry" is always safe.
- Keep "this record is bad" and "you are blocked" as separate outcomes, with different buttons.
- Make the server answer JSON clients with a coded 4xx instead of a redirect.
Tukri is at tukri.bd, with a free plan.
Disclosure: we are the Tukri team. This post was written by an AI agent from our own codebase and project notes, and published by the Tukri team.
Top comments (0)