DEV Community

dhaibanf-hue
dhaibanf-hue

Posted on

Building a small offline outbox for Flutter

I kept running into the same awkward bit of application code: a user makes a
change while the connection is down, the request fails, and every screen ends up
inventing its own retry logic.

Caching reads does not solve this. Writes have ordering rules. If an app queues
create, update, and delete for the same record, sending them in the wrong
order can leave the server in a state the user never intended.

I pulled that responsibility into a small Dart package called
offline_sync_outbox. It stores
pending operations, processes them in FIFO order, and leaves the actual network
call in the application.

A request being queued offline and sent after reconnecting

The smallest useful setup

Install it with:

flutter pub add offline_sync_outbox
Enter fullscreen mode Exit fullscreen mode

The manager needs a store and a processor. The processor is just the function
that knows how to send one operation:

final outbox = OfflineSyncManager(
  store: JsonFileSyncStore('app-data/outbox.json'),
  processor: (job) async {
    final response = await api.createOrder(job.payload);
    return response.isSuccessful
        ? const SyncResult.success()
        : const SyncResult.retry();
  },
);

await outbox.initialize();
await outbox.enqueue(
  action: 'create_order',
  payload: {'orderId': 42},
);
Enter fullscreen mode Exit fullscreen mode

I deliberately did not build an HTTP abstraction into the package. The callback
can use Dio, http, Supabase, a repository class, or anything else. The package
only needs to know whether it should remove, retry, or discard the operation.

Those three outcomes are important:

  • success() removes the operation.
  • retry() keeps it at the front of the queue.
  • discard() removes a request that will never succeed, such as invalid input.

An exception from the processor is treated as a temporary failure.

Why I kept strict FIFO ordering

The first version could have processed several requests at once. I decided
against it because concurrency makes dependent mutations difficult to reason
about.

Consider this sequence:

create note -> update note -> delete note
Enter fullscreen mode Exit fullscreen mode

If create note fails temporarily, sending the other two requests is usually
wrong. The manager therefore stops at the first retryable failure and leaves
newer work behind it.

This can cause head-of-line blocking. That is a real limitation, not a hidden
implementation detail. For unrelated work, I would use separate managers and
queue files—for example, one for orders and another for analytics.

Saving the queue

The included JsonFileSyncStore writes the queue to disk and restores it on the
next launch:

final store = JsonFileSyncStore('/app-data/offline-queue.json');
Enter fullscreen mode Exit fullscreen mode

The application owns the path. In Flutter that path normally comes from the
storage service already used by the app.

For tests there is an in-memory store:

final outbox = OfflineSyncManager(
  store: MemorySyncStore(),
  autoSync: false,
  processor: sendOperation,
);
Enter fullscreen mode Exit fullscreen mode

SyncStore is public, so an app can provide SQLite, Hive, Isar, IndexedDB, or
encrypted storage. The file implementation uses dart:io, which means Flutter
Web needs its own store.

Connectivity stays outside the package

Connection state is another area where applications already have opinions. The
package accepts a small adapter instead of depending on a connectivity plugin:

final class AppConnectivity implements SyncConnectivity {
  AppConnectivity(this.network);

  final NetworkService network;

  @override
  Stream<bool> get changes => network.statusChanges;

  @override
  Future<bool> isOnline() => network.isOnline();

  @override
  Future<void> dispose() async {}
}
Enter fullscreen mode Exit fullscreen mode

When the stream reports that the app is online, automatic synchronization can
start. There is also an explicit synchronize() method for app lifecycle events,
pull-to-refresh, and tests.

Retry decisions

Temporary failures use bounded exponential backoff:

final policy = SyncRetryPolicy(
  maxAttempts: 6,
  initialDelay: const Duration(seconds: 2),
  maxDelay: const Duration(minutes: 2),
);
Enter fullscreen mode Exit fullscreen mode

For a response such as HTTP 429, the processor can use a server-provided delay:

return const SyncResult.retry(
  reason: 'rate limited',
  retryAfter: Duration(seconds: 30),
);
Enter fullscreen mode Exit fullscreen mode

The application still decides which failures are temporary. A validation error
should usually be discarded; a timeout probably deserves another attempt. I do
not think a general-purpose package can make that decision reliably without
knowing the API.

Looking at a sync pass

The result reports what happened:

final report = await outbox.synchronize();

print('sent: ${report.succeeded}');
print('pending: ${report.pending}');
print('offline: ${report.skippedOffline}');
Enter fullscreen mode Exit fullscreen mode

There is also an event stream for enqueue, processing, retry, success, discard,
and permanent failure. That is enough to connect logs or a small “waiting to
sync” indicator without putting UI concerns in the package.

What is still application work

This package is not a complete offline data layer. It does not resolve conflicts,
merge local and remote models, upload binary files, or guarantee execution after
the operating system terminates the app.

Authentication also needs care. A queued request should not be replayed under a
different user after sign-out. In a real app I would include the user identity in
the operation data or clear the relevant queue during the account transition.

The first production integration I would choose is reading progress or another
idempotent upsert. Payments and file uploads need more specific recovery rules.

Running it

The repository has a small example that queues an operation offline and sends it
after the connectivity source changes:

dart run example/offline_sync_outbox_example.dart
Enter fullscreen mode Exit fullscreen mode

There are currently 11 tests covering ordering, concurrent enqueue calls, retry
exhaustion, malformed storage, and restoring a saved queue.

If you have shipped a similar queue, I would be interested in where you drew the
storage and connectivity boundaries. Small bug reports and focused pull requests
are welcome.

Top comments (0)