The Reality of Retail Networks
If you have ever stood in line at a busy coffee shop or retail store when the internet drops, you know the panic. The cashier taps the screen repeatedly, the spinner hangs indefinitely, and the customer queue spills out the door.
In point-of-sale (POS) engineering, the network is an unreliable luxury. If your mobile POS relies on a synchronous HTTP roundtrip to record an order or print a receipt, your application will fail when your merchants need it most.
When engineering point-of-sale systems—both at high-scale scale-ups like Opaper and in our independent retail platform Sobatoko—our core design philosophy has always been: the local device is the single source of truth for transactions; the cloud is an eventual consistency target.
Here is the architectural pattern we use to achieve uninterrupted, 60 FPS offline-first cashier operations with React Native.
1. Local-First Transaction Logging with SQLite
Rather than dispatching an axios.post('/orders') and waiting for the API to respond before printing a receipt, the client follows an append-only event log model.
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────────┐
│ Cashier Taps │ ───> │ Write to SQLite │ ───> │ Fire Bluetooth ESC/POS │
│ "Charge Rp 50k" │ │ Local State Instant │ │ Thermal Print Job │
└─────────────────┘ └──────────────────────┘ └────────────────────────┘
│
▼ (Async Background Worker)
┌──────────────────────┐
│ Sync Queue Engine │ ───> Cloud API (When Online)
│ With Idempotency Key │
└──────────────────────┘
The Transaction Flow:
- Local Atomic Write: When the cashier clicks “Bayar” (Pay), an atomic SQLite transaction writes the order, decrements local stock quantities, and assigns a client-generated UUID
order_id. - Immediate Receipt Dispatch: The receipt data is formatted and pushed directly over Bluetooth or TCP socket to the thermal printer. Total latency: < 150ms.
- Queue Enqueue: A background synchronization queue registers a sync job containing the payload and the unique idempotency key.
// Sample SQLite transactional insertion with idempotency key
import * as SQLite from 'expo-sqlite';
export async function recordOfflineTransaction(db: SQLite.SQLiteDatabase, order: OrderPayload) {
return await db.withTransactionAsync(async () => {
// 1. Insert order record locally
await db.runAsync(
`INSERT INTO local_orders (id, customer_id, total, status, created_at, sync_status)
VALUES (?, ?, ?, 'completed', ?, 'pending')`,
[order.id, order.customerId, order.total, Date.now()]
);
// 2. Adjust local inventory immediately
for (const item of order.items) {
await db.runAsync(
`UPDATE local_inventory SET stock = stock - ? WHERE item_id = ?`,
[item.quantity, item.itemId]
);
}
// 3. Register into persistent sync queue
await db.runAsync(
`INSERT INTO sync_queue (action, payload, attempts, idempotency_key)
VALUES ('SYNC_ORDER', ?, 0, ?)`,
[JSON.stringify(order), order.id]
);
});
}
2. Idempotency & Conflict Resolution
When the device regains Wi-Fi or cellular connectivity, the synchronization engine flushes the queue in sequential batches. But what happens if the server accepts the order, but the device’s connection drops before receiving the HTTP 200 OK response?
Without idempotency, the queue engine would retry and bill or create a duplicate order.
The Invariable Rules:
- Client-Generated UUIDs: The server never generates primary keys for offline-capable entities. The client generates cryptographic UUIDv4 identifiers.
- Strict Idempotency Keys: Every sync request carries an
X-Idempotency-Key: <order_uuid>HTTP header. The backend checks Redis or PostgreSQL for existing transactions matching that key. If found, it returns the cached result without re-executing business logic. - Last-Write-Wins with Sequence Vectors: For product catalogue edits (like price changes made on the web dashboard while a cashier is offline), we use monotonically increasing revision timestamps. The client only accepts server updates if
server.revision > local.revision.
3. Direct Hardware Communication: Bluetooth ESC/POS
Most third-party POS libraries wrap thermal printers in bloated native SDKs that frequently crash React Native bridge threads.
We prefer lightweight raw byte stream communication using the ESC/POS command standard:
// ESC/POS Command Byte Assembly
const ESC = 0x1b;
const GS = 0x1d;
export function buildReceiptPayload(order: OrderPayload): Uint8Array {
const buffer: number[] = [
ESC, 0x40, // Initialize printer
ESC, 0x61, 0x01, // Center align
];
// Store Header
buffer.push(...encodeText("SOBATOKO STORE\n"));
buffer.push(...encodeText("Invoice: #" + order.id.slice(0, 8) + "\n\n"));
// Left align for item list
buffer.push(ESC, 0x61, 0x00);
for (const item of order.items) {
buffer.push(...encodeText(`${item.name.padEnd(20)} ${item.price}\n`));
}
// Paper cut command
buffer.push(GS, 0x56, 0x41, 0x10);
return new Uint8Array(buffer);
}
By streaming raw byte buffers over BLE (Bluetooth Low Energy) using direct MTU slicing (typically 20 to 128 bytes per chunk), receipt printing is instantaneous and immune to app reloads or background thread killing.
Conclusion & Business Impact
Building offline-first is harder than building traditional CRUD apps, but the payoff for retail products is massive:
- Zero Cashier Stalls: Cashiers can complete 100+ checkouts even during full internet blackouts.
- Snappy 60 FPS Experience: Because reads and writes hit SQLite locally instead of an API gateway, UI transitions never wait on network latency.
- Merchant Trust: In retail, when software never fails during peak lunch hours, merchants never leave.
Need Help Building Production Mobile Systems?
If your team is designing a mobile app, retail POS, or hardware-integrated software that needs mission-critical reliability, renodotdev can help you architect and ship it without junior handoffs.
- Chat directly with Reno on WhatsApp (+62 812 1299 6550)
- Or reach out via email at renodotdev@gmail.com