+1 (415) 480-3939

Push Notifications in Flutter That Actually Arrive: FCM HTTP v1, APNs, and Background Isolates

Almost every Flutter engagement eventually gets the same ticket: "notifications don't arrive on some phones." It is rarely one bug. It is usually four separate systems — Firebase Cloud Messaging, APNs, the Android background execution rules, and your own routing code — each failing quietly in a different way, on a different subset of devices.

This tutorial builds a notification stack we would be comfortable shipping: FCM HTTP v1 on the server, permission flows that don't burn the one prompt you get, a background isolate handler that behaves, notification-to-screen routing that survives a cold start, and a triage checklist for the "it works on my Pixel" class of bug. Versions are firebase_messaging 15.x and flutter_local_notifications 18.x on Flutter 3.4x.

The mental model: two payload types, three app states

Everything downstream depends on getting this right.

A message from FCM has two optional halves: a notification block and a data block.

PayloadAndroid background/terminatediOS background/terminatedForeground (both)
notification onlyOS draws the tray item; your code never runsOS draws the banner; your code never runsNothing is drawn; onMessage fires
data onlyYour background handler runs; you draw itDelivered only with content-available, throttled by iOSonMessage fires
bothOS draws it and the handler runsOS draws it; handler runs if content-availableonMessage fires with data

The single most common architecture mistake is sending notification blocks and then wondering why analytics, badge counts, or cache updates never happen when the app is backgrounded. The OS displayed the message; your Dart code was never invoked.

Our default for anything that needs app-side behaviour is: send both, keep the data block authoritative, and treat the OS-drawn tray item as a display convenience. For silent sync-only messages, send data only — and never rely on it for correctness on iOS.

Server side: FCM HTTP v1, not the legacy API

The legacy /fcm/send endpoint and server keys are gone. Send OAuth2-authenticated HTTP v1 requests to https://fcm.googleapis.com/v1/projects/<project-id>/messages:send, with the platform blocks spelled out explicitly:

{
  "message": {
    "token": "<device-token>",
    "notification": { "title": "Job #4821 assigned", "body": "Tap to view the work order" },
    "data": { "type": "work_order", "id": "4821", "deeplink": "/orders/4821" },
    "android": {
      "priority": "high",
      "notification": { "channel_id": "jobs", "tag": "work_order_4821" }
    },
    "apns": {
      "headers": { "apns-priority": "10", "apns-collapse-id": "work_order_4821" },
      "payload": { "aps": { "sound": "default", "badge": 3, "mutable-content": 1,
                            "interruption-level": "time-sensitive" } }
    }
  }
}

Four details that matter in production:

  • All data values must be strings. Numbers or nested JSON will be rejected or silently stringified. Encode structures yourself: "payload": jsonEncode(map).
  • android.priority: high is what allows delivery to a device in Doze. Use it for user-visible messages and not for silent background sync, or you will be rate-limited.
  • apns-collapse-id / Android tag replace an earlier notification about the same entity instead of stacking five copies.
  • interruption-level on iOS 15+ decides whether the message pierces Focus modes. time-sensitive requires the matching entitlement; abusing it gets apps flagged in review.

Handle token responses properly. UNREGISTERED or INVALID_ARGUMENT on a token means delete that row — retrying it forever is how notification services end up throttled.

Client setup: channels before permissions

Create Android channels at startup. A channel created after the first notification arrives gets the default importance forever, and users can't fix it.

const jobsChannel = AndroidNotificationChannel(
  'jobs',                                   // must match android.notification.channel_id
  'Job assignments',
  description: 'New and reassigned work orders',
  importance: Importance.high,              // heads-up banner
);

Future<void> initNotifications() async {
  final local = FlutterLocalNotificationsPlugin();
  await local
      .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(jobsChannel);

  await local.initialize(
    const InitializationSettings(
      android: AndroidInitializationSettings('@mipmap/ic_launcher'),
      iOS: DarwinInitializationSettings(
        requestAlertPermission: false,      // we ask later, deliberately
        requestBadgePermission: false,
        requestSoundPermission: false,
      ),
    ),
    onDidReceiveNotificationResponse: (response) =>
        NotificationRouter.instance.handle(response.payload),
  );
}

Ship one channel per category the user might reasonably want to mute separately — job assignments, chat, marketing — and no more. Channels are permanent: deleting and recreating one with the same id does not reset its settings.

Permission: ask once, ask late, ask with context

On iOS and on Android 13+ (POST_NOTIFICATIONS) you get exactly one system prompt. If the user declines, only a trip to Settings can undo it. Prompting on first launch is the most expensive habit in mobile — opt-in rates roughly double when the prompt follows a screen that explains the value.

Future<bool> requestNotificationPermission() async {
  final messaging = FirebaseMessaging.instance;
  var settings = await messaging.getNotificationSettings();

  if (settings.authorizationStatus == AuthorizationStatus.notDetermined) {
    // Show your own explanation sheet first; only continue if the user opts in.
    if (!await showPrimingSheet()) return false;
    settings = await messaging.requestPermission(alert: true, badge: true, sound: true);
  }

  if (settings.authorizationStatus == AuthorizationStatus.denied) {
    // Deep-link to system settings rather than re-prompting; the OS will ignore you.
    return false;
  }
  return settings.authorizationStatus == AuthorizationStatus.authorized ||
      settings.authorizationStatus == AuthorizationStatus.provisional;
}

iOS provisional authorization is underused: pass provisional: true and notifications arrive silently in Notification Centre with no prompt at all, and the user is offered "Keep" or "Turn Off" after seeing a real one. For low-stakes categories it is strictly better than burning the prompt.

Tokens: register, refresh, and unregister

Token handling is where multi-user apps leak notifications to the wrong person.

class PushRegistrar {
  StreamSubscription<String>? _sub;

  Future<void> bind(String userId) async {
    if (Platform.isIOS) {
      // Ensure APNs has issued its token before asking FCM for one.
      await FirebaseMessaging.instance.getAPNSToken();
    }
    final token = await FirebaseMessaging.instance.getToken();
    if (token != null) await api.registerToken(userId: userId, token: token);

    _sub?.cancel();
    _sub = FirebaseMessaging.instance.onTokenRefresh
        .listen((t) => api.registerToken(userId: userId, token: t));
  }

  Future<void> unbindOnLogout() async {
    await _sub?.cancel();
    await api.unregisterToken(await FirebaseMessaging.instance.getToken());
    await FirebaseMessaging.instance.deleteToken(); // forces a fresh token next login
  }
}

Rules we apply on every project: tokens belong to a (user, device) pair, not to a user; delete the token on logout before clearing the session; store updatedAt server-side and prune tokens untouched for 60+ days. FCM tokens rotate on reinstall, restore-from-backup, and occasionally on their own.

The iOS-specific trap is calling getToken() before APNs has registered — you get null or an exception on a real device, usually only in TestFlight builds where nobody notices until launch week.

The background handler runs in its own isolate

This is the part that surprises teams. onBackgroundMessage executes in a separate isolate with no access to your providers, your DI container, your navigator, or any singleton state your app set up. It must be a top-level (or static) function annotated for AOT retention, and it must initialise everything it needs.

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();          // required: fresh isolate
  final db = AppDatabase();                // its own connection, not the app's
  try {
    await db.upsertNotification(message.data);
    await _incrementBadge(db);
  } finally {
    await db.close();
  }
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
  runApp(const App());
}

Constraints worth writing on the wall:

  • Keep it under a few seconds. Both platforms will kill a long handler, and Android may stop waking your app if it consistently overruns.
  • Do not touch SharedPreferences you also mutate in the UI isolate without treating it as shared mutable state; a database with transactions is safer.
  • No navigation, no BuildContext, no Riverpod/GetIt lookups. Write to storage; let the UI isolate react when it next runs.
  • On iOS, this only runs if the push includes content-available: 1, and the OS decides how often. Budget for "sometimes never."

Foreground: you draw it yourself

In the foreground nothing is displayed automatically on Android, and iOS shows a banner only if you opt in. The standard pattern is to render foreground notifications through flutter_local_notifications so you control appearance and tap handling in one place.

FirebaseMessaging.onMessage.listen((message) async {
  final n = message.notification;
  if (n == null) return;                        // pure data message: handle silently
  if (NotificationRouter.instance.isViewing(message.data['id'])) return; // already on screen

  await local.show(
    message.hashCode,
    n.title,
    n.body,
    NotificationDetails(
      android: AndroidNotificationDetails(jobsChannel.id, jobsChannel.name,
          importance: Importance.high, priority: Priority.high,
          tag: message.data['id']),
      iOS: const DarwinNotificationDetails(presentAlert: true),
    ),
    payload: jsonEncode(message.data),
  );
});

Suppressing the banner when the user is already looking at the relevant screen is a small touch that reviewers and users both notice.

Routing a tap, including from a cold start

There are three entry points and teams routinely implement only two:

class NotificationRouter {
  NotificationRouter(this._router);          // go_router instance
  final GoRouter _router;
  static late NotificationRouter instance;

  Future<void> bootstrap() async {
    // 1. App was terminated and launched by the tap.
    final initial = await FirebaseMessaging.instance.getInitialMessage();
    if (initial != null) _navigate(initial.data);

    // 2. App was backgrounded and resumed by the tap.
    FirebaseMessaging.onMessageOpenedApp.listen((m) => _navigate(m.data));
    // 3. Local notification tap (foreground path) arrives via onDidReceiveNotificationResponse.
  }

  void handle(String? payload) {
    if (payload != null) _navigate(jsonDecode(payload) as Map<String, dynamic>);
  }

  void _navigate(Map<String, dynamic> data) {
    final path = data['deeplink'] as String?;
    if (path == null) return;
    if (!_isSignedIn) { _pendingPath = path; return; }   // resolve after auth
    _router.go(path);
  }
}

Two rules save most of the pain. First, notifications carry a path, not a screen name — the same /orders/4821 string works from a push, an email link, and a QR code, and it is testable with flutter run --route. Second, always stash the target and replay it after authentication and after any onboarding gate finishes; the cold-start tap frequently lands before your router knows who the user is.

Rich notifications and images

Images require native help. On iOS you need a Notification Service Extension to download the attachment before display, and the payload must set mutable-content: 1. On Android, flutter_local_notifications can fetch and attach a BigPictureStyleInformation from the background handler. Budget a day for the extension target: it is a separate bundle with its own provisioning profile, and it is the most common reason "images work in debug, not on the App Store".

Triage: why it works on your device and not theirs

When a report comes in, walk the chain in this order rather than guessing:

  1. Is the token current? Log the token server-side with the send. A stale token returns UNREGISTERED and the send looks "successful" in your own logs.
  2. Permission state, retrieved via getNotificationSettings() and reported into your analytics. A surprising share of "missing notifications" are simply denied prompts.
  3. Channel importance. The user may have muted the channel; getNotificationChannels() tells you.
  4. Aggressive OEM battery management. Xiaomi, Oppo, Vivo, Huawei, and Samsung ship background restrictions that kill high-priority delivery. Detect the vendor and offer a one-tap link to the battery settings screen — it is the only real fix.
  5. Focus modes / Do Not Disturb on iOS, which is what interruption-level addresses.
  6. Silent-push throttling on iOS. If your feature depends on content-available arriving promptly, redesign it.

Instrument every step: server send id, FCM message id, client receipt, tap. Without end-to-end ids, notification bugs are unfalsifiable.

Testing without spamming users

  • firebase_messaging can be driven in widget tests by faking the platform channel; assert that a RemoteMessage produces the right local notification and route.
  • For device testing, push directly to APNs with a .p8 key and curl, which skips FCM entirely and isolates which layer is broken.
  • Use a staging Firebase project. Sending test pushes from production is how a "hello world" reaches 40,000 people.
  • Add an integration test that launches the app from a notification payload (patrol handles the OS-level tap) and asserts the destination screen.

Where this usually lands

For most apps the finished stack is: FCM HTTP v1 with explicit Android and APNs blocks, both notification and data on user-visible messages, one channel per category, a primed permission request placed after the user has seen the value, tokens scoped per device with logout cleanup, a small self-contained background isolate handler, and a router that treats a notification as just another deep link. That is a couple of days of work done properly, versus weeks of intermittent bug reports done otherwise.

If your team is fighting delivery gaps, silent-push assumptions, or a notification stack inherited from an earlier vendor, our Flutter consultants do exactly this kind of hardening work — get in touch and we will look at your payloads and your token lifecycle with you.