Back to News & Insights
Web Development September 4, 2026 · 7 min read

The network died mid-sale. The customer still had cash in her hand.

There is a shop on the ground floor of a plaza off Lagos Island. Fabric, mostly. Two staff. One...

The network died mid-sale. The customer still had cash in her hand.

There is a shop on the ground floor of a plaza off Lagos Island. Fabric, mostly. Two staff. One counter. The internet is a phone on top of the till, tethered, propped against a bottle of water because that's the corner of the room where it holds a signal.

The cashier had scanned three items. She tapped Save. The button went into that state buttons go into — greyed, a small spinner, the polite lie that something is happening. The customer was holding four thousand naira, counted out, waiting. Ten seconds. Twenty. Behind her, two more people.

The cashier did what anyone would do. She wrote it in a notebook, took the cash, and told me she'd "enter it later." Which she did — sometimes.

Every sale that lands in a notebook is a sale that will not match the inventory count on Friday. Dallio's whole promise to a retailer is your numbers are right, and a dropped connection was quietly turning that into your numbers are right when NTEL is having a good day.

The first version is localStorage and a JSON blob. It works for about a week, until you need to find "unsynced sales for this branch since Monday" and discover you're writing Array.prototype.filter over a parsed string.

The second version is a hand-rolled IndexedDB wrapper. Now you have stores, and keys, and a promise shim, and you've reinvented about 15% of a query engine, badly, in a file nobody wants to open.

The third version is the honest one: a second data layer. Local models that shadow your server models. A second set of types. A second implementation of soft delete, of tenant scoping, of "don't show voided sales." Every bug fixed on the server has to be fixed again, slightly differently, in the tab.

I'd already lived that in post 1 — Mongo for operations, Postgres for reporting, two of everything. I was not doing it a third time in JavaScript.

That line should not know or care whether it's talking to Postgres over TCP or to a database living inside the tab. forge ships no driver of its own — every backend is an optional peer dependency, which sounded like packaging trivia when I wrote post 1 and turned out to be the reason any of this is possible.

Every browser since roughly 2017 has IndexedDB built in. No wasm blob to download and host, no Web Worker, no bundler plugin, no COOP/COEP headers to argue with your CDN about.

The idb: prefix (or the alias indexeddb:) picks the adapter, and the string after the colon is the IndexedDB database name — the same thing you'd hand to indexedDB.open(name) yourself. If you'd rather not thread a URL, indexedDbDriver({ name: 'dallio' }) from forge-orm/indexeddb gets you the same adapter and lets you hand in a database you opened in your own bootstrap code.

That's the whole setup. Installed size of the database layer: zero bytes over what you already shipped.

forge push is a Node CLI. You cannot shell out to Node from a tab, so the same DDL emitter is exposed as a runtime call.

Call it at app boot. It's idempotent — existing stores and indexes are skipped, and on the SQLite tier, columns added since the last boot get patched in with ALTER TABLE … ADD COLUMN where that's safe. Anything destructive (a dropped column, a type change) is left alone and reported under report.pending for you to decide about, rather than silently eating someone's sales history.

On IndexedDB it maps onto the platform's own onupgradeneeded versioning, and rather well: adding a field is a no-op because IDB is schemaless, and adding an index back-populates existing rows for free. The engine fingerprints the DDL plan and only bumps the IDB version when the fingerprint changes, so $migrate() on an unchanged schema is a boot-time metadata check and nothing more.

The outbox is an ordinary model in the same schema — id, target id, op, a f.json() payload, queuedAt, tries. A drain loop empties it when the connection comes back:

The cashier's Save button now returns in a millisecond and is honest about it. The queue depth goes in the corner of the screen — 3 pending — because a shop owner would rather see a number than trust a spinner.

That pattern is examples/02-sqlite-browser-offline-first, and it is the shape Dallio's POS uses.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation