Offline-first point of sale with IndexedDB and Dexie

Building an offline-first point of sale with IndexedDB and Dexie: local-first writes, a sync queue, idempotent server endpoints and the edge cases a POS hits.

By Hamza Ghouri

A point of sale has one job it can never fail at: taking the sale. If the shop's internet drops and the till stops working, the customer walks out and the sale is gone. That's why I built Sabusons POS as an offline-capable point of sale, with IndexedDB through Dexie on the client and React, Express, TypeORM and PostgreSQL around it, plus i18n and QR support. This post covers the offline-first POS pattern with IndexedDB and Dexie: how to structure local writes, how to sync them, and the edge cases that bite.

Why offline-first, specifically

"Works offline" can mean two different designs. In one, the app talks to the server and falls back to local storage when the network fails. In the other, the app always writes locally first and syncs in the background. The second is offline-first, and for a POS it's the one I'd choose every time.

The fallback design has two code paths for every action, and the offline one is the path you test least. Offline-first has one path: write to the device, show success, sync later. The network becomes a background concern, and a slow connection feels the same as a fast one at the counter.

Write to IndexedDB first

IndexedDB is the browser's built-in transactional database. Its raw API is awkward, and Dexie wraps it with promises, schema versions and a readable query API. Here's the core of the local side, covering how I'd structure it:

import Dexie, { type Table } from "dexie";

export interface Sale {
  id: string; // generated on the device, so a retried sync can't create a duplicate
  items: { sku: string; qty: number; unitPriceCents: number }[];
  totalCents: number;
  createdAt: string;
  status: "pending" | "synced";
}

class PosDB extends Dexie {
  sales!: Table<Sale, string>;
  constructor() {
    super("pos");
    this.version(1).stores({ sales: "id, status, createdAt" });
  }
}

export const db = new PosDB();

export async function recordSale(items: Sale["items"]): Promise<Sale> {
  const sale: Sale = {
    id: crypto.randomUUID(),
    items,
    totalCents: items.reduce((sum, i) => sum + i.qty * i.unitPriceCents, 0),
    createdAt: new Date().toISOString(),
    status: "pending",
  };
  await db.sales.add(sale);
  return sale;
}

export async function syncPending(): Promise<void> {
  const pending = await db.sales.where("status").equals("pending").limit(50).toArray();
  if (pending.length === 0) return;

  const res = await fetch("/api/sales/batch", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(pending),
  });
  if (!res.ok) return; // left as pending; the next sync attempt picks them up

  const { synced } = (await res.json()) as { synced: string[] };
  await db.sales.where("id").anyOf(synced).modify({ status: "synced" });
}

A few decisions in there are worth calling out:

  • Ids come from the device. crypto.randomUUID() gives every sale a stable identity before the server ever sees it. That's what makes retries safe.
  • Money is integer cents. Floating-point totals drift. Integers don't.
  • Status is an indexed string. IndexedDB can't index booleans, so a "pending" string is what you query on.
  • Only confirmed ids get marked synced. The client trusts the server's list, never its own assumption that the request worked.

Run syncPending() on an interval, when the browser fires the online event, and right after each sale. Calling it often is cheap when there's nothing pending.

Make the server endpoint idempotent

The network will fail halfway through a sync: the server saves the batch, the response never arrives, and the client sends the same sales again. The server has to accept the same sale twice and store it once. With a client-generated primary key, Postgres does the hard part:

import express from "express";
import { Pool } from "pg";
import { z } from "zod";

const pool = new Pool();
const app = express();

const SaleBatch = z.array(
  z.object({
    id: z.string().uuid(),
    items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive(), unitPriceCents: z.number().int() })),
    createdAt: z.string().datetime(),
  }),
).max(50);

app.post("/api/sales/batch", express.json({ limit: "1mb" }), async (req, res) => {
  const parsed = SaleBatch.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "invalid batch" });
    return;
  }

  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    for (const s of parsed.data) {
      const totalCents = s.items.reduce((sum, i) => sum + i.qty * i.unitPriceCents, 0);
      await client.query(
        `INSERT INTO sales (id, items, total_cents, created_at)
         VALUES ($1, $2, $3, $4)
         ON CONFLICT (id) DO NOTHING`,
        [s.id, JSON.stringify(s.items), totalCents, s.createdAt],
      );
    }
    await client.query("COMMIT");
    res.json({ synced: parsed.data.map((s) => s.id) });
  } catch {
    await client.query("ROLLBACK");
    res.status(500).json({ error: "sync failed" });
  } finally {
    client.release();
  }
});

The server recomputes the total from the line items and ignores the client's figure. In a real system the server also checks prices against its own catalogue. The same pattern carries over to an ORM; I compared the two main Node.js options in TypeORM vs Prisma.

Stock, prices and conflicts

Sales are append-only, which makes them easy to sync: two tills can never edit the same sale. Shared state is where offline gets hard.

  • Stock levels. Two offline tills can both sell the last unit. You can't prevent that without a network, so record what was sold, let the server deduct stock when the sale arrives, and flag negative stock for a person to resolve. On a multi-tenant retail SaaS I work on, POS counter sales deduct inventory and post to a ledger on the server, which keeps that logic in one place.
  • Prices. Snapshot the price into each sale line at the moment of sale. If the catalogue changes later, past sales still show what the customer paid.
  • Catalogue updates. Pull products down into IndexedDB when online, so lookups and QR scans work offline too.

For a multi-shop setup, the server side has its own tenancy questions, which I go into in multi-tenant SaaS architecture lessons.

The edge cases that bite

  • Storage eviction. Browsers can clear site data under storage pressure. Call navigator.storage.persist() to request persistent storage, and show a warning if unsynced sales sit on the device too long.
  • Schema changes. Bump the Dexie version and add an upgrade function. Devices that were offline during a release will upgrade whenever they next load the app.
  • Multiple tabs. Two tabs syncing at once is harmless here thanks to idempotency, but it's wasted work. Use the Web Locks API or a simple leader tab if it matters.
  • Device clocks. createdAt comes from the device and can be wrong. Store the server's receive time as well.
  • Translations offline. With i18n, bundle the language files with the app so they load without a network.

The short version

Write every sale to IndexedDB first, give it a device-generated id, and sync in the background to an endpoint that accepts the same sale twice and stores it once. Keep sales append-only, let the server own stock and prices, and plan for storage eviction and schema upgrades from day one. You can see Sabusons POS alongside my other work on the projects page.

Want this kind of work on your product?

Get in touch