Offline-First, Budget-First: Building POS Software for a Solo Retailer
Most POS write-ups start with a chain of stores or a fast-growing D2C brand. This one starts with a single laptop on a counter in Islamabad, a thermal printer, and a barcode scanner that behaves like a keyboard.
The problem
A retailer that already sells online has a specific version of a common problem. They have a Shopify store, a real product catalog, real inventory counts, and now they are opening a physical location too. The obvious move is to buy a POS system off the shelf. In practice, almost every commercial option assumes a much bigger business than the one actually asking for it.
Licensing costs scale for teams, not for a single till. Most POS platforms want to be the source of truth for inventory, which means either migrating the whole catalog into a second system or running two inventories side by side and hoping they agree with each other. Neither is acceptable for a small operator who has already built their catalog, pricing, and online sales flow around Shopify. And almost none of them are built with the assumption that the internet connection might just stop for a while, which is a normal Tuesday in parts of Pakistan.
Craftan, a Multani blue pottery and handicrafts retailer, is a case in point. Their online store runs on Shopify. Their new physical shop sells the same catalog: blue pottery, wooden handicrafts, one-of-a-kind pieces where "just recount the shelf" isn't a real fallback if a number goes wrong. Staff working the counter are not technical, transactions are frequently cash, and the connection is good enough most of the time and unreliable at exactly the moments you'd rather it wasn't.
That combination ruled out buying something off the shelf. It also ruled out building something that treated Shopify as an afterthought.
The build
The design decision that mattered most was refusing to create a second inventory. Shopify's catalog stays the single source of truth. The POS is a client to it, not a competitor to it.
The app itself is a custom web app, not installed software. React and Vite on the frontend, Tailwind for styling, Zustand for state. The backend is Supabase, Postgres with Edge Functions. It's deployed on Vercel with GitHub-based CI/CD, so a git push ships an update to the till without anyone touching the store machine directly. In practice it runs in Chrome on a Windows laptop at the counter.
Every sale hits Shopify live, per transaction, through a Supabase Edge Function that acts as a proxy. Sales decrement inventory using decrement_ignoring_policy. Returns and voids restock using adjust_inventory with a positive delta. That distinction turned out to matter a lot more than it sounds like it should, which the next section gets into.
The harder problem was connectivity, and here the build went further than "resilient to drops." It's offline-first by design. Every sale writes to a local IndexedDB store first, via Dexie.js, tagged as local_only. A sync engine then pushes pending records to Supabase and Shopify, polling every 30 seconds and also firing whenever the browser reports it's back online. Failed syncs retry up to three times before landing in a manual "Sync Errors" panel in settings, so nothing silently disappears. A small connectivity banner in the UI tells staff, at a glance, whether they're syncing live or queued locally. The whole thing is a PWA built with vite-plugin-pwa and Workbox, so the app shell itself loads with zero connection. If the internet drops mid-shift, the till doesn't know or care until it's back.
The physical setup is deliberately unglamorous. A Windows laptop, a USB barcode scanner that works as a plain keyboard input with no driver to install, and an 80mm thermal receipt printer wired through the browser's own print dialog rather than a dedicated print driver. Card payments are recorded in the system but processed on a separate card machine, the same way most small retailers already handle cards. Cash has no integrated drawer. It gets counted manually against the system's expected total at close, which is normal practice for an operation this size and avoids adding a piece of hardware that isn't solving a real problem.
The friction
The worst bug in the project wasn't a UI glitch. It was a sign error in inventory adjustment, and it's worth describing exactly because it's a good example of how Shopify's inventory model punishes assumptions.
A sale would correctly show something like Available 4, Committed 3, On-hand 7. Process a return on that sale, and On-hand should climb to 8. Instead it dropped to 6. The return was restocking with a negative delta instead of a positive one, silently making inventory worse every time the "fix" was applied.
The root cause wasn't a typo, it was a conceptual gap. Shopify's inventory adjustment API has narrow, unforgiving semantics. Sales need decrement_ignoring_policy. Returns and voids need adjust_inventory with an explicit positive delta. The seemingly obvious choice, restock_type: 'return', actively breaks on-hand counts for unfulfilled orders rather than fixing them. None of this is intuitive from the outside, and getting it wrong doesn't throw an error. It just quietly corrupts stock numbers until someone notices the shelf doesn't match the screen.
For a retailer selling mass-produced items, a bad count is an annoyance. For a retailer selling handmade blue pottery and wooden handicrafts, where each piece is genuinely one of a kind, an inventory system that lies to you is close to useless. The rule had to be written down explicitly and carried forward into every piece of work that touched inventory afterward, because this is exactly the kind of mistake that's cheap to make twice if it isn't documented once, clearly, in one place.
The result
The store has been running on this system since it opened in May. It's held up well, and the work since launch has mostly been the kind of iteration that only shows up once real staff are using something daily rather than testing it: more detail on printed receipts, and a smoother return and exchange flow than the first version had. Nothing dramatic. The unglamorous, steady kind of "it just works now" that a small retailer actually wants from the system running their till.
The takeaway
The interesting engineering problems in small-business software are rarely the ones that make it into enterprise case studies. Nobody needs a distributed systems paper to explain a boutique's till. What they need is a system that respects the constraints they actually operate under: one inventory, not two, a connection that can't be trusted to stay up, staff who shouldn't need training to ring up a sale, and a budget that rules out paying for features nobody in the shop will ever touch. Building for those constraints, honestly, without pretending the business is bigger than it is, is its own kind of hard problem. It just doesn't look like one from the outside.
It's also the same discipline that shows up in our web and mobile app development work more broadly: match the build to the constraints that are actually real, not the ones a bigger client would have.
If you're weighing a similar build — a system that has to work offline, respect an existing platform as the source of truth, and not cost more than the business it's serving — we're glad to talk it through.