Boffin Codersdurable_queueby Boffin Coders

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.

dart pub add durable_queue
Pure Dart core, no runtime dependenciesAndroid · iOS · web · desktopKilled mid-upload in CIMIT
Demo on an Android phone: photos are queued, the app goes to the home screen, the uploads still reach the server, and the server is told about each one when the app is opened again

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),
  ),
);

Read the full guide →

The 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.

WhereWhat the test doesResult
Android 14 emulator, in CIKills the app (kill -9) with 6 uploads in flight, relaunches it offline for 15 seconds, then onlineNothing sent while offline. All 6 uploads completed; the step after each ran exactly once.
iOS 26.5 simulator, in CITerminates the app with 6 uploads in flight, then relaunches itThe uploads kept going while the app was terminated. All 6 completed; the step after each ran exactly once.
Real iPhone and Android phonePhotos queued, app sent to the home screenEvery upload reached the server (Android, iPhone).
GitHub runners, Ubuntu and macOSBenchmarks for storing, running and recovering tasksAbout 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.

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.

How the durable_queue packages fit together Your app enqueues tasks into the durable_queue core engine, which durable_queue_flutter and durable_queue_workmanager can start and run. The core has three slots: gates (filled by durable_queue_connectivity), storage (durable_queue_sqlite or durable_queue_hive, optionally wrapped by durable_queue_encryption) and handlers (durable_queue_dio and durable_queue_transfer). Each slot has a built-in default. DURABLE_QUEUE · ARCHITECTURE by Boffin Coders durable_queue · core engine retries · priorities · dependencies · rate limits · events Your Flutter or Dart app enqueue tasks · show status · react to results durable_queue_flutter starts the queue with the app, pauses it in the background, rebuilds widgets durable_queue_workmanager runs the queue in background jobs while the app is away Gates may a task start? built in: ManualQueueGate Storage where tasks live built in: MemoryQueueStorage Handlers what a task does built in: any async function durable_queue_connectivity waits while offline durable_queue_sqlite, _hive survive restarts durable_queue_encryption encrypts what is stored durable_queue_dio HTTP, retry rules, Retry-After durable_queue_transfer background uploads, downloads Every package is optional. Start with the built-in pieces, then swap in a package for the slot you need.
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 function

Everything 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.

Get started Browse the cookbook

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.

Boffin Coders logo

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?

Hire Flutter developersTalk to usboffincoders.com