DEV Community

WolfOf420Stret
WolfOf420Stret

Posted on

Building Offline-First Flutter Apps with SyncForge

Offline support is one of those features that sounds simple until you actually have to build it.

Saving data locally is easy. The harder questions come afterwards.

What happens when two devices edit the same record while they are offline? What happens when one device deletes something while another device still has an older copy? How do you make sure a queued write survives an app restart? And when the devices reconnect, how do you decide which version should win?

These are the problems I was trying to solve when I built SyncForge, an open-source synchronization toolkit for Flutter and Dart applications.

The project is still young, currently at version 0.1.1, but the goal is fairly straightforward: provide the synchronization primitives without forcing an application to use a particular backend, networking library, or database.

The project is available on GitHub, and the core package is available on pub.dev.

Three packages instead of one

I deliberately split SyncForge into three packages instead of putting everything into a single library.

sync_engine
     |
     +----------------------+
     |                      |
     v                      v
SyncStorage            SyncTransport
     |
     v
sync_engine_drift

sync_engine_generator
     |
     v
Generated SyncAdapters
Enter fullscreen mode Exit fullscreen mode

The packages have different responsibilities:

sync_engine

The core synchronization engine is pure Dart.

It contains the synchronization contracts, vector clocks, conflict-resolution primitives, outbox behavior, and in-memory implementations.

It does not depend on Flutter, Drift, or a particular networking stack.

dependencies:
  sync_engine: ^0.1.1
Enter fullscreen mode Exit fullscreen mode

sync_engine_drift

This package provides durable persistence using Drift.

dependencies:
  sync_engine_drift: ^0.1.1
Enter fullscreen mode Exit fullscreen mode

It provides the database-backed storage and outbox implementation needed when synchronization state needs to survive an application restart.

sync_engine_generator

The generator removes a lot of repetitive serialization and adapter code.

dev_dependencies:
  sync_engine_generator: ^0.1.1
  build_runner: ^2.5.4
Enter fullscreen mode Exit fullscreen mode

You annotate a model, run build_runner, and SyncForge generates the adapter required by the engine.

I wanted these responsibilities separated because not every application needs Drift, and the synchronization layer should not have to know which database or transport an application happens to use.

The architecture

At the center of the system is SyncEngine.

Application model
       |
       v
Generated SyncAdapter
       |
       v
SyncEngine
   |           |
   v           v
SyncStorage  SyncTransport
   |
   v
DriftSyncStorage
Enter fullscreen mode Exit fullscreen mode

The engine coordinates local operations and synchronization cycles.

The application provides three important pieces:

  • a SyncStorage implementation
  • a SyncTransport implementation
  • generated adapters for synchronizable models

This keeps the core engine independent from the backend.

For example, an application could use REST, GraphQL, Supabase, or its own HTTP protocol without changing the core synchronization logic.

Defining a synchronizable model

The generator uses annotations to create an adapter for each model.

Here is a simple example:

import 'package:sync_engine/sync_engine.dart';

part 'todo.sync.dart';

@Syncable()
class Todo {
  const Todo({
    required this.id,
    required this.title,
  });

  @Id()
  final String id;

  @ConflictStrategy(ConflictType.lastWriteWins)
  final String title;
}
Enter fullscreen mode Exit fullscreen mode

The part directive is important.

The generator creates todo.sync.dart, so the model needs:

part 'todo.sync.dart';
Enter fullscreen mode Exit fullscreen mode

The generated adapter contains the serialization, deserialization, merge metadata, and adapter contract required by SyncEngine.

The generator configuration goes into build.yaml:

targets:
  $default:
    builders:
      sync_engine_generator|syncable:
        generate_for:
          - lib/**.dart
Enter fullscreen mode Exit fullscreen mode

Then generate the code:

dart run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

The result includes a TodoSyncAdapter.

You can then register that adapter with the engine:

final sync = SyncEngine(
  storage: storage,
  transport: transport,
  nodeId: 'device-a',
  adapters: {
    Todo: const TodoSyncAdapter(),
  },
);
Enter fullscreen mode Exit fullscreen mode

The generator currently supports the built-in conflict strategies. Unsupported custom conflict strategies are rejected during generation instead of producing something that fails much later at runtime.

How conflict resolution works

This is where offline synchronization gets interesting.

SyncForge uses vector clocks to reason about causality.

A vector clock lets the engine determine whether:

  • operation A happened before operation B
  • operation B happened before operation A
  • the operations are concurrent

That distinction matters.

If device A updates a record and device B later receives that update and modifies it, the second operation is causally newer.

If both devices modify the record while disconnected, neither operation necessarily happened before the other. They are concurrent.

Concurrent updates are then resolved according to the conflict strategy configured for each field.

For a last-write-wins field, SyncForge uses the field metadata and a deterministic node ID comparison when necessary to select a winner.

This means the result is deterministic across replicas.

SyncForge also includes other CRDT-style primitives.

For example:

  • grow-only counters merge using per-node maximum values
  • grow-only sets merge using set union
  • last-write-wins fields select a deterministic winner

I think the most useful way to describe this architecture is as CRDT primitives integrated into a synchronization protocol.

The entire library is not simply "a CRDT". It combines causal metadata, field-level merge strategies, storage, an outbox, and transport synchronization around those primitives.

That distinction becomes important when thinking about what the system guarantees.

Deletes are different

Deletes are one of the easiest parts of synchronization to get subtly wrong.

Imagine this:

Device A                  Device B

Todo exists               Todo exists
     |                         |
     |                         |
   DELETE                   offline
     |
 tombstone
Enter fullscreen mode Exit fullscreen mode

If device B later reconnects with an old copy of the Todo, simply deleting the local database row on device A would not give the synchronization layer enough information to know that the record had actually been deleted.

SyncForge therefore represents deletes using tombstones.

A tombstone retains the synchronization metadata associated with the deleted entity.

The important part is that deletes follow causal rules too.

The current behavior is:

  • a causally newer delete wins over an older update
  • a causally newer update may resurrect an entity
  • a concurrent update and delete use delete-wins behavior
  • the Drift storage implementation rejects causally stale deletes before they can overwrite newer persisted state

That last point is particularly important for durable storage.

The Drift implementation compares the causal metadata before accepting a delete, rather than blindly replacing whatever is already persisted.

DriftSyncStorage also preserves tombstone metadata through the storage round trip.

This means the synchronization state is not reduced to simply:

deleted = true
Enter fullscreen mode Exit fullscreen mode

The causal information remains available to the synchronization system.

The offline write flow

With an engine-backed storage implementation, an offline write looks roughly like this:

User changes data
       |
       v
Local storage
       |
       +----> UI immediately sees local state
       |
       v
Outbox
       |
       v
Synchronization
       |
       +----> transport sends operation
       |
       +----> remote changes are pulled
                    |
                    v
                 merge
                    |
                    v
              local storage
Enter fullscreen mode Exit fullscreen mode

The important property here is that the local write does not have to wait for the network.

The engine persists the local operation and queues synchronization work.

When synchronization runs, queued operations can be sent through the application's transport implementation.

Remote operations are then applied locally according to their causal and conflict metadata.

The outbox also needs to survive failures.

A network request can fail because the device goes offline. The server can be temporarily unavailable. The application can be killed before the request completes.

The synchronization layer therefore needs more than a simple queue in memory.

Retryable outbox processing

SyncForge's outbox supports retry behavior with exponential backoff.

Transient failures can be retried rather than immediately being treated as permanent failures.

After the configured retry limit is reached, the operation can move to the dead-letter list and emit a synchronization failure event.

The core package contains the synchronization contracts and in-memory behavior.

For applications that need restart-safe persistence, sync_engine_drift provides the durable implementation.

That distinction is intentional.

The core package does not pretend that every application needs a database.

A small test might be perfectly happy with in-memory storage:

SyncEngine
    |
    v
InMemoryStorage
Enter fullscreen mode Exit fullscreen mode

A production Flutter application that needs synchronization state to survive process death can use:

SyncEngine
    |
    v
DriftSyncStorage
    |
    v
SQLite / Drift
Enter fullscreen mode Exit fullscreen mode

Durable Drift storage

The Drift package provides a database-backed implementation of the storage contract.

A basic setup looks like this:

import 'package:drift/native.dart';
import 'package:sync_engine_drift/sync_engine_drift.dart';

final database = SyncDriftDatabase(
  NativeDatabase.memory(),
);

final storage = DriftSyncStorage(
  database,
  adapters: {
    Todo: const TodoSyncAdapter(),
  },
);

await storage.initialize();
Enter fullscreen mode Exit fullscreen mode

NativeDatabase.memory() is useful for tests and smoke checks.

For an application that needs persistence across restarts, you would provide a file-backed Drift executor instead.

The synchronized entities are stored using a shared synchronization table.

The stored information includes things such as:

  • entity type
  • entity ID
  • serialized payload
  • vector clock
  • tombstone state
  • writer metadata
  • field metadata

The important distinction is that the generated Drift table option is separate from the runtime synchronization table.

Generated Drift tables can be used as application-side declarations for typed application queries.

They are not automatically used by DriftSyncStorage for synchronization.

The runtime synchronization implementation continues to use the shared synchronization table.

Why keep the transport separate?

SyncForge does not replace your backend.

The backend is still responsible for things such as:

  • authentication
  • authorization
  • server-side validation
  • persistence
  • conflict policy that belongs to the business domain
  • transport protocol mapping

SyncForge only needs an implementation of SyncTransport.

That transport could communicate with:

REST API
GraphQL API
Supabase
Custom backend
Any other protocol
Enter fullscreen mode Exit fullscreen mode

For example, an application could serialize SyncForge operations into JSON and send them to an HTTP endpoint.

The server does not have to know anything about Flutter.

This separation is important because synchronization and networking are related problems, but they are not the same problem.

Testing the synchronization layer

One of the reasons I kept the core package pure Dart is testing.

The synchronization logic can be tested without starting a Flutter application or connecting a physical device.

The repository contains focused tests for areas such as:

  • vector-clock behavior
  • CRDT merge behavior
  • replica convergence
  • last-write-wins registers
  • outbox processing
  • Drift persistence
  • tombstone behavior
  • generated adapters
  • synchronization flows

There are also example applications covering progressively more complete integrations:

  • minimal application
  • notes application
  • task manager
  • conflict-resolution playground

The examples are useful because synchronization code can look correct in isolation while still being awkward to integrate into an actual application.

What SyncForge does not try to solve

There are some boundaries I want to be explicit about.

SyncForge is currently 0.1.1, so I would not present it as a finished replacement for every synchronization platform.

Some current limitations are:

  • the core package does not provide durable persistence by itself
  • the backend remains responsible for authentication, authorization, and server-side validation
  • custom merge strategies are not currently accepted by the generator
  • Drift frontier pruning requires a configured known-replica roster
  • the task-manager backend example is an in-memory demonstration server, not a production backend
  • encryption at rest and encryption in transit remain application responsibilities

These are not necessarily problems with the library.

They are boundaries.

I would rather make those boundaries obvious than imply that the synchronization layer somehow makes the entire application distributed-system-safe by itself.

A small example of the bigger picture

Putting the pieces together, the application architecture can look like this:

                  Flutter Application
                         |
                         v
                  Domain Model
                         |
                         v
                Generated Adapter
                         |
                         v
                    SyncEngine
                    /        \
                   /          \
                  v            v
          Local Storage      Transport
                |                |
                v                v
        DriftSyncStorage      Backend
                |
                v
          SQLite / Drift
Enter fullscreen mode Exit fullscreen mode

The application owns the business logic.

The backend owns authentication, authorization, validation, and server-side persistence.

SyncForge sits between those layers and handles the synchronization mechanics.

That separation is probably the most important architectural decision in the project.

Getting started

If you want to try SyncForge, the smallest path is to start with the core package and an in-memory storage implementation.

Define a model:

@Syncable()
class Todo {
  const Todo({
    required this.id,
    required this.title,
  });

  @Id()
  final String id;

  @ConflictStrategy(ConflictType.lastWriteWins)
  final String title;
}
Enter fullscreen mode Exit fullscreen mode

Add:

part 'todo.sync.dart';
Enter fullscreen mode Exit fullscreen mode

Configure the generator and run:

dart run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

Then register the generated adapter with SyncEngine.

Once the synchronization flow makes sense, add sync_engine_drift and move the storage to a file-backed database when restart-safe persistence is required.

The full source is available on GitHub.

The core package is available on pub.dev, with the Drift and generator packages available alongside it.

Final thoughts

Offline-first synchronization is one of those engineering problems where the happy path is rarely the difficult part.

The difficult cases are the ones that happen when nobody has a network connection.

Two devices edit the same record.

One device deletes it.

Another device comes back online three hours later with an old copy.

The application gets killed halfway through synchronization.

A request fails five times.

A tombstone gets lost.

A stale operation arrives after a newer one.

Those cases are where synchronization systems either become predictable or become very difficult to reason about.

With SyncForge, I wanted the rules to be explicit.

Vector clocks provide causal ordering.

Field-level conflict strategies determine how concurrent changes are merged.

Tombstones represent deletes.

The outbox provides retryable synchronization work.

Drift provides durable persistence.

The transport remains replaceable.

And the generated adapters keep the repetitive serialization and synchronization metadata out of application code.

There is still a lot I want to improve, but 0.1.1 is now published and usable.

If you're building an offline-first Flutter application and want to experiment with the approach, I'd be interested in seeing what you build with it.

Source: Wolfof420Street/flutter_sync_engine

Package: sync_engine on pub.dev

Top comments (0)