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 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

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?

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:
- 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.
- 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.
- 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.
- 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.
- 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 shape | Strategy | Why |
|---|---|---|
| Profile fields, notes, settings | Last-write-wins on server timestamp | Single owner, low stakes, cheap to be wrong |
| Counters, tallies, photo sets | Additive merge (append-only) | Two offline devices both add; neither should erase the other |
| Booking slots, stock allocation, assignments | Optimistic locking with a version column | A lost update here is a double-booking, so fail the second writer loudly |
| Free-text reports edited by two people | Server-side merge or manual resolution UI | Machine 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.
| Option | What it gives you | What you still own |
|---|---|---|
| PowerSync | Bidirectional sync between server Postgres/MongoDB/MySQL/SQL Server and embedded SQLite, with sync rules for partial replication and a managed upload queue | Sync rule design, backend write validation, hosting cost above the free tier |
| Electric | Read-path sync from Postgres to clients: partial replication, real-time updates, fan-out | The entire write path, by design |
| Hand-rolled outbox | Total control, no vendor, no extra dependency | Queue, 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.
