skip to content

With Flutter's firebase_messaging, which API receives a message or notification tap when the app is foregrounded, backgrounded or terminated?

level: middleimportance: must knowfreq 62%

answer

  1. three app states, four entry points
  2. foreground: a static Stream
  3. tap from background vs cold start
  4. getInitialMessage is consumed once
  5. top-level handler for background delivery

basics

~10 s

FirebaseMessaging.onMessage delivers messages in the foreground; onMessageOpenedApp fires when a tap resumes a backgrounded app; getInitialMessage() returns the tap that launched a terminated app; onBackgroundMessage registers a top-level handler for background or terminated delivery.

solid answer

~40 s

firebase_messaging splits delivery by app state. In the foreground, the static `FirebaseMessaging.onMessage` stream emits each `RemoteMessage`, and a notification message shows no banner by default. A tap on a notification that brings a **backgrounded** app forward arrives on `FirebaseMessaging.onMessageOpenedApp`. A tap that **cold-starts** a terminated app is not streamed: you call `FirebaseMessaging.instance.getInitialMessage()` once at startup, and it returns that message or `null`, and `null` on later calls because the message is consumed. Messages received while the app is backgrounded or terminated go to the top-level function registered with `FirebaseMessaging.onBackgroundMessage`, which runs outside your widget tree. A robust app wires all four, so an order-shipped tap opens the order whether the app was open, paused or killed.

code

dart · 70 lines
dart
import 'dart:async';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

@pragma('vm:entry-point')
Future<void> onBackgroundPush(RemoteMessage message) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  // No UI here: persist or sync only.
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(onBackgroundPush);
  runApp(const MaterialApp(home: Scaffold()));
}

class OrderPushListener extends StatefulWidget {
  const OrderPushListener({super.key, required this.onOpenOrder, required this.child});

  final void Function(String orderId) onOpenOrder;
  final Widget child;

  @override
  State<OrderPushListener> createState() => _OrderPushListenerState();
}

class _OrderPushListenerState extends State<OrderPushListener> {
  StreamSubscription<RemoteMessage>? _foreground;
  StreamSubscription<RemoteMessage>? _opened;

  @override
  void initState() {
    super.initState();
    _foreground = FirebaseMessaging.onMessage.listen(_showBanner);
    _opened = FirebaseMessaging.onMessageOpenedApp.listen(_openOrder);
    _handleColdStartTap();
  }

  Future<void> _handleColdStartTap() async {
    final initial = await FirebaseMessaging.instance.getInitialMessage();
    if (initial != null && mounted) _openOrder(initial);
  }

  void _showBanner(RemoteMessage message) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message.notification?.title ?? 'Order update')),
    );
  }

  void _openOrder(RemoteMessage message) {
    final orderId = message.data['orderId'];
    if (orderId is String) widget.onOpenOrder(orderId);
  }

  @override
  void dispose() {
    _foreground?.cancel();
    _opened?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

go deeper

for a junior

Name the four entry points and match each to a state: onMessage for the foreground, onMessageOpenedApp for taps that resume the app, getInitialMessage for cold-start taps, onBackgroundMessage for background delivery.

for a middle

Explain why the cold-start tap needs a Future read once rather than a stream, why no banner appears in the foreground by default, and why the background handler must be top-level and cannot touch widgets.

for a senior

Show how you make tap handling idempotent across both paths, where the listeners live so they survive rebuilds, and how you would verify each state on a real device before a release.

for a principal

Discuss where push handling sits in the app's architecture: one service that normalises all four entry points into domain events, so screens never depend on which app state delivered the message.

## The three app states **firebase_messaging** is the FlutterFire plugin that connects a Flutter app to Firebase Cloud Messaging (FCM). The plugin's documentation defines three states and routes each incoming message or notification tap through a different API: - **Foreground**: the app is open, visible and in use. - **Background**: the app is still running but minimised, for example after the user pressed home or switched apps. - **Terminated**: the app process is not running, or the device is locked. | Event | App state | API | Shape | |---|---|---|---| | Message arrives | Foreground | `FirebaseMessaging.onMessage` | static `Stream<RemoteMessage>` | | User taps notification | Background | `FirebaseMessaging.onMessageOpenedApp` | static `Stream<RemoteMessage>` | | User taps notification | Terminated | `FirebaseMessaging.instance.getInitialMessage()` | `Future<RemoteMessage?>` | | Message arrives | Background or terminated | `FirebaseMessaging.onBackgroundMessage(handler)` | top-level `Future<void> Function(RemoteMessage)` | A `RemoteMessage` carries the payload: `data` (a `Map<String, dynamic>`), an optional `notification` with title and body, `messageId`, `sentTime` and more. ## Foreground: onMessage While the user is looking at the app, the plugin hands each message to the `onMessage` stream. Because you are in the main isolate, you can read app state and show your own UI, such as a `SnackBar` saying the order has shipped. - A **notification message** that arrives in the foreground shows **no system banner by default**, on Android or iOS. - On iOS you can opt in with `setForegroundNotificationPresentationOptions(alert: true, badge: true, sound: true)`. The options are persisted, so removing the call later does not turn them off. You must call it again with `false`. - On Android you display it yourself, typically through a local-notifications plugin and a high-importance notification channel. ## Taps: onMessageOpenedApp vs getInitialMessage Tapping a notification opens the app by default. What you get depends on whether the process was alive: 1. **Backgrounded app**: the app is brought forward and `onMessageOpenedApp` emits the tapped message. Subscribe early, for example in the `initState` of a widget near the root. 2. **Terminated app**: the app launches from scratch. No listener existed when the tap happened, so the plugin stores the message and `getInitialMessage()` returns it once. After that the message is removed and further calls return `null`. 3. **Handle both.** The upstream guide recommends wiring both paths. That way the order-shipped tap opens the order screen whichever state the app was in. One Android edge: if your app displayed a notification itself while in the foreground, and the user taps it after the app was backgrounded or killed, `getInitialMessage()` returns `null`. That notification belongs to whichever plugin displayed it, so its tap arrives through that plugin. ## Background delivery: onBackgroundMessage `FirebaseMessaging.onBackgroundMessage(handler)` registers a function that runs when a message arrives while the app is backgrounded or terminated. It must be a named top-level function, not an anonymous closure or an instance method, and in release builds it needs `@pragma('vm:entry-point')` so tree shaking keeps it. On Android the plugin starts it in a separate isolate with its own engine. It therefore cannot touch widgets or in-memory state: it can persist data, make HTTP calls or talk to plugins. Register it in `main()`, before `runApp`. ## Putting it together for an order-shipped push A typical wiring: - `main()` initialises Firebase, registers the background handler, then calls `runApp`. - A root-level `State` subscribes to `onMessage` (show a banner) and `onMessageOpenedApp` (open the order), and awaits `getInitialMessage()` once for the cold-start tap. - The tap handlers read an id such as `data['orderId']` and hand it to your router. How the route is matched is a navigation concern, not a messaging one. - Cancel the stream subscriptions in `dispose`, and check `mounted` after the `await` before touching `context`. ## Common mistakes - Expecting `onMessageOpenedApp` to see a cold-start tap. Since firebase_messaging 16.7.0 the Android side sends a tap that created the activity only to `getInitialMessage()`, which matches iOS and the API docs. - Calling `getInitialMessage()` from several places and wondering why the second call returns `null`. - Expecting a visible banner from `onMessage` without presentation options (iOS) or a local notification (Android). - Doing UI work, or reading globals set in `main()`, from the background handler.

  • Why could older firebase_messaging versions open the same order screen twice after a cold-start tap on Android?
    Before 16.7.0, the Android plugin could also emit a tap that created the activity on `onMessageOpenedApp`, while `getInitialMessage()` returned the same message. An app handling both paths navigated twice. 16.7.0 sends terminated-launch taps only to `getInitialMessage()`, matching iOS. Guarding by `messageId` still makes the handler safe to call twice.
  • On Android, your app showed a local notification for a foreground FCM message, and the user taps it after the app was killed. What does getInitialMessage() return?
    `null`. The plugin's docs say that on Android, a message received in the foreground whose notification is pressed later from a background or terminated state is not returned by `getInitialMessage()`. The notification was posted by whichever plugin displayed it, so its tap payload must be read through that plugin's own launch API.
  • Why is getInitialMessage() a Future and not a Stream like the other tap API?
    It answers a one-time question: did a notification tap launch this process? There is at most one such message per launch, and the plugin removes it once consumed, so a `Future<RemoteMessage?>` fits. Taps that resume an already running app can happen many times per session, so they arrive on the `onMessageOpenedApp` stream.

Think of a house. A doorbell while you are home is onMessage. A note slipped under the door that you read as you walk back in is onMessageOpenedApp. The letter waiting in the mailbox when you move back in is getInitialMessage, and once you take it out, it is gone. The house-sitter who signs for parcels while you are away is the background handler: they can store things, but they cannot rearrange your living room.

saying these in an interview costs you the question

  • onMessageOpenedApp also delivers the tap that cold-starts a terminated app.
  • onMessage shows the system notification banner automatically while the app is open.
  • getInitialMessage returns the same message every time you call it.
  • The background handler can call setState or push a route.
  • One onMessage listener covers foreground, background and terminated delivery.