Flutter Push Notifications with FCM — Complete 2026 Walkthrough

Every project I touch eventually needs push notifications, and every project asks the same question: why does the notification work in the foreground but vanish in the background? So, in this article, I will be showing you how you can set up Flutter push notifications with Firebase Cloud Messaging (FCM) the complete way — foreground, background, and terminated states — including the 2026 gotchas: the Android 13 runtime permission, the FCM HTTP v1 API, and the background handler that half the tutorials skip.
For this purpose, we need to add these dependencies in your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.8.0
firebase_messaging: ^15.1.0
flutter_local_notifications: ^18.0.0
firebase_coreinitializes Firebase in your app — mandatory first step.firebase_messaginghandles receiving FCM messages and the device token.flutter_local_notificationsshows notifications for messages received while the app is in the foreground — a step everyone forgets, because FCM does not display foreground messages by default.
The firebase_messaging package's own documentation points you to flutter_local_notifications for exactly this reason, and skipping it is the #1 cause of "notifications work when the app is closed but not when I open it."
Let's jump into the coding part.
Step 1: Firebase Project Setup
Before any Dart, the project-side setup, because it is where most people stall:
- Create a Firebase project in the Firebase console and add your Android (package name) and iOS (bundle ID) apps.
- Download
google-services.jsonand drop it intoandroid/app/, andGoogleService-Info.plistintoios/Runner/. - Add the Google services Gradle plugin to
android/build.gradle:
// android/build.gradle
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.2'
}
}
and apply it at the bottom of android/app/build.gradle:
apply plugin: 'com.google.gms.google-services'
- For iOS, update your AppDelegate to let Flutter know the app is done launching (needed for background notifications):
// ios/Runner/AppDelegate.swift
import FirebaseCore
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
On iOS you must also upload your APNs key to Firebase (Project settings → Cloud Messaging) or FCM cannot deliver to iPhones. This is the most common silent failure: everything works on Android, nothing arrives on iOS, and the reason is a missing APNs key.
Step 2: Initialize Firebase and Request Permission
Now the Dart side. Initialize Firebase before anything else, request notification permission, and grab the device token:
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
Future<void> setupPush() async {
await Firebase.initializeApp();
final messaging = FirebaseMessaging.instance;
// Android 13+ requires a runtime permission (API 33+)
final settings = await messaging.requestPermission(
alert: true, badge: true, sound: true,
);
debugPrint('Granted: ${settings.authorizationStatus}');
// Register for remote messages (needed on iOS too)
await messaging.setForegroundNotificationPresentationOptions(
alert: true, badge: true, sound: true,
);
// Get the device token to send to your backend
final token = await messaging.getToken();
// Send `token` to your server and store it.
debugPrint('FCM Token: $token');
}
On Android 13 and newer, requestPermission() triggers the runtime dialog the OS requires — without it, notifications are silently blocked. On older Android and on iOS, this maps to the appropriate system permission. Run this in main() after WidgetsFlutterBinding.ensureInitialized().
Step 3: The Foreground Message Handler
FCM does not show a notification when the app is in the foreground — it only delivers the data to your handler. So we forward it to flutter_local_notifications:
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
final _local = FlutterLocalNotificationsPlugin();
Future<void> initLocalNotifications() async {
const init = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await _local.initialize(init);
}
Future<void> showForegroundNotification(RemoteMessage message) async {
await _local.show(
message.hashCode,
message.notification?.title ?? 'Update',
message.notification?.body ?? '',
const NotificationDetails(
android: AndroidNotificationDetails(
'main_channel', 'General',
channelDescription: 'General notifications',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
),
);
}
Then, in your setup, listen for foreground messages and route them:
FirebaseMessaging.onMessage.listen(showForegroundNotification);
Step 4: The Background and Terminated-State Handler
This is the part that gets skipped, and it is why notifications "disappear" when the app is closed. A message that arrives when the app is backgrounded or terminated is delivered to a top-level handler — a function outside your widget tree, exactly like the workmanager callback in my background-tasks guide:
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Do NOT touch UI here. Log, or queue work.
debugPrint('Background message: ${message.notification?.title}');
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}
The @pragma('vm:entry-point') annotation is essential — without it, release builds can strip the handler and background messages silently do nothing. When the app is terminated, the OS launches a minimal isolate to run this handler; it has a few seconds and no UI access. If you need to handle taps that open the app, listen to FirebaseMessaging.onMessageOpenedApp and handle messaging.getInitialMessage() for terminated-state launches.
Step 5: Sending a Notification — the 2026 Way
In 2026, send using FCM's HTTP v1 API (the legacy send endpoint is deprecated). Example using the legacy-free approach with a server service account:
# Server side — use an OAuth2 token from your service account
curl -X POST "https://fcm.googleapis.com/v1/projects/<YOUR_PROJECT_ID>/messages:send" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"token": "<DEVICE_FCM_TOKEN>",
"notification": {
"title": "Order shipped",
"body": "Your order #4821 is on the way"
},
"android": { "notification": { "channel_id": "main_channel" } }
}
}'
Two things matter here. First, the android.notification.channel_id must match the channel name from your AndroidNotificationDetails — mismatch means the notification arrives with the app's default channel and bad behavior. Second, for Android 13+ you can include android.notification.priority and rely on the channel importance you set in the app, not the payload.
Important Notes — The Failure Modes I Have Paid For
- Foreground messages are never shown by FCM. If you did not wire
onMessageintoflutter_local_notifications, foreground notifications appear to be "broken." They are not broken — they are being delivered to a handler that does nothing. - Android 13 blocks notifications without the runtime permission. Test on a physical Android 13+ device, not an emulator. If
requestPermission()returns denied, no notification will ever show, and no amount of payload tuning fixes it. - The background handler must be top-level with
@pragma('vm:entry-point'). A closure that references widget state will crash or no-op in the background isolate. - Token refresh. FCM tokens rotate. Listen to
messaging.onTokenRefreshand update your backend, or users quietly stop receiving notifications after a reinstall. - iOS needs the APNs key configured in Firebase, or Android works and iOS is dead silent. Also register the background modes in your Xcode project if you need
data-onlymessages. - Do not trust the emulator for delivery. Emulators often have unreliable FCM delivery. Physical devices behave differently; test there.
- Keep notification payloads small. A 4KB notification payload is the FCM limit. If you need to send rich data, send an ID and fetch the rest over your API when the notification is tapped.
Step 6: Handle Token Refresh and Notification Taps
Two pieces of the wiring complete the picture, and both are usually missing from tutorials.
Token refresh. FCM tokens rotate — after a reinstall, an app update, or on some OS quirks. Listen for it and push the new token to your backend, or your users silently stop getting notifications:
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
// Send newToken to your backend, replacing the old one.
debugPrint('Token refreshed: $newToken');
});
Tap handling with navigation. When the user taps a notification you usually want to deep-link somewhere. The message payload's data map is where you put a route or ID, and you handle taps in the foreground and the backgrounded/terminated states separately:
void setupTapHandlers() {
// App was in foreground when tapped
FirebaseMessaging.onMessageOpenedApp.listen((message) {
final route = message.data['route'];
navigatorKey.currentState?.pushNamed(route ?? '/');
});
// App was terminated when tapped — check on launch
FirebaseMessaging.instance.getInitialMessage().then((message) {
if (message != null) {
final route = message.data['route'];
navigatorKey.currentState?.pushNamed(route ?? '/');
}
});
}
Keep a GlobalKey<NavigatorState> at the app root so the handler can navigate even when it runs outside a widget's context. Data messages (a data field without a notification field) are the reliable way to carry this routing payload, because FCM delivers them to your handler on every state — foreground, background, and terminated.
Step 7: Subscribing to Topics
Instead of sending to individual device tokens, FCM supports topics — one message fans out to every subscribed device. This is how I shipped a weekly digest for a client without managing a token list:
await FirebaseMessaging.instance.subscribeToTopic('digest-weekly');
await FirebaseMessaging.instance.unsubscribeFromTopic('digest-weekly');
Send to a topic with the same HTTP v1 endpoint, replacing "token" with "topic": "digest-weekly". Topics are perfect for broadcast-style notifications (news, offers, digests) and dead simple at scale. The catch: everyone subscribed to a topic gets the same message, so personalization still needs a token-based send.
The 2026 Checklist — Go Through This Before You Ship
-
google-services.jsoninandroid/app/and Gradle plugin applied - iOS:
GoogleService-Info.plistinios/Runner/, APNs key uploaded in Firebase - iOS:
FirebaseApp.configure()in AppDelegate, notification delegate set - Android 13+ runtime permission requested and granted on a physical device
- Foreground messages routed through
flutter_local_notifications - Top-level background handler with
@pragma('vm:entry-point') -
onMessageOpenedApp+getInitialMessagehandle taps in every state -
onTokenRefreshupdates your backend - Payload
datacarries a route key, tested on a locked, backgrounded device - Sends use the FCM HTTP v1 API, not the deprecated legacy endpoint
Data-Only Messages vs Notification Messages — Pick Deliberately
FCM has two message shapes, and choosing wrong is the root of half the "notification disappeared" bugs I debug.
A notification message (a notification field) is handled by the OS when the app is backgrounded or terminated — FCM itself builds the notification, and your Dart handler is not guaranteed to run for display purposes. A data-only message (only a data field, no notification) is delivered to your Dart handler in every state — foreground, background, and terminated — and you decide what to show, or whether to show anything.
My rule: use notification messages for simple alerts you want the OS to display with zero code, and data-only messages whenever you need custom behavior — routing, localization, deciding on-device whether the notification is relevant. The tap-handling code above relies on data, so if you only ever send notification messages, you will have nothing to read in onMessageOpenedApp. When in doubt, send both: a small notification for OS display plus a data payload with your routing keys.
For quick manual tests, the Firebase console's Cloud Messaging section lets you compose and send a message without writing any server code — target your physical device by token and confirm each state (foreground, background, terminated) before you involve the backend. That ten-minute manual pass has caught more misconfiguration for me than any amount of code reading, because it isolates the problem: if the console send fails to display, the bug is in your app wiring; if it displays but your API send does not, the bug is server-side.
That's it — a complete FCM push notification integration for 2026: Firebase project setup, Android 13 permission, device token, foreground routing through local notifications, the top-level background handler, and a working HTTP v1 send. The four-file checklist is: google-services.json, the Gradle plugin, the iOS AppDelegate, and the main.dart wiring — and the background handler is the one that makes the difference between "works on my desk" and "works on a locked phone in a pocket."
I have also covered local scheduled notifications and background tasks with this exact stack — comment below with the notification feature you are stuck on and I'll cover it next.
*Gulshan Yad
Setting Up the Firebase Project and Configuration Files
The first step in any Flutter push‑notification workflow is to create a Firebase project and enable Cloud Messaging. In the Firebase console, add a new Android and iOS app, following the wizard to download the google‑services.json and GoogleService‑Info.plist files. Place these files in the app module and root directories respectively. Next, make sure the Gradle build scripts reference the Firebase services plugin and that the AndroidManifest.xml includes the necessary services and permissions. For iOS, open the Runner project in Xcode, enable the appropriate capabilities (e.g., Push Notifications, Background Modes) and import the plist. This foundation guarantees that the Flutter app can communicate with FCM servers and receive tokens. Once the configuration files are in place, run a quick build to confirm that the Firebase SDK initializes without errors. A common pitfall is mismatched package names or incorrect bundle identifiers; double‑check that the values match the app’s actual identifiers. After a successful build, the app will generate a registration token that can be retrieved via FirebaseMessaging.instance.getToken(). This token is the key to routing messages from your server or the Firebase console to the specific device.
Integrating firebase_messaging and Handling Permissions
With the Firebase core set up, add the firebase_messaging package to your pubspec.yaml. The latest stable release includes built‑in support for both Android and iOS notification handling. In main.dart, initialize Firebase with Firebase.initializeApp() before calling runApp(). After initialization, request notification permissions on iOS by calling FirebaseMessaging.instance.requestPermission(). Specify alert, badge, and sound options to match your app’s design. On Android, permissions are granted at install time, but you should still check for the ACCESS_NOTIFICATION_POLICY permission if you plan to use notification channels. Handling permissions is only the first step; you must also set up background message handling. Define a top‑level function annotated with @pragma('vm:entry-point') that accepts a RemoteMessage and processes it. Register this function with FirebaseMessaging.onBackgroundMessage. This ensures that even when the app is terminated, the OS will wake the Dart VM to run your handler. Inside the handler, you can parse data payloads, write to a local database, or trigger a local notification using flutter_local_notifications. By centralizing background logic, you avoid code duplication across iOS and Android.
Implementing Background Message Handlers
Background message handlers are the backbone of silent updates and critical alerts. On Android, the system invokes the handler when a high‑priority data message arrives, even if the device is in Doze mode. On iOS, the handler is called for content‑available messages when the app is in the background or terminated. Because the handler runs in isolation, avoid heavy UI work; instead store data for later consumption when the user opens the app. Use the flutter_local_notifications package to display a custom banner that matches your brand. Configure the notification channel with a unique channel ID, name, and importance level, and then schedule the notification with the desired title and body. When designing the handler, be mindful of the payload size limit: 4 KB for the entire message. Any data beyond this threshold will be truncated. It’s often safer to send a simple key/value pair and fetch the full content from your backend during the handler execution. Additionally, keep the handler stateless; rely on shared preferences or a local database to persist state across restarts. This pattern keeps the background logic lightweight and compliant with platform restrictions.
Managing Device Tokens and Server‑Side Registration
The FCM registration token is volatile; it changes when the user reinstalls the app, clears app data, or the token is rotated by Firebase. To maintain reliable delivery, listen to FirebaseMessaging.instance.onTokenRefresh and immediately push the new token to your server. Store the token locally using a secure mechanism such as flutter_secure_storage to avoid leaks. On the server side, maintain a mapping of user IDs to tokens, and implement an endpoint that accepts token updates. When a user logs in, send the current token to the server and associate it with the user profile. Token lifecycle management also involves cleanup. When a user logs out, remove the token from the server to prevent orphaned notifications. For bulk operations, use the FCM HTTP v1 API’s batch endpoint to delete tokens that are no longer valid. This reduces wasted delivery attempts and keeps your messaging quota in check. Finally, consider implementing a health‑check routine that verifies token reachability by sending a lightweight test message and monitoring delivery reports. A healthy token list translates directly to higher engagement rates.
Crafting Data‑Only Payloads for Custom UI
Data‑only messages give you full control over how notifications appear. When sending from the Firebase console, choose the "Data" tab and populate key/value pairs. On the client, parse the payload in the onMessage and onBackgroundMessage callbacks. Instead of relying on the system tray, use flutter_local_notifications to build a rich notification with custom layouts, images, and action buttons. This approach is especially useful for in‑app messaging, where you want the notification to trigger a specific screen or update a badge. If you need to support legacy devices that do not honor high‑priority data messages, combine a notification payload with a data payload. The system will display the notification automatically, while your app can still process the data in the background. Keep the data minimal to stay within the size limits; for larger content, send a reference URL and fetch the full payload when the user taps the notification. This hybrid strategy balances deliverability with flexibility.
Advanced Topics: Topics, Priority, and Security
Topic messaging allows you to broadcast to a dynamic set of users without storing individual tokens. Subscribing to a topic is as simple as calling FirebaseMessaging.instance.subscribeToTopic('news'). On the server, publish to the topic via the FCM HTTP v1 API. Topics are ideal for role‑based notifications, such as updates for beta testers or region‑specific alerts. Priority settings are critical for time‑sensitive alerts. For Android, set "priority": "high" in the payload; for iOS, include "apns": {"headers": {"apns-priority": "10"}}. High‑priority messages wake
Key Takeaways
- Configure Firebase Cloud Messaging in the Firebase console, enable Android and iOS, and download the appropriate configuration files (google-services.json for Android, GoogleService-Info.plist for iOS) before integrating the Flutter plugin.
- Add firebase_messaging to pubspec, initialize the plugin in main.dart, request notification permissions on iOS, and set up background handlers with FirebaseMessaging.onBackgroundMessage.
- Use onMessage, onMessageOpenedApp, and onBackgroundMessage callbacks to route payload data to in‑app navigation or state updates, ensuring a consistent user experience across foreground and background states.
- Register device tokens with your backend and implement a secure token‑refresh strategy to keep the FCM registration token current and avoid silent failures.
- Employ topic subscriptions and message priority settings to target specific user segments and guarantee delivery even when device battery optimizations are active.
Frequently Asked Questions
How do I handle token refresh in Flutter?
The token is refreshed automatically by Firebase; listen to FirebaseMessaging.instance.onTokenRefresh to capture the new token and send it to your backend. Store the token locally and update the server immediately to keep push notifications reachable.
What is the difference between data and notification messages in FCM?
Notification messages are handled by the system tray when the app is in background; data messages are delivered to the app code regardless of state. For custom UI or silent updates, prefer data messages and build your own notification widget with flutter_local_notifications.
How do I test push notifications locally without a server?
Use the Firebase console’s "Notifications" tab to send a test notification to a specific device token or topic. You can also use curl with the FCM HTTP v1 API and a server key to post a payload directly to the device.
Why do notifications not appear when the app is in the foreground on iOS?
iOS silences notification alerts while the app is active. To show alerts, implement UNUserNotificationCenterDelegate and call presentAlert/presentSound in the willPresentNotification callback, or use a local notification plugin to display the message.
How can I ensure high‑priority messages reach the device during Doze mode?
Set the priority to high in the FCM payload ("priority": "high") and enable "High‑priority notifications" in the Firebase console. On Android, the system will wake the device to deliver the message even in Doze.
What steps are required for iOS background notification delivery?
Enable the "Background Modes" capability for "Remote notifications" in Xcode, add FirebaseAppDelegateProxyEnabled to Info.plist, and register the UNUserNotificationCenter delegate. The system will then wake your app to process the notification even when it is terminated.
How do I prevent duplicate notifications when the user opens the app from a notification?
Remove the notification from the notification center in onMessageOpenedApp or handle it by marking it as read on the server side. You can also set content‑available: 1 to fetch fresh data and replace the existing notification.
Can I use FCM to send push notifications to web clients in the same project?
Yes, after enabling Firebase Cloud Messaging in the web section of the Firebase console, you can register a service worker and use the Firebase JavaScript SDK to send messages to browsers. The same FCM server key can be used.
How do I debug missing token or permission issues on Android?
Check that google-services.json is in android/app, the google‑services plugin is applied, and that the app has the INTERNET and ACCESS_NETWORK_STATE permissions. Use Logcat to view Firebase logs and verify the token is generated.
What security best‑practice should I follow when sending FCM messages?
Keep the server key secret and use a secure backend that authenticates the app instance before sending a message. Use Firebase Admin SDK or HTTPS REST with bearer tokens to authenticate and avoid exposing credentials in the client.
1 followers
AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com




Comments
Sign in to join the conversation
No comments yet. Be the first to share your thoughts!