Skip to content
Tech Stack13 August 2026 · 10 min read

Flutter Offline-First Architecture for Field Tools in 2026

The local SQLite part is solved. What breaks a field tool is the outbound write queue, a per-table conflict policy, and Android 15's six-hour sync ceiling.

Flutter Offline-First Architecture for Field Tools in 2026

A flutter offline first app is one where the local database is the source of truth and the network is a background process that eventually agrees with it. That is cheap to say and expensive to hold, because the moment you accept the definition you own three problems the online-first version never had: an outbound write queue, a conflict policy, and a background execution budget the operating system controls rather than you.

I have shipped this pattern and I have watched it rot. BookBed is a property management SaaS I built solo from a single Flutter codebase across six platforms, starting 16 October 2025, and its calendar has to stay usable when a host is standing in a stone building with one bar of signal. Field tools raise the stakes. A technician with no signal is not inconvenienced, they are blocked, and blocked people go back to paper and never come back.

What Does Offline-First Actually Mean for a Field Tool?

A folded paper work order and a stub of carpenter's pencil resting on a chipped concrete step in a dim basement stairwell, lit by a single hard shaft of light, a dirty thumbprint pressed into the top fold

A technician in a basement with no signal can open a work order, edit it, sign it, and close it without seeing an error. Everything they touched is durable on the device the instant they touch it, and the server finds out later. The distinction that matters is not "does the app cache things" but "can the app accept new truth while disconnected." Caching is a read optimization. Offline-first is a write commitment, and the write commitment is what costs you.

Flutter's own architecture guidance frames the read side cleanly: emit locally stored data from a stream first, because that call is faster and less error-prone than a network call, then yield the remote data when it lands (Flutter offline-first design pattern). For reads, that is genuinely most of the work. A repository, a local store, a stream. Done.

The write side is where the same document offers you two options and no help choosing between them: write online-only and keep the client consistent with the server, or write locally first and accept temporary desynchronization. For a field tool the second one is not optional. So you inherit the desynchronization, and now you need a plan for it.

The Local Database Is the Easy Part

A wooden card-index drawer pulled open on a desk, tightly packed blank index cards inside with one card standing proud of the row, a loose tangle of coiled cable out of focus in the shadow behind it

Teams that fail at offline-first rarely fail at storage. They fail downstream of it, in the queue and the conflict policy, and they discover this in week five rather than week one.

SQLite on device is a solved problem. sqflite works, drift gives you typed queries and migrations, sqlite_async gives you a connection pool that does not block the UI isolate, and any of them will hold a field worker's day without complaint. If you want a shortlist of what to actually install, I keep one in Flutter developer tools worth installing. Picking among them is an afternoon of reading. It feels like progress because it produces code.

Then look at what the canonical reference does not cover. The official Flutter offline-first page documents read strategies in detail, documents two write strategies, and contains no conflict resolution section at all. Actually, that overstates it as a criticism. The page is honest about its scope, and conflict resolution is genuinely application-specific. But it means the single most-cited answer to "how do I do this in Flutter" stops precisely where the expensive part starts, and a lot of codebases stop there too.

You have felt the other half of this as a user. You tap save on a form in a lift, the spinner turns, the doors open, and you have no idea whether the thing saved. That ambiguity is the actual product defect. Offline-first exists to delete it.

How Do You Handle Writes Made Offline?

A wire mesh desk in-tray holding a neat row of small blank paper tickets standing on edge like filing tabs, the front ticket curled and leaning forward out of the row, the tray's grid shadow thrown across the desk

You write to local SQLite immediately, append the mutation to a durable queue with an idempotency key, and replay it when connectivity returns. The queue is the architecture. Everything else is detail around it.

Concretely, the order of operations for every user-initiated write:

  1. Apply the change to the local database inside a transaction. The UI reads from local, so the screen updates instantly and the user is done thinking about it.
  2. Append a mutation record to an outbox table in the same transaction. Same transaction is load-bearing. If the write lands and the outbox row does not, the change exists only on that device forever.
  3. Stamp the mutation with a client-generated idempotency key and a monotonic sequence number. The server uses the key to reject duplicates when a retry succeeds after the response was lost.
  4. Drain the outbox in order when the network returns, one mutation at a time per entity, stopping on the first permanent failure rather than skipping past it.
  5. Mark each mutation applied, failed-retryable, or failed-permanent. Permanent failures need a visible surface in the UI. A silently discarded work order is worse than a crash.

Picture the failure mode you are designing against. It is 5:40pm, a van in a supermarket car park, forty photos and six signed job sheets sitting in a queue, and the app has just been swiped away from the recents list because the phone felt slow. If those mutations were held in memory or in a provider's state rather than on disk, the day is gone. Durable-before-acknowledged is the whole rule.

Pick a Conflict Strategy Per Table, Not Per App

Choosing one global conflict policy is the most common design error I see, because the right answer genuinely differs by row shape. A user's display name and a bookable time slot should not be resolved the same way.

Data shapeStrategyWhy
Profile fields, notes, settingsLast-write-wins on server timestampSingle owner, low stakes, cheap to be wrong
Counters, tallies, photo setsAdditive merge (append-only)Two offline devices both add; neither should erase the other
Booking slots, stock allocation, assignmentsOptimistic locking with a version columnA lost update here is a double-booking, so fail the second writer loudly
Free-text reports edited by two peopleServer-side merge or manual resolution UIMachine merge produces nonsense; show both versions

The pattern that saves the most work is the one that removes the conflict instead of resolving it. Partition by owner. If a work order is assigned to exactly one technician for the duration of the job, two devices cannot write the same row, and the entire conflict question collapses into an ordering question. Append-only event tables do the same thing from the other direction. Design the schema so concurrency is rare, then handle the rare case properly.

If your backend is PostgreSQL, a version integer column plus UPDATE ... WHERE id = $1 AND version = $2 gives you optimistic locking in one line, and a zero row count is your conflict signal.

The Sync Budget Android Quietly Imposes

Most offline-first designs are written without knowing this constraint exists. Android's own foreground-service documentation for Android 15 states that the system permits dataSync and mediaProcessing foreground services to run for a total of six hours in a 24-hour period, after which it calls Service.onTimeout(); the service then has a few seconds to call stopSelf() or the system throws a RemoteServiceException (Android foreground service timeout behavior). Bringing the app to the foreground resets the timer.

Six hours sounds generous until you are syncing a photo-heavy field app over a weak mobile connection across a full working shift. A design that assumes it can hold a long-lived sync service open all day is a design that will crash on someone's phone in the eleventh hour, on a device you cannot reach, at the end of a shift when the queue is fullest.

Flutter's architecture doc flags the same territory from the battery angle, warning that running background operations continuously can drain the device battery dramatically and that some devices limit background processing capability. Treat sync as a series of bounded jobs with checkpoints, not as a daemon. Every mutation must survive the worker being killed mid-drain, which is another argument for the outbox table being the only state that matters.

Should You Use PowerSync, Electric, or Roll Your Own?

Use a sync engine when your write conflicts are genuinely concurrent and multi-user; hand-roll when each row has exactly one owner in the field. That ownership question predicts the answer better than team size or timeline does.

OptionWhat it gives youWhat you still own
PowerSyncBidirectional sync between server Postgres/MongoDB/MySQL/SQL Server and embedded SQLite, with sync rules for partial replication and a managed upload queueSync rule design, backend write validation, hosting cost above the free tier
ElectricRead-path sync from Postgres to clients: partial replication, real-time updates, fan-outThe entire write path, by design
Hand-rolled outboxTotal control, no vendor, no extra dependencyQueue, retries, idempotency, conflict policy, partial sync, and every edge case in this article

Electric is unusually direct about its boundary. Its docs state that Electric does not do write-path sync and does not provide or prescribe a built-in solution for getting data back into Postgres, then lay out four escalating write patterns you can implement yourself, from plain online writes up to through-the-database sync (Electric writes guide). About that last pattern the same page warns that a local embedded database adds quite a heavy dependency and that the shadow table and trigger machinery complicate your client-side schema. A sync vendor telling you the honest cost of the deepest option is worth more than a comparison table from someone selling you the shallow one.

PowerSync sits on the other side of the line and does handle writes. Its Dart SDK reached version 2.3.3 in late July 2026 and supports Postgres, MongoDB, MySQL and SQL Server behind the same SQLite client (powersync on pub.dev). Cost is real, though: PowerSync's 2026 pricing lists a free tier at 2 GB synced per month, 500 MB hosted, and 50 peak concurrent connections, with Pro starting at $49 per month and Team at $599 (PowerSync pricing). For a fifty-technician deployment pushing photos, model the synced-gigabytes line before you commit, because it scales with payload rather than seat count.

My own bias, from having built the hand-rolled version: it is correct for single-owner data and it is a bad trade the moment two people can edit the same row. BookBed's calendar is single-owner per unit, so the outbox pattern holds. A dispatch tool where a coordinator and a technician both touch the same job is a different product, and paying a vendor to be right about ordering is cheaper than being wrong about it in production. That calculus is one of the recurring questions in how any SaaS product gets scoped.

Partial Sync Is a UX Problem First

You cannot ship the whole database to a phone, so you ship a slice, and the user needs to know which slice. Show the boundary. "Jobs assigned to you, last 30 days" in the empty state beats a blank list every time, and it stops the support call where a technician swears a record vanished.

Where to Start on Monday

If you have an existing Flutter app to convert, do not begin with the sync engine evaluation. Open your schema and mark every table with its owner: exactly one person, or several people at once. Tables with exactly one owner can go offline this month with an outbox and no vendor. Tables with many owners are the ones that need either a version column and a real conflict UI, or a sync engine that has already thought about ordering harder than you will.

Which of your tables actually has two concurrent writers? Most teams find the answer is one or two out of thirty, and that changes the entire cost of the project.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What is the best local database for offline-first Flutter apps?

SQLite through `drift` or `sqflite` is the default answer, and the choice between them matters less than committing to SQL rather than a key-value store. Drift adds a typed query layer, compile-time-checked schemas, and a migration system, which pays off the first time you change a table that already holds a technician's unsynced work. Sqflite is thinner and fine if you are comfortable writing raw SQL. Pair either with `sqlite_async` so heavy reads do not block the UI isolate. SQL wins over Hive or shared preferences because offline-first eventually needs joins, indexes, and transactions spanning your data table and your outbox table, and a key-value store gives you none of those.

Is PowerSync worth it for a Flutter app?

PowerSync earns its cost when several people can edit the same record while offline, and is hard to justify when each record has a single owner. It handles bidirectional sync between an embedded SQLite database and Postgres, MongoDB, MySQL, or SQL Server, which makes the upload queue, retry semantics, and ordering someone else's problem rather than yours. The tradeoff is cost and coupling. Published pricing starts with a free tier of 2 GB synced per month and moves to $49 per month for Pro on PowerSync's own pricing page, and that meter runs on payload volume, so photo-heavy field apps reach it far faster than form-heavy ones. Model your monthly synced gigabytes before committing.

Can ElectricSQL handle offline writes?

No, not on its own, and the project states this plainly rather than burying it. Electric is a read-path sync engine that streams data out of Postgres to clients with partial replication and real-time updates, and it deliberately leaves getting data back into Postgres to your existing API, which its own writes guide states outright. For an offline-first field tool that means you still build the outbox, the idempotency keys, and the conflict policy yourself. Electric's own docs walk through four escalating patterns for this, ending with a local embedded database plus shadow tables and triggers, and they note that option adds a heavy dependency and complicates your client-side schema. Pick Electric for fast reads, not for free writes.

What does a good field app architecture look like?

A field app architecture is offline-first by default, with local SQLite as the source of truth, a durable outbox table backing every write, and bounded background sync jobs instead of a long-lived daemon. Partition data by owner so most rows have exactly one writer and conflicts stay rare. Sync in checkpointed batches that survive the worker being killed, because mobile operating systems will kill it. Surface queue state in the UI so a technician can see that six job sheets are still pending upload rather than assuming they are filed. Design your empty states around partial sync too, since the device holds a slice of the database and never all of it.

How do you test offline-first sync?

Make the network failure deterministic instead of waiting for a real dead zone. Put your transport behind an interface you can swap for a fake that returns timeouts, 409 conflicts, and duplicate-request responses on demand, then write integration tests that kill the process mid-drain and assert the outbox replays correctly on restart. The bugs that reach production are almost never plain loss of connectivity; they are partial connectivity, a response lost after the server already committed, or a worker terminated between two mutations. On Android, simulate the six-hour foreground-service timeout rather than trusting that a long sync will simply finish; Android's foreground service timeout documentation spells out the exact behavior, including the exception thrown if you ignore it.