Open source · MIT · Dart & Flutter
Work that survives the user closing the app.
durable_queue stores every task before enqueue returns, retries it with backoff across crashes and restarts, waits while the device is offline, and can hand uploads to the operating system so they finish in the background.
Close the app. The uploads still arrive.
The demo app queues photos, goes to the home screen, and the server still receives every upload, including ones whose first try the server failed on purpose. When the app opens again, the server is told about each photo exactly once.
Quick start
Describe work as data, register a handler, and enqueue. The queue owns retries, ordering and recovery.
final class UploadPhotoTask extends DurableTask {
UploadPhotoTask({required this.path});
final String path;
@override
String get type => 'upload_photo';
@override
Map<String, dynamic> toJson() => {'path': path};
static UploadPhotoTask fromJson(Map<String, dynamic> json) =>
UploadPhotoTask(path: json['path'] as String);
}
final queue = DurableQueue(storage: SqliteQueueStorage.open(path));
queue.register<UploadPhotoTask>(
type: 'upload_photo',
decoder: UploadPhotoTask.fromJson,
handler: (task, context) => photoApi.upload(task.path),
);
await queue.start(); // recovers tasks interrupted by the last shutdown
// Stored before enqueue returns, then retried until it succeeds or
// its attempts run out.
await queue.enqueue(
UploadPhotoTask(path: '/storage/avatar.jpg'),
retryPolicy: RetryPolicy.exponential(
maxAttempts: 5,
initialDelay: Duration(seconds: 1),
),
);Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final queue = DurableQueue(storage: await openStorage());
queue.register<UploadTask>(
type: 'upload',
decoder: UploadTask.fromJson,
handler: upload,
);
// Starts the queue with the app and pauses it in the background.
runApp(DurableQueueScope(queue: queue, child: const MyApp()));
}
// Anywhere below the scope:
final queue = DurableQueueScope.of(context);
await queue.enqueue(UploadTask(path));dependencies:
durable_queue: ^0.5.8
durable_queue_sqlite: ^0.2.2 # or durable_queue_hive
durable_queue_flutter: ^0.1.1 # Flutter appsThe problem
A try/catch handles one failure. Then you still have to decide:
Where does the work live?
Until it can be retried, and whether it survives a restart or a crash.
When do you try again?
How long to wait between attempts, and when to give up.
What about double taps?
How to stop the same photo from uploading twice.
Which goes first?
How many uploads may run at once, and in what order.
What if step 2 fails?
What happens to step 3 when the step it depends on does not succeed.
What if the app closes?
Whether an upload in progress can finish without the app.
Proof, not promises
Every claim is tested, and the results are public.
| Where | What the test does | Result |
|---|---|---|
| Android 14 emulator, in CI | Kills the app (kill -9) with 6 uploads in flight, relaunches it offline for 15 seconds, then online | Nothing sent while offline. All 6 uploads completed; the step after each ran exactly once. |
| iOS 26.5 simulator, in CI | Terminates the app with 6 uploads in flight, then relaunches it | The uploads kept going while the app was terminated. All 6 completed; the step after each ran exactly once. |
| Real iPhone and Android phone | Photos queued, app sent to the home screen | Every upload reached the server (Android, iPhone). |
| GitHub runners, Ubuntu and macOS | Benchmarks for storing, running and recovering tasks | About 0.15 to 0.3 ms to store a task durably; 1,000 interrupted tasks recovered in 0.25 to 0.6 s. All numbers and the method → |
What we do not promise: exactly-once delivery. If the app is killed after a handler's side effect but before its result is stored, the task runs again. Send the task's idempotency key so your server can drop the repeat. Read the guarantees →
Nine packages, one core
Each integration is a separate package, so the core never forces a database, an HTTP client or Flutter on you.
durable_queue0.5.8
The engine: tasks, retries, priorities, dependencies, rate limits, gates. Pure Dart, no runtime dependencies.
durable_queue_sqlite0.2.2
SQLite storage: tasks survive restarts and crashes, on mobile, desktop, and the web.
durable_queue_hive0.1.0
Hive storage on hive_ce, for apps that already use Hive.
durable_queue_encryption0.1.0
Encrypts stored payloads and error messages with AES-256-GCM. Wraps any storage.
durable_queue_dio0.2.2
Dio integration: HTTP retry rules, Retry-After, idempotency keys, queued requests.
durable_queue_flutter0.1.1
Starts the queue with the app, pauses it in the background, and rebuilds widgets from stored tasks.
durable_queue_connectivity0.1.0
A network gate: tasks wait while offline and start when the connection returns.
durable_queue_transfer0.1.2
Uploads and downloads the operating system finishes after the app is minimised or closed.
durable_queue_workmanager0.1.0
Runs the queue from Workmanager background jobs on Android and iOS.
How the packages fit together
The core decides when each task runs. A gate decides whether a task may start now, storage decides where tasks live, and a handler does the work.
Text version of the diagram
Your Flutter or Dart app
enqueue tasks · show status · react to results
│
durable_queue_flutter ──────▶│ starts the queue with the app,
(optional) │ pauses it in the background,
│ rebuilds widgets from tasks
│
durable_queue_workmanager ──▶│ runs it in background jobs
(optional) │ while the app is away
▼
┌─────────────────────────────────────────────────────────────┐
│ durable_queue · core engine │
│ retries · priorities · dependencies · rate limits · events │
├───────────────────┬───────────────────┬─────────────────────┤
│ Gates │ Storage │ Handlers │
│ may a task start? │ where tasks live │ what a task does │
└─────────▲─────────┴─────────▲─────────┴──────────▲──────────┘
│ │ │
│ │ │
durable_queue_ durable_queue_ durable_queue_dio
connectivity sqlite, _hive HTTP requests, retry
waits while offline survive restarts rules, Retry-After
durable_queue_ durable_queue_transfer
encryption uploads, downloads the
encrypts what is OS finishes in the
stored background
built in: built in: built in:
ManualQueueGate MemoryQueueStorage any async functionEverything you need
Retries that make sense
Fixed or exponential backoff with optional jitter, a retryIf filter, and Retry-After with Dio.
Dependencies and chains
"Upload, then tell the server": a task starts only after the ones it depends on completed.
Waits for the network
Tasks wait while offline without using attempts, and start when the connection returns.
Deduplication
A deduplication key stops a double tap from queuing the same work twice.
Background transfers
Uploads and downloads handed to the operating system keep going after the app is closed.
Encryption at rest
Optional AES-256-GCM for stored payloads and error messages, with key rotation.
Priorities and rate limits
Higher priority first, limits per task type or per key such as an API host.
Flutter widgets
Rebuild your UI from stored task status; start and pause with the app's lifecycle.
Testable
An injectable clock tests retry logic without waiting.
Questions
How do I make uploads survive the app being closed in Flutter?
Hand them to the operating system with durable_queue_transfer: in the task's handler, call transfers.start(context, UploadTask(...)) on a QueueTransfers. The upload continues while the app is in the background or closed, and its queue task completes, with the tasks after it, the next time the app runs. If the user force-quits the app, the system may stop the upload until the app is opened again; it is restarted or retried then.
How do I retry failed uploads with exponential backoff in Flutter?
Enqueue the task with retryPolicy: RetryPolicy.exponential(maxAttempts: 5, initialDelay: Duration(seconds: 1)). With SQLite or Hive storage the task is stored before enqueue returns, so its retries continue across restarts and crashes; a retryIf filter on register decides which errors are worth retrying.
How do I queue requests while offline in Flutter?
Add a network gate from durable_queue_connectivity with queue.addGate('network', await ConnectivityGate.create()), then register the task type with requires: {'network'} and deferOn: deferOnConnectionError('network'). Tasks wait while the device is offline without using attempts, and start as soon as a connection comes back.
How do I run background tasks with Workmanager and a queue?
Use durable_queue_workmanager: create a QueueBackgroundWork with openQueue and openLease, call background.schedule() in main and background.run(taskName) in Workmanager's callback, and run the app's queue through a LeasedQueueRunner. The lease keeps the app and the job from running the queue at once. Android runs the jobs at most every 15 minutes, for up to about 10 minutes; iOS decides when, and gives each about 30 seconds.
Does durable_queue run each task exactly once?
No. Every task runs at least once, until it succeeds or its attempts run out. If the app dies after a handler's side effect but before the result is written, the task runs again, so send the task's context.idempotencyKey to your server and let it drop the repeat.
How do I encrypt queued data?
Wrap the storage with durable_queue_encryption: EncryptedQueueStorage(SqliteQueueStorage.open(path), cipher: AesGcmCipher(key)), with a 32-byte key from AesGcmCipher.generateKey() kept in the platform's secure storage. Payloads, error messages, deduplication keys and groups are encrypted with AES-256-GCM.
Does durable_queue need Flutter?
No. The core package, durable_queue, is pure Dart with no runtime dependencies, and runs on Android, iOS, the web and desktop. The Flutter packages add app lifecycle and widgets, connectivity, background transfers and Workmanager.
Built by Boffin Coders
We design and build websites, web applications and mobile apps, and run their SEO, for businesses and agencies. We build and maintain durable_queue as open source. Need offline-first sync, reliable uploads or a whole Flutter app built for you?