Offline-First Flutter: Lessons from Building Hishabee and Smart Bazaar

Two apps, same non-negotiable requirement: they have to work perfectly with no internet connection at all, because that's the reality for a lot of the people using them.

Hishabee is a business ledger for shop owners — invoices, inventory, expenses, Excel import/export, localized into four languages. Smart Bazaar is a Bengali-language grocery app built the same way. Neither is a "sync eventually" app where offline is a fallback state. For both, offline is the primary state, and connectivity is the exception you design around.

That framing changes which database you reach for.

Why Drift over plain sqflite or Hive

All three are viable for local storage in Flutter, and I've shipped with each of them at some point. For a ledger app specifically, the deciding factors were:

  • Real relational queries. An invoice references a customer, references line items, references inventory. Hive's key-value model means modeling relationships and joins yourself in Dart; Drift gives you actual SQL underneath with type-safe Dart bindings on top, so a query like "total expenses this month, grouped by category" is a query, not a manual reduce over three separate boxes.
  • Compile-time safety on schema changes. Drift generates typed classes from your table definitions, so a renamed column is a compile error in your repository code, not a runtime KeyNotFoundException discovered by a shop owner three months after you shipped it.
  • Migrations you can actually reason about. Both apps have shipped schema changes post-launch — adding fields to an invoice, adding new inventory categories. Drift's migration API makes "what does this look like for a user who installed version 1.0 and never updated until 1.4" an answerable question.
// Drift table definition — this becomes a fully typed
// Dart class, and schema changes are caught at compile time.
class Invoices extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get customerId => integer().references(Customers, #id)();
  DateTimeColumn get issuedAt => dateTime()();
  RealColumn get total => real()();
  BoolColumn get isSynced => boolean().withDefault(const Constant(false))();
}

Plain sqflite gets you the same underlying engine without the codegen — you'd be hand-writing SQL strings and manually mapping rows to models, which is exactly the kind of repetitive, error-prone glue code Drift exists to remove.

Designing for "no connection," not "bad connection"

Every write in Hishabee — a new invoice, an inventory adjustment, an expense entry — goes to the local Drift database first and only afterward gets marked for sync. The UI never blocks on a network call to complete an action a shop owner is standing at their counter waiting on. That isSynced boolean above isn't decoration — it's how a background sync worker knows what still needs to go up, and it's also how the UI can honestly show a small "pending sync" indicator instead of pretending everything is always current.

A rule I hold to: if a feature only works with a connection, it needs a visibly different state in the UI, not a spinner that quietly never resolves. Users trust an app more when it tells them the truth about its own state than when it hides the truth behind "still loading."

Localization isn't a translation file, it's a data-shape decision

Smart Bazaar is Bengali-first, and Hishabee ships in four languages. Flutter's official localization tooling (ARB files and generated AppLocalizations) handled the UI strings without much friction. The part that actually took thought was numeral and currency formatting — Bengali uses its own digit forms in some contexts, and a ledger app is exactly the kind of product where a misformatted number is a trust-breaking bug, not a cosmetic one. That pushed number and currency formatting into a single shared utility layer rather than scattering NumberFormat calls through individual screens, so a formatting fix only ever needs to happen in one place.

Excel import/export as a first-class feature, not an afterthought

A lot of the shop owners using Hishabee already keep records in Excel out of habit, sometimes because that's what an accountant expects to receive. Rather than treat import/export as a "nice to have," the Drift schema was designed with a straightforward mapping to a flat spreadsheet shape from day one — which made round-tripping data in and out far less painful than retrofitting it later would have been.

What carries over to any offline-first app

  • Pick a local database that gives you real relational queries and compile-time schema safety if your data has any relationships at all — Drift over Hive for anything beyond simple key-value caching.
  • Every write lands locally first. Sync is a background concern, never a blocker on the user completing their task.
  • Track sync state explicitly (a boolean, a timestamp, a status enum) and surface it honestly in the UI instead of hiding it behind a generic spinner.
  • If you know export/import to a format like Excel is coming, shape your schema with that mapping in mind from the start.