easy_sync
easy_sync is a Flutter background sync and offline sync orchestration package.
It helps you run reliable app-open, manual, and background sync flows with workmanager, retry, preconditions, and execution controls such as rate limit and circuit breaker.
Overview
easy_sync helps you organize sync work into reusable tasks.
It is designed for apps that need to:
- run sync on app open
- trigger sync manually from the UI
- schedule background sync with workmanager
- retry transient failures with backoff
- track task state in a predictable way
The package is:
- auth-agnostic
- backend-agnostic
- database-agnostic
- reusable across different Flutter apps
Use Cases
Use easy_sync when your app needs:
- offline-first sync from local database to remote API
- queued uploads and retry after transient failures
- app-open refresh and pull-to-refresh with shared orchestration
- background periodic sync with WorkManager and iOS fetch/processing modes
- backend protection through rate limiting and circuit breaker behavior
Installation
Add the package to your app:
dependencies:
easy_sync: ^0.2.0
Then install dependencies:
flutter pub get
If you enable background sync, complete the platform setup in the Native Setup For Background Sync section below.
Quick Start
Set up easy_sync with a minimal task:
import 'package:easy_sync/easy_sync.dart';
Future<void> example() async {
final easySync = await EasySync.setup(
tasks: <SyncTask>[
SyncTask.fn(
key: 'sync_users',
manual: true,
appOpen: true,
background: true,
run: (context) async {
// Call your repository or API client here.
},
),
],
appOpenSync: true,
background: EasySyncBackgroundConfig.enabled(
frequency: const Duration(hours: 1),
),
taskTimeout: const Duration(seconds: 20),
isolateTaskFailures: true,
);
await easySync.runAll(
metadata: const <String, Object?>{
'source': 'manual',
'hasNetwork': true,
},
);
await easySync.dispose();
}
See the Full Example below for a production-ready setup with retry handling, preconditions, and background sync configuration.
Recommended Integration Order
Follow this simple flow in your app:
- Define your
SyncTaskclasses - Call
EasySync.setup(...)during app startup (usually inmain()) - Pass the returned
EasySyncinstance into your app - Trigger manual sync from the UI when needed (
runAll()/runTask()) - Let
easy_synchandle app-open and background sync automatically
Full Example (main.dart)
This example shows a practical startup flow.
import 'package:flutter/material.dart';
import 'package:easy_sync/easy_sync.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
Future<void> uploadPendingItems() async {
// Call your repository or API layer.
}
Future<String?> readAccessToken() async {
// Read from your auth module.
return 'token';
}
final easySync = await EasySync.setup(
// 1) Define your app tasks.
tasks: <SyncTask>[
SyncTask.fn(
key: 'upload_pending_items',
appOpen: true,
manual: true,
background: true,
retry: RetryConfig.exponential(
initialDelay: Duration(seconds: 1),
maxRetries: 4,
),
preconditions: <SyncPrecondition>[
RequiresNetworkPrecondition(
checker: (context) async =>
context.value<bool>('hasNetwork') ?? false,
),
PredicatePrecondition(
name: 'auth-ready',
predicate: (context) async => await readAccessToken() != null,
),
],
run: (context) async {
await uploadPendingItems();
},
retryWhen: (error, stackTrace) => true,
),
],
// 2) Automatically trigger app-open sync on start and resume.
appOpenSync: true,
// 3) Enable background sync with the common configuration path.
background: EasySyncBackgroundConfig.enabled(
frequency: const Duration(hours: 1),
),
// 4) Optional execution safety settings.
taskTimeout: const Duration(seconds: 20),
isolateTaskFailures: true,
);
// 5) The app only needs the returned EasySync instance.
runApp(MyApp(easySync: easySync));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.easySync});
final EasySync easySync;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('easy_sync example')),
body: Center(
child: ElevatedButton(
onPressed: () async {
// 6) Manual sync stays simple.
await easySync.runAll(
metadata: const <String, Object?>{
'source': 'button_tap',
'hasNetwork': true,
},
);
},
child: const Text('Run Manual Sync'),
),
),
),
);
}
}
App Open Sync
For the common case, set appOpenSync: true in EasySync.setup().
final easySync = await EasySync.setup(
tasks: tasks,
appOpenSync: true,
);
This automatically:
- triggers app-open tasks once on startup
- listens for
AppLifecycleState.resumed - triggers app-open tasks again when the app returns to foreground
Manual Sync
Use the returned EasySync instance from UI interactions such as pull-to-refresh or a button tap.
await easySync.runAll(
metadata: const <String, Object?>{
'source': 'pull_to_refresh',
'hasNetwork': true,
},
);
await easySync.runTask(
'upload_pending_items',
metadata: const <String, Object?>{
'source': 'retry_button',
'hasNetwork': true,
},
);
Background Sync
For the common case, provide EasySyncBackgroundConfig.enabled() to EasySync.setup().
final easySync = await EasySync.setup(
tasks: tasks,
background: EasySyncBackgroundConfig.enabled(
frequency: const Duration(hours: 1),
),
);
On iOS, this uses the BGTaskScheduler periodic path (custom frequency, processing mode).
For iOS Background Fetch mode (system-managed timing, no custom frequency), use:
final easySync = await EasySync.setup(
tasks: tasks,
background: EasySyncBackgroundConfig.iosBackgroundFetch(),
);
To explicitly disable background scheduling while keeping the rest of setup() the same:
final easySync = await EasySync.setup(
tasks: tasks,
background: EasySyncBackgroundConfig.disabled(),
);
Keep in mind:
- Android uses WorkManager semantics.
- iOS supports two paths via
workmanager:- BGTaskScheduler periodic (
EasySyncBackgroundConfig.enabled(...)/.periodic(...)) - Background Fetch (
EasySyncBackgroundConfig.iosBackgroundFetch())
- BGTaskScheduler periodic (
- Android periodic work has a practical 15 minute minimum interval behavior.
- If you pass less than 15 minutes,
easy_syncclamps it to 15 minutes. - background timing is not guaranteed
- iOS background timing is especially best-effort
Native Setup For Background Sync
Use these steps before enabling background sync through EasySync.setup(...).
Android
Android setup is the simple part.
- Add
easy_syncto your app. - Run
flutter pub get. - Make sure your app uses Flutter's default generated Android setup.
- No extra Android manifest or Application class setup is usually needed for basic workmanager usage.
In most apps, Android works after Dart-side initialization only.
iOS
iOS needs explicit native setup.
- Open
ios/Runner.xcworkspacein Xcode. - Select the
Runnertarget. - Set the minimum deployment target to iOS 14.0 or later.
- Open
Signing & Capabilities. - Add
Background Modes. - Enable the background mode that matches your scheduling approach.
Option A: Background Fetch (simpler, no custom frequency)
Use this if you call EasySyncBackgroundConfig.iosBackgroundFetch().
Add these keys in ios/Runner/Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
No AppDelegate registration is needed for this mode.
Option B: BGTaskScheduler periodic (custom frequency)
Use this if you call EasySyncBackgroundConfig.enabled(...) or .periodic(...).
For periodic background sync with workmanager, use BGTaskScheduler-style setup:
Add these keys in ios/Runner/Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<!-- Example: if your app id is ca.devsloom.testapp -->
<string>ca.devsloom.testapp.sync-background</string>
</array>
Then register the same identifier in ios/Runner/AppDelegate.swift:
import UIKit
import Flutter
import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Use your own app's bundle-style identifier here.
// Example:
// if your app id is ca.devsloom.testapp
// then use ca.devsloom.testapp.sync-background
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "ca.devsloom.testapp.sync-background",
frequency: NSNumber(value: 20 * 60)
)
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Keep these rules in mind:
- Use the same identifier in
Info.plistandAppDelegate.swift. - A safe pattern is:
<your-app-id>.sync-background. - iOS background execution is best-effort.
- Exact timing is not guaranteed.
- Real devices are more reliable than simulators for background testing.
If you want the most up-to-date native details, check the workmanager quick start as well, because platform requirements can change between plugin versions.
Advanced Usage
The high-level EasySync.setup() API is meant for the common integration path.
Use the lower-level APIs when you need custom control over:
- task registration creation
- state store lifecycle
- app-open scheduling behavior
- background bridge registration
- scheduler initialization timing
Available lower-level APIs:
EasySync.initialize(...)EasySyncBackgroundConfig.periodic(...)EasySyncBackgroundConfig.iosBackgroundFetch(...)SyncEngineSyncTaskRegistrationWorkmanagerSyncBridge.registerTaskMapping(...)WorkmanagerBackgroundScheduler.initialize()WorkmanagerBackgroundScheduler.schedulePeriodic(...)WorkmanagerBackgroundScheduler.isScheduledByUniqueName(...)(Android only)
Example:
final easySync = EasySync.initialize(
tasks: tasks,
stateStore: InMemorySyncTaskStateStore(),
);
final taskRegistrations = <SyncTaskRegistration>[
for (final task in tasks) SyncTaskRegistration(task: task),
];
WorkmanagerSyncBridge.registerTaskMapping(
taskName: 'sync-background',
taskRegistrations: taskRegistrations,
stateStoreFactory: InMemorySyncTaskStateStore.new,
);
final scheduler = WorkmanagerBackgroundScheduler();
await scheduler.initialize();
await scheduler.schedulePeriodic(
uniqueName: 'easy-sync-periodic',
taskName: 'sync-background',
frequency: const Duration(hours: 1),
);
Use EasySyncBackgroundConfig.periodic(...) when you need to customize values such as:
uniqueNametaskNameinputDatastateStoreFactoryinitialDelay- custom scheduler driver for advanced integrations or testing
Core Concepts
SyncTask: a uniquely keyed unit of sync workSyncPolicy: controls whether a task can run on app-open, manual, or background triggersSyncTaskHandler: contains the actual sync implementationSyncPrecondition: blocks execution until requirements are metSyncResult: reports success, failure, or retryable failureSyncTaskState: stores the latest known runtime state for a taskEasySync: convenience API for manual sync and state streamingWorkmanagerSyncBridge: connects workmanager callbacks to your task registrations
Preconditions
Preconditions decide whether a task is allowed to run.
Use them for checks such as:
- network availability
- authentication readiness
- account state
- feature flags
Example:
class AuthReadyPrecondition implements SyncPrecondition {
AuthReadyPrecondition(this.readAccessToken);
final Future<String?> Function() readAccessToken;
@override
String get name => 'auth-ready';
@override
Future<PreconditionResult> check(SyncContext context) async {
final token = await readAccessToken();
if (token == null) {
return PreconditionResult.blocked(reason: 'Missing access token');
}
return PreconditionResult.allow();
}
}
Retry
Retries are controlled by SyncPolicy.retry and only happen when your handler returns SyncResult.retryable(...).
@override
SyncPolicy get policy => const SyncPolicy(
manual: true,
background: true,
retry: RetryConfig.exponential(
initialDelay: Duration(seconds: 1),
maxRetries: 4,
),
);
This produces delays like:
- 1s
- 2s
- 4s
- 8s
Use retry only for transient failures such as:
- temporary network issues
- short-lived server errors
- temporary dependency failures
Rate Limit and Circuit Breaker
Use execution controls when you want to protect your backend during instability or high trigger volume.
final easySync = await EasySync.setup(
tasks: tasks,
rateLimit: const SyncRateLimit.slidingWindow(
maxExecutions: 5,
per: Duration(minutes: 1),
),
circuitBreaker: const SyncCircuitBreaker.standard(
failureThreshold: 3,
openFor: Duration(minutes: 5),
),
);
How it works:
- rate limit blocks task execution when attempts exceed the configured sliding window
- circuit breaker opens after repeated failures and temporarily blocks further execution
- once the open duration passes, execution is allowed again
State Tracking
Use stateStream to observe task changes.
final subscription = easySync.stateStream.listen((state) {
print(
'task=${state.taskKey} status=${state.status.name} attempt=${state.attempt}',
);
});
await subscription.cancel();
This is useful for:
- loading indicators
- sync history UI
- retry messaging
- debug logging
Platform Notes
- Android background work follows WorkManager behavior.
- iOS background work can use BGTaskScheduler periodic mode or Background Fetch through
workmanager. - Some devices and OS versions may delay or skip background work.
- For native setup details, use the
workmanagerpackage documentation.
Limitations
easy_syncdoes not provide authentication.easy_syncdoes not provide API clients.easy_syncdoes not provide local database integration.- background execution timing is not guaranteed
- iOS background execution is best-effort and may be infrequent
FAQ
Does easy_sync include authentication?
- No. It is intentionally auth-agnostic.
Does easy_sync include API client or database code?
- No. It is backend-agnostic and database-agnostic.
Where should I call EasySync.setup()?
- Near app startup, usually in
main()beforerunApp().
Where should I configure background sync?
- During app startup, before scheduling periodic work.
When should I trigger app-open sync?
- Set
appOpenSync: trueinEasySync.setup()for the common case.
How do I sync local database data in background with Dio?
- Read pending records inside your task
runfunction, call your Dio API client, then mark records as synced. Re-open required dependencies in background execution context.
Should I use iOS Background Fetch or BGTaskScheduler periodic mode?
- Use
EasySyncBackgroundConfig.iosBackgroundFetch()for simpler setup and system-managed timing. UseEasySyncBackgroundConfig.enabled(...)or.periodic(...)when you need BGTaskScheduler periodic behavior with custom frequency.
Can I protect my backend from sync spikes?
- Yes. Configure
SyncRateLimitandSyncCircuitBreakerinEasySync.setup(...)to block excessive execution bursts and temporarily open-circuit on repeated failures.