Skip to content
Tech Stack13 August 2026 · 8 min readUpdated 30 September 2026

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 foreground-service limit.

Flutter Offline-First Architecture for Field Tools in 2026

What you'll learn

  • What Does Offline-First Mean for a Field Tool?
  • How Do You Handle Writes Made Offline?
  • Should You Use PowerSync, Electric, or Roll Your Own?
Article contents

If a technician opens your app in a basement with no signal, what happens when they tap save? Answer that before you pick a database. A flutter offline first app keeps the local database as the source of truth and treats the network as a background process that eventually agrees with it. That is cheap to say and expensive to hold, because the moment you accept it you own three problems an online-first app never had: an outbound write queue, a conflict policy, and a background execution budget the operating system controls.

First action: open your schema and write next to every table whether one person or several can edit its rows. The rest of this post depends on that answer.

BookBed is a Flutter property management app I built solo for six platforms, with Firebase and Stripe behind it. Its timeline calendar has overbooking detection, which makes it a useful mental model here: a lost update on a booking is a double-booking. I'm not going to claim BookBed is a field tool, though. The examples below are hypothetical, labelled as such, and the numbers come from the linked docs.

What Does Offline-First 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

It means a user with no signal can open a record, edit it and finish the job without an error, because every change is durable on the device the moment they make it and the server hears about it later.

Consider a two-person inspection team in a plant room with no coverage. If the app can only cache, they can read yesterday's checklist and nothing more. If it is offline-first, they can complete today's checklist, and the sync happens whenever a signal shows up. Caching is a read optimization. Offline-first is a write commitment, and the write commitment is what costs you.

Flutter's architecture guide frames the read side cleanly: emit the locally stored data from a stream first, since that call is generally faster and less error prone than a network call, then emit the remote data when it arrives (Flutter offline-first design pattern). For reads, a repository, a local store and a stream cover most of it.

The same page describes two ways to write: online-only, which keeps the client and server in step, or local-first, which accepts temporary desynchronization. A field tool needs the second one, so you inherit the desynchronization and 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

Storage is rarely where offline-first projects go wrong. The trouble sits downstream of it, in the queue and the conflict policy, and it shows up after the local database already works.

On-device SQLite has several good Dart options. As of 30 September 2026 pub.dev lists drift 2.35.0, which advertises schema migrations, transactions and joins, sqflite 2.4.4 and sqlite_async 0.14.5, described as a high-performance asynchronous interface for SQLite. Picking among them is an afternoon of reading. It feels like progress because it produces code. My shortlist of tools worth installing is in Flutter developer tools.

Then look at what the official page leaves out. It documents read strategies and two write strategies, and it has no conflict resolution section at all. That is a fair scope for a general guide, because conflict rules depend on your data. It does mean the most-cited answer to "how do I do this in Flutter" stops where the expensive part starts.

You have felt the user side of this. You tap save in a lift, the spinner turns, the doors open, and you don't know whether it saved. That ambiguity is the product defect offline-first exists to remove.

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 outbox table with an idempotency key, and replay the outbox when connectivity returns. The queue is the architecture, and everything else is detail around it.

For every user-initiated write, this is the order of operations I recommend:

  1. Apply the change to the local database inside a transaction, so the screen updates instantly.
  2. Append a mutation record to an outbox table in the same transaction. If the write lands and the outbox row does not, the change exists on that device only.
  3. Stamp the mutation with a client-generated idempotency key and a monotonic sequence number. The server uses the key to drop 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, and stop on the first permanent failure instead of skipping past it.
  5. Mark each mutation applied, failed-retryable or failed-permanent, and give permanent failures a visible place in the UI. A silently discarded work order is worse than a crash.

Picture the failure you are designing against. It is the end of a shift, a technician has forty photos and six signed job sheets queued, and the operating system kills the app to free memory. If those mutations lived in a provider's memory rather than on disk, the day is gone. Durable before acknowledged is the whole rule.

Pick a Conflict Strategy Per Table, Not Per App

A single global conflict policy is the design mistake I would watch for first, because the right answer changes with the shape of the row. A display name and a bookable time slot should not resolve 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, assignmentsOptimistic locking with a version columnA lost update is a double-booking, so fail the second writer loudly
Free-text reports edited by two peopleServer-side merge or a manual resolution screenMachine merges of prose produce nonsense; show both versions

The pattern that saves the most work 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 conflict question becomes an ordering question. Append-only event tables get you the same result from the other direction. This is a design recommendation, not a documented guarantee, so test it against your own data.

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

What Sync Budget Does Android Impose?

Android 15 lets an app's dataSync foreground services run for six hours in any 24-hour period, then calls Service.onTimeout(). The service must stop within seconds or the system throws an exception, and bringing the app to the foreground resets the timer.

That is from the Android foreground service timeout documentation (retrieved 30 September 2026). It applies to apps targeting Android 15 or higher, and the same page recommends alternatives such as WorkManager instead of a dataSync foreground service. A design that holds one long sync service open all day is a design that can fail late in a shift, when the queue is fullest. Flutter's own page adds a battery warning: continuous background operations can drain the battery dramatically, and some devices limit background processing.

Treat sync as a series of bounded jobs with checkpoints, not a daemon. Every mutation must survive the worker being killed mid-drain, which is another reason the outbox table should be the only state that matters.

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

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

OptionWhat its docs say it gives youWhat you still own
PowerSyncKeeps a client-side SQLite database in sync with Postgres, MongoDB, MySQL or SQL ServerVerifying how your conflict rules and write validation map onto it; hosting cost above the free tier
ElectricRead-path sync out of PostgresThe entire write path, by design
Hand-rolled outboxTotal control and no vendorQueue, retries, idempotency, conflict policy, partial sync

Electric is direct about its boundary. Its writes guide says Electric does not do write-path sync and does not provide or prescribe a built-in solution for getting data back into Postgres, then lays out four write patterns from online writes up to through-the-database sync. Of the last one it warns that a local embedded database adds quite a heavy dependency, and that shadow table and trigger machinery complicate your client-side schema (Electric writes guide).

PowerSync sits on the other side of that line. Its Flutter SDK is at version 2.4.0 on pub.dev as of 30 September 2026 and supports Postgres, MongoDB, MySQL and SQL Server. Cost is real: the PowerSync pricing page lists a free tier with up to 2 GB synced per month, 500 MB hosted and 50 peak concurrent clients, Pro from $49 per month and Team from $599 per month. Consider a fifty-person crew uploading photos every day. Model the synced-gigabytes line before you commit, because it scales with payload rather than seat count.

My recommendation: hand-roll the outbox for single-owner data, and reach for a sync engine the moment two people can edit the same row. A dispatch tool where a coordinator and a technician both touch a job is a different product from a private checklist, and paying a vendor to get ordering right is cheaper than getting it wrong in production. That trade-off shows up in most SaaS product scoping, and if the app also needs to run on the web, Flutter web for SaaS dashboards covers what changes there.

Partial Sync Is a UX Problem First

You cannot put the whole database on a phone, so you ship a slice, and the user needs to see which slice. Show the boundary. A line such as "Jobs assigned to you, last 30 days" in the empty state beats a blank list, and it heads off the support call where someone is sure a record vanished. Surface queue state too: "six job sheets waiting to upload" is more honest than a green tick.

Which of your tables has two concurrent writers? Mark each one today, and start the outbox with the tables that have exactly one.

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 is Flutter offline-first architecture for a field app?

Flutter offline-first architecture treats the local database as the source of truth and the network as a background sync process, so a technician keeps working with no signal. For a field app that means local SQLite, 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.