Firebase Messaging in Flutter: Handling Messages in Every App State
Foreground onMessage, the @pragma('vm:entry-point') background isolate handler, notification vs data messages, and tap routing from terminated state with firebase_messaging.
Published on • November 13, 2026
AI Assistant

Registration is the easy half of push. The hard half is that firebase_messaging delivers your payload through four different code paths depending on whether the app is foreground, background, or terminated — and each path has different rules about what Flutter code even runs. This post maps the message-handling side of FCM; for registration and the Firebase Installation ID flow, see firebase-messaging-flutter-push, and for the server-side send, see push-notifications-done-right-fcm-apns.
The three device states, and what runs where
Firebase’s docs define the states precisely:
| State | Description | Where your Dart runs |
|---|---|---|
| Foreground | App open, in view and in use | onMessage stream inside your UI isolate |
| Background | App open but minimized (home button, app switcher, other tab) | onBackgroundMessage handler in a separate isolate (Android) or the app process (iOS/macOS) |
| Terminated | App not running | Nothing runs until the user taps — then getInitialMessage() on startup |
Two preconditions apply to all of them: the app must have opened at least once so FCM registration happened, and if the user force-quit the app (swipe-away on iOS, force-stop on Android), background delivery stays dead until the user manually reopens it.
Foreground: you own the UI
Listen to onMessage while the app is visible:
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
if (message.notification != null) {
// The OS will NOT show this notification: display it yourself.
_showInAppBanner(message);
} else {
// Pure data message: update state silently.
_handleDataPayload(message.data);
}
});
The trap: notification messages that arrive in the foreground are not displayed by default on either platform. The Firebase docs are explicit about the overrides — on Android you must create a “High Priority” notification channel, and on iOS you update the app’s presentation options. Most teams instead show their own in-app banner, which gives them control over taps and styling anyway.
Background: three rules the handler must obey
On Android the background handler runs in a spawned isolate; on iOS/macOS it runs in the app process. Either way, FirebaseMessaging.onBackgroundMessage() has hard requirements from the docs:
- It must not be an anonymous function.
- It must be a top-level function — not a class method that requires initialization.
- On Flutter 3.3.0+ it must be annotated with
@pragma('vm:entry-point'), or tree shaking can delete it in release mode.
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Other Firebase services need their own init inside this isolate.
await Firebase.initializeApp();
// HTTP, local storage, plugin calls: fine.
// UI, your Riverpod container, BuildContext: impossible here.
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}
Because the handler lives outside your UI isolate, it cannot touch application state or widgets. It can do IO, HTTP, and update local storage. The docs also warn about the clock: finish quickly, because tasks running past roughly 30 seconds can get the process killed by the OS.
Notification vs data: pick by what should happen
The message type decides which code path you need:
- Notification messages are rendered by the OS when the app is backgrounded or terminated — zero Dart runs. Use them for “you have a new message” style alerts.
- Data messages carry only key/value pairs in
message.data. On Android they can wake your background handler while the app is closed; on iOS, background data delivery is throttled and not guaranteed.
A reliable pattern is hybrid: send a notification for the visible alert plus a data block with a type and an id so every handler — foreground, background, tap — routes from the same payload.
Taps: both entry paths, always
When the user taps, the app opens (cold start) or comes forward (background). The docs recommend handling both:
Future<void> setupInteractedMessage() async {
// 1. Terminated state: one-shot future, consumed once.
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) _handleMessage(initial);
// 2. Background state: stream of later taps.
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessage);
}
void _handleMessage(RemoteMessage message) {
if (message.data['type'] == 'chat') {
context.push('/chat/${message.data['id']}');
}
}
Miss the first one and cold-start taps silently land on the home screen — the most common push bug in production Flutter apps.
Web is a completely different animal
On the web there is no isolate: background messages are handled by a JavaScript service worker (firebase-messaging-sw.js) registered from index.html with its own scope, separate from Flutter’s own worker. The web client also needs VAPID keys passed to register(). That setup is covered end-to-end in flutter-web-push-notifications.
Takeaway: Route every payload through one handler per state — onMessage for foreground, a top-level @pragma('vm:entry-point') handler for background, and both getInitialMessage() and onMessageOpenedApp for taps — keep the handler UI-free and under 30 seconds, and remember that foreground notification messages never display themselves.