DEV Community

Cover image for Background Tasks in Flutter: The workmanager Guide That Works
Gulshan Yadav
Gulshan Yadav

Posted on Originally published at misar.blog

Background Tasks in Flutter: The workmanager Guide That Works

I have lost count of how many times a client has asked me to "just run this sync every hour" inside a Flutter app. On mobile, that single sentence opens a box of platform restrictions that no tutorial warns you about up front. So, in this article, I will be showing you how you can run background tasks in Flutter the reliable way, using the workmanager package — and I will show you the exact setup that has survived Play Store review and iOS review for me this year, including the failure modes that only show up on real devices.

For this purpose, we need to add these dependencies in your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  workmanager: ^0.5.2
Enter fullscreen mode Exit fullscreen mode

That is the entire dependency list. workmanager wraps Android's WorkManager API and iOS's BGTaskScheduler behind one Dart API, which means one code path for both platforms. That is the single biggest reason I use it over hand-rolled platform channels.

Let's jump into the coding part.

Step 1: Register the Workmanager and Initialize It

The first thing people get wrong is where the callback lives. Your background task runs in an isolate separate from your UI, so the callback must be a top-level or static function — never a closure that captures state from your widget tree.

import 'package:flutter/material.dart';
import 'package:workmanager/workmanager.dart';

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    switch (task) {
      case 'syncData':
        await syncDataToServer();
        break;
      case 'cleanCache':
        await cleanLocalCache();
        break;
    }
    return true; // success
  });
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  Workmanager().initialize(callbackDispatcher);
  runApp(const MyApp());
}
Enter fullscreen mode Exit fullscreen mode

Two details that matter. First, the @pragma('vm:entry-point') annotation tells the Dart compiler not to tree-shake this function away — without it, the background isolate can crash with a missing entry point on release builds. Second, syncDataToServer() and cleanLocalCache() must be your own top-level functions; you cannot call methods on a stateful widget from here because that widget's state does not exist in the background isolate.

Step 2: Register the Periodic Task

Now register the task you want to run on a schedule. I put this in initState or right after login, guarded so you only register once:

Future<void> registerTasks() async {
  await Workmanager().registerPeriodicTask(
    'sync-job',                      // unique name
    'syncData',                      // matches the case in callbackDispatcher
    frequency: Duration(minutes: 15),
    initialDelay: Duration(minutes: 1),
    existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
    constraints: Constraints(
      networkType: NetworkType.connected,
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

The existingWorkPolicy: update bit is the sneaky one. If you re-run registerPeriodicTask on every app start with the same name and the default policy, you can queue duplicate jobs. Using update replaces the existing registration instead.

Step 3: Register a One-Off Task (Forcing a Sync on Demand)

Not everything needs to be periodic. When the user hits "Sync now", use a one-off task so you do not wait for the next scheduled slot:

Future<void> syncNow() async {
  await Workmanager().registerOneOffTask(
    'sync-now',
    'syncData',
    inputData: {'force': 'true'},
    constraints: Constraints(networkType: NetworkType.connected),
  );
}
Enter fullscreen mode Exit fullscreen mode

Your syncData function can read inputData to decide whether to skip the "last synced less than 5 minutes ago" check.

Step 4: Android Manifest and iOS Setup

This is where most "it works on my emulator" setups die on real devices.

Android — add the workmanager permissions and keep the default provider in AndroidManifest.xml:

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
Enter fullscreen mode Exit fullscreen mode

The RECEIVE_BOOT_COMPLETED permission is what keeps your scheduled task alive across a device reboot. The package's manifest merges in the WorkManager provider automatically, so do not delete the merged bits when you clean up.

iOS — open AppDelegate.swift and register the background processing handler:

import workmanager

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(_ application: UIApplication,
                            didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "sync-job", frequency: NSNumber(value: 15 * 60))
    WorkmanagerPlugin.registerBGProcessingTask(withIdentifier: "sync-processing")
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}
Enter fullscreen mode Exit fullscreen mode

Then go to Signing & Capabilities → Background Modes and tick Background fetch and Background processing. Skip this and iOS will silently refuse to run anything in the background. This is the #1 cause of "it worked on Android but not on my iPhone."

Important Notes — The Constraints Nobody Tells You

  1. Android minimum frequency is 15 minutes. registerPeriodicTask with a frequency shorter than that is silently clamped to 15 minutes by the OS. If you need sub-minute intervals, you are in foreground-service territory, not workmanager.
  2. Periodic tasks are not exact. Android batches and delays them to save battery. Your "every 15 minutes" job might fire at 17 or 22 minutes. If you need a hard schedule (like an alarm clock), you need the android_alarm_manager_plus package instead.
  3. iOS limits the budget. iOS gives background tasks roughly 15 seconds to complete on a fetch, and the OS decides when the task runs based on usage patterns — you cannot force it. Keep the work idempotent: if it runs twice, nothing breaks.
  4. Do not do heavy work here. If your sync touches 10,000 rows, it will be killed on both platforms. Do the heavy lifting on the server and let the background task only pull small deltas. I once watched a client's background sync get killed by iOS three days in a row before we moved the aggregation server-side.
  5. Debugging tip: add a test button that calls syncNow() so you can trigger the same code path on demand. Waiting 15 minutes between iterations is how background-task bugs eat a day.
  6. Test on a real device. Emulators do not apply Doze mode or iOS throttling. The exact same build behaves differently on a Pixel in your pocket than in the Android emulator.

Checking on a Task's State

Sometimes you need to know whether a task already ran, so you do not double-schedule on app restart. workmanager exposes status queries on Android:

import 'package:workmanager/workmanager.dart';

Future<bool> isTaskPending() async {
  final pending = await Workmanager().getPendingTasks();
  return pending.any((task) => task.uniqueName == 'sync-job');
}
Enter fullscreen mode Exit fullscreen mode

On iOS, getPendingTasks is not supported the same way, so I track the last-run timestamp myself — persist it in SharedPreferences after each successful sync and read it in initState. It is simpler than fighting platform differences and it doubles as your "last synced at" UI.

The Most Common Production Bugs (In Order of How Often I See Them)

I want to be honest that this setup did not work the first time — I have shipped this pattern enough times to have a short list of the exact bugs that eat an afternoon each:

  1. Missing @pragma('vm:entry-point'). Your background isolate runs in release mode with tree shaking on; without the pragma the callback is stripped and the task silently never runs. This one is the sneakiest because it works perfectly in debug builds.
  2. Registering the periodic task every time the app opens with the default existingWorkPolicy, queuing duplicate jobs until the OS starts batching them unpredictably. Use ExistingPeriodicWorkPolicy.update and guard with a single flag.
  3. Doing slow I/O in the callback. A network call to an endpoint that takes 30 seconds will be killed on both platforms. Keep each task under a few seconds of real work, or chunk it.
  4. Assuming 15 minutes means 15 minutes. On Android it is the minimum period, not a promise; on iOS there is no period guarantee at all. Design for "might run twice, might run late."
  5. Not handling the return value. Returning false from executeTask tells the OS the work failed; returning true marks it complete. Return false only when you actually want to be retried.

Cancelling Tasks and Recovering From Restarts

Two housekeeping operations complete the lifecycle, and both have bitten me when I skipped them.

Cancelling a task — needed when the user logs out and you must stop syncing for their account:

await Workmanager().cancelByUniqueName('sync-job'); // one task
await Workmanager().cancelAll();                    // everything
Enter fullscreen mode Exit fullscreen mode

Surviving a device reboot. With RECEIVE_BOOT_COMPLETED in your manifest, Android re-registers your periodic tasks automatically after a reboot — but only if the app has been launched at least once since install (the user opening the app after boot is what triggers the re-registration path). This is why a background sync that "just stopped working" after a reboot is usually the user having cleared the app from recents and never reopening it; your callback never gets a chance to re-register. The mitigation is registering in main() (which we do) so that any app open re-establishes the schedule, and being honest in the UI that a fully killed app may not sync until reopened.

The Complete main() for Copy-Paste Reference

Here is everything wired together, exactly as it ships:

import 'package:flutter/material.dart';
import 'package:workmanager/workmanager.dart';

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    try {
      switch (task) {
        case 'syncData':
          await syncDataToServer();
          break;
        case 'cleanCache':
          await cleanLocalCache();
          break;
      }
      return true;
    } catch (e) {
      return false; // will be retried
    }
  });
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  Workmanager().initialize(callbackDispatcher);
  runApp(const MyApp());
}
Enter fullscreen mode Exit fullscreen mode

The try/catch returning false is deliberate: a crash mid-task tells the OS the work failed and can trigger a retry, while returning true marks it done. I have watched teams debug "my sync randomly drops rows" only to find the callback threw after completing part of the work. Wrap it, and treat the return value as a contract, not a formality.

Alternatives, and When to Reach for Them

workmanager is my default, but it is not the only tool, and knowing the boundaries saves you a rewrite:

  • android_alarm_manager_plus — when you need an exact alarm (medication reminders, booking windows). It schedules with the alarm clock, which survives Doze, but it is Android-only and you must implement iOS separately.
  • flutter_background_service — when you need a long-running background service (location tracking, audio). This is heavier and has its own battery implications; do not use it for a five-second sync.
  • A foreground service via a plugin — the only honest answer for "run every minute" or "run continuously." It shows a persistent notification and drains battery; only reach for it when the OS constraints genuinely block your feature.

The Rule of Thumb I Now Ship With

If a client asks me for background work, I say one sentence before writing any code: "scheduled, small, and idempotent, with the OS in charge of timing." If the task must be exact, make it a foreground service. If it must be frequent, reconsider the feature. If it can be late and duplicate-safe, workmanager is your tool — and the setup above is the version I have shipped three times this year.

That's it — a complete, production-shaped background task setup in Flutter with workmanager. The one-page summary: top-level callback with @pragma('vm:entry-point'), register once with update policy, respect the 15-minute minimum on Android and the 15-second budget on iOS, and keep the work small and idempotent.

I have also covered scheduled notifications and local caching with this exact pattern — comment below with the background task you are trying to schedule and I'll cover it next.


*Gulshan Yad

Top comments (0)