Originally published at muhammadmoiz.dev.
A point of sale system has one job that matters more than any feature: it must keep selling. On a busy Friday night a restaurant can't tell a queue of customers to wait until the Wi-Fi comes back.
When I worked on Oscar, a POS for restaurants and retail businesses in Pakistan, "works offline" wasn't a nice-to-have. It shaped the whole architecture. Here's how an offline-first POS works in the browser, and the lessons I'd pass on to any founder planning one.
What "offline-first" actually means
Many apps say they "work offline" when they really mean they show a friendly error. Offline-first flips the usual design:
- The app reads and writes a local database first. The screen never waits for the server.
- The server is updated in the background whenever a connection is available.
- The cloud stays the source of truth for reporting, so owners still see every branch's sales in one place.
For a cashier, the result is simple: punching an order, taking payment and printing a receipt behave exactly the same with or without internet.
The architecture in one picture
The React app reads and writes a local database in the browser, so it works offline. When a connection is available, a sync process sends local changes to the backend, which updates the main database used for reporting.
- React POS app ↔ Local database: Read / write
- Local database ↔ Backend API: Sync when online
- Backend API → Main database
On Oscar, the local database was WatermelonDB running on browser storage, so there was no app to install. The backend received the synced changes and mapped them to the main database.
Lesson 1: design the data before the screens
With a normal web app you can sketch the UI first and add API calls later. Offline-first doesn't forgive that. Every feature started with data modelling: the tables, the relations between them, and the functions that create, update and query records.
An order, for example, isn't one row. It has items, modifiers, discounts and payments, and all of them must be created locally, in one go, without asking the server for IDs. Here's a simplified model to show the idea:
import { Model } from "@nozbe/watermelondb";
import { children, field, date } from "@nozbe/watermelondb/decorators";
export class Order extends Model {
static table = "orders";
static associations = {
order_items: { type: "has_many", foreignKey: "order_id" },
payments: { type: "has_many", foreignKey: "order_id" },
} as const;
@field("type") type!: "dine_in" | "takeaway" | "delivery";
@field("status") status!: string;
@date("created_at") createdAt!: Date;
@children("order_items") items!: unknown;
@children("payments") payments!: unknown;
}
If the data model is right, the screens are easy. If it's wrong, every new feature fights the sync.
Lesson 2: sync is a product feature, not a background detail
The hardest bugs in offline apps aren't on the screen. They're in the sync: an order that arrives twice, a discount that disappears, two devices editing the same table. What helps:
- Every record is created with its own unique ID on the device, so the server never has to invent one and duplicates are easy to detect.
- Changes sync as a batch, so a half-sent order never shows up in reports.
- The backend owns the rules for conflicts. When two devices change the same thing, one place decides, the same way every time.
Lesson 3: real-time and offline can live together
Kitchens want orders the moment they're punched. On Oscar, orders reached the Kitchen Display System over WebSockets, and prints went to the right printer (receipt, kitchen or bar) through QZ Tray. Each real-time feature needs its own answer to "what happens offline?", decided at design time, not after the first outage.
For founders: If your product is used at a counter, in a warehouse or on the road, ask early: "What happens when the internet drops?" Adding offline support later usually means rebuilding the data layer.
Lesson 4: test the bad days, not the good ones
An offline-first app needs testing in the conditions it was built for:
- Turn the network off in the middle of an order, then back on.
- Punch orders on two devices while one is offline.
- Close the browser before a sync finishes and reopen it.
- Fill the local database with a busy week of orders and check the app is still fast.
These tests find the problems that real customers would otherwise find for you.
Is offline-first right for your product?
It adds real work, so it's worth it when people must keep working without a connection: POS systems, field-service apps, inventory and delivery tools. For a typical SaaS dashboard used at a desk, a well-built online app with good error handling is usually enough.
If you're planning a POS or another app that has to keep working when the internet doesn't, you can read how I built Oscar in the case study, or tell me about your project.
I'm Muhammad Moiz, a full-stack developer who builds web apps, SaaS and POS systems, and takes AI-built apps (Lovable, Bolt, Replit) to production. Get in touch.
Top comments (1)
Good point on unique IDs created on the device. One thing that bites on a POS is the receipt or order number, since cashiers expect it sequential. I'd keep the UUID as the real key and give each device its own prefix or counter for the visible number, so two offline tills never print the same one.