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
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
sync_engine_drift
This package provides durable persistence using Drift.
dependencies:
sync_engine_drift: ^0.1.1
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
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
The engine coordinates local operations and synchronization cycles.
The application provides three important pieces:
- a
SyncStorageimplementation - a
SyncTransportimplementation - 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;
}
The part directive is important.
The generator creates todo.sync.dart, so the model needs:
part 'todo.sync.dart';
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
Then generate the code:
dart run build_runner build --delete-conflicting-outputs
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(),
},
);
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
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
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
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
A production Flutter application that needs synchronization state to survive process death can use:
SyncEngine
|
v
DriftSyncStorage
|
v
SQLite / Drift
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();
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
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
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;
}
Add:
part 'todo.sync.dart';
Configure the generator and run:
dart run build_runner build --delete-conflicting-outputs
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)