We use analytics to understand how our website is used. No personal data is collected.

December 18, 2025 · Piyush Ranjan Mishra

Building Offline-First Web Apps with Dexie.js and Firebase Sync

Offline-FirstDexie.jsFirebaseRFQTrace

RFQTrace, the multi-tenant RFQ and supplier quote management SaaS I built, uses offline-first caching with Dexie.js synced against Firebase RTDB. Manufacturing and procurement teams don’t always have reliable connectivity — a plant floor, a supplier visit, a factory with patchy WiFi — and a procurement tool that becomes unusable the moment the connection drops is a real problem for exactly the users it’s meant to serve. Offline-first wasn’t a nice-to-have here; it was a requirement driven directly by how the product is actually used.

Why Dexie.js specifically

Dexie is a wrapper around IndexedDB, the browser’s built-in structured storage — genuinely persistent, works offline by definition since it’s local to the device, and with a much friendlier API than raw IndexedDB’s callback-heavy interface. For RFQTrace’s data model (RFQs, supplier quotes, organization and permission data — structured, relational-ish data with real query needs, not just key-value blobs) Dexie’s IndexedDB-backed table and query API was a much better fit than simpler local-storage options, which don’t handle structured querying or larger data volumes well.

The core pattern: local-first reads and writes, sync in the background

Every read in RFQTrace’s UI goes against the local Dexie cache first — instant, no network round-trip, works identically online or offline. Writes (creating an RFQ, submitting a quote) also go to Dexie first, marked with a sync-pending flag, and a background sync process pushes pending writes to Firebase RTDB when connectivity is available, then reconciles the confirmed server state back into the local cache. The user experience of “create this RFQ” is instant regardless of connection quality — the sync happening after the fact is invisible unless something goes wrong.

async function createRFQ(data: RFQInput) {
  const localId = await db.rfqs.add({ ...data, syncStatus: "pending" });
  syncQueue.enqueue({ type: "create", table: "rfqs", localId });
  return localId; // UI updates immediately, sync happens in the background
}

Conflict resolution is where offline-first gets genuinely hard

The interesting problem isn’t caching data locally — it’s what happens when the same record changes both locally (while offline) and on the server (someone else’s change, synced while you were disconnected) before your local change syncs. For RFQTrace’s data model, most conflicts were avoidable by design rather than needing clever merge logic: RFQs and quotes are mostly append-only or owned by a single actor at a time (a supplier owns their own quote submission; a buyer owns RFQ status changes), so genuine concurrent-edit conflicts on the same field were rare by construction, not because I solved general-purpose conflict resolution. Where true conflicts were structurally possible (RFQ status changes, which multiple people with permission could theoretically trigger), last-write-wins with a server timestamp was a deliberate, simple choice — appropriate because the cost of an occasional lost concurrent status change was low relative to the cost of building and maintaining a more sophisticated merge system for an edge case that’s rare in practice.

Sync status needs to be visible, not hidden

Early versions hid sync state entirely from the user, on the theory that “it just works in the background” is the ideal experience. In practice, users doing something consequential (submitting a quote, awarding a job) wanted to know whether that action had actually reached the server or was still sitting pending locally — especially given the exact offline scenarios this feature exists for, where connectivity might not return for a while. Surfacing a simple, honest sync indicator (synced / pending / failed) per record, rather than hiding sync state for a falsely seamless feel, turned out to matter more for user trust than the seamlessness itself.

Firebase RTDB’s own offline support isn’t a substitute for this

Firebase’s client SDKs have their own offline persistence and reconnection handling, which raises a fair question: why build a separate Dexie caching layer on top of that? The answer is control and query flexibility — Firebase’s built-in offline cache is opaque and optimized for its own sync model, while Dexie gave RFQTrace an explicit, queryable local data layer that the application logic could reason about directly (complex filtered views, offline-computed aggregates for the RFQ comparison UI) in ways that worked reliably regardless of Firebase SDK version behavior or connection state nuances. Firebase’s own offline handling still helps at the network layer underneath; Dexie is the layer the actual UI and business logic depend on.

What I’d tell someone building offline-first into a real product

Design your data model to minimize genuine concurrent-conflict surface area before reaching for sophisticated conflict-resolution algorithms — for a lot of real products, ownership boundaries (who’s allowed to change what) eliminate most conflicts by construction, the same way it did for RFQTrace. Make sync status visible to users doing anything consequential, rather than optimizing purely for invisible seamlessness. And treat “instant local read/write, background sync” as the default interaction model from the start — retrofitting offline-first onto a product built assuming always-online is a much larger project than building it in from day one.