Guide
Flutter push notifications with FCM in 2026: iOS, Android and web
How to set up Firebase Cloud Messaging in a Flutter app on all three platforms, handle a message in every app state, and move from registration tokens to Firebase Installation IDs, which FCM now recommends.
Updated
The short version
- Registration tokens are deprecated in
firebase_messaging17. Infirebase_messaging17,getToken()andonTokenRefreshare marked deprecated. New code registers withregister(), receives a Firebase Installation ID (FID) ononRegistered, and the server sends tofid. - Each platform needs its own setup: an APNs key, the Push Notifications capability and two background modes on iOS, a notification permission on Android 13 and later, and a VAPID key plus a service worker on web.
- A message can arrive in three app states, and each has its own handler. The background handler must be a top-level function marked
@pragma('vm:entry-point'), and it can't touch the UI. - Store one ID per device, under the user, and remove it when the app unregisters or a send fails.
What changed in October 2026
Firebase now recommends Firebase Installation IDs over registration tokens. The Flutter client docs mark token access as deprecated: "Use Firebase Installation IDs, as this method will be removed in a future release." In firebase_messaging 17.0.0 (October 2026), getToken(), onTokenRefresh and deleteToken() carry @Deprecated annotations pointing at register(), onRegistered and unregister().
On the server, the FCM HTTP v1 API has a new fid target, and token is deprecated. During the transition, token also accepts an FID, so a server can switch before every app is updated. The Node Admin SDK (firebase-admin 14.5) has the matching fid and fids fields.
Before you upgrade, know three things:
firebase_messaging17 is a breaking release. It needs Firebase iOS SDK 13 and AndroidminSdk24. Upgrade the other FlutterFire packages with it.- Installation IDs are opt-in on Android and iOS (see below). Web needs no flag.
- Don't mix the two APIs. The docs say not to use the installation ID APIs and the token APIs for registration at the same time. Move each app in one release.
Platform setup
iOS
- In Xcode (
ios/Runner.xcworkspace), add the Push Notifications capability, and under Background Modes tick Background fetch and Remote notifications. - Create an APNs authentication key (
.p8) in your Apple Developer account and upload it in the Firebase console under Project settings → Cloud Messaging, with its key ID and your team ID. - To use installation IDs, add this to
ios/Runner/Info.plist:
<key>FirebaseMessagingInstallationIdEnabled</key>
<true/> On Apple platforms the APNs token must be available before FCM API calls (the docs cite iOS SDK 10.4.0 and later), so check getAPNSToken() before you call register(). Test delivery on a real device with a release build before you ship.
Android
- FCM needs Android 7.0 or later with Google Play services.
firebase_messaging17 setsminSdk24, which matches. - Android 13 and later need the user's permission, like iOS.
requestPermission()asks for it. - To show notifications while the app is open, create a high-priority notification channel. Notifications don't display in the foreground by default.
- To use installation IDs, add this inside
<application>inandroid/app/src/main/AndroidManifest.xml:
<meta-data android:name="firebase_messaging_installation_id_enabled"
android:value="true" />Web
- In the Firebase console, open Project settings → Cloud Messaging → Web Push certificates and generate a key pair. Pass the public key as
vapidKey. - Add
web/firebase-messaging-sw.js. It must sit at the web root and be served from the same origin as the app. It imports the app and messaging SDKs, initialises Firebase with your web config, and creates themessaginginstance, which shows notifications while the tab is in the background:
importScripts('https://www.gstatic.com/firebasejs/13.1.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/13.1.0/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: '...',
authDomain: '...',
projectId: '...',
messagingSenderId: '...',
appId: '...',
});
const messaging = firebase.messaging();Use your own project's web config here. A common bug is a service worker copied from a sample, still pointing at someone else's Firebase project, so web push silently goes nowhere.
Then register the worker. The JS SDK falls back to registering /firebase-messaging-sw.js itself, but the Firebase docs register it explicitly in web/index.html, which makes failures visible in the console:
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('firebase-messaging-sw.js', {
scope: '/firebase-cloud-messaging-push-scope',
});
});
}
</script>Register the device
Subscribe once, then register. onRegistered fires after every register() call, even when the app was already registered, so calling register() after each sign-in keeps your stored ID current. On Android and iOS, FCM also registers on its own at startup. Registration doesn't need notification permission, so ask for permission separately; on web, ask from a button click.
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
const webPushKey = 'YOUR_PUBLIC_VAPID_KEY';
StreamSubscription<String>? _onRegistered;
StreamSubscription<String>? _onUnregistered;
// Call after sign-in. Safe to call again: old listeners are cancelled first.
Future<void> registerForPush(String uid) async {
final messaging = FirebaseMessaging.instance;
// On Apple platforms, FCM calls need the APNs token first.
final apple = !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS);
if (apple && await messaging.getAPNSToken() == null) {
return; // Not ready yet (no permission or no network). Try again later.
}
await _onRegistered?.cancel();
await _onUnregistered?.cancel();
_onRegistered = messaging.onRegistered.listen(
(fid) => saveDevice(uid, fid),
onError: (e) => debugPrint('FCM registration failed: $e'),
);
_onUnregistered = messaging.onUnregistered.listen(
(fid) => removeDevice(uid, fid),
);
await messaging.register(vapidKey: kIsWeb ? webPushKey : null);
}
// Call from a button that explains why, not on page load.
Future<bool> askForPushPermission() async {
final settings = await FirebaseMessaging.instance.requestPermission();
return settings.authorizationStatus == AuthorizationStatus.authorized;
}
// Call on sign-out, so this device stops getting the old user's pushes.
Future<void> unregisterFromPush() => FirebaseMessaging.instance.unregister();saveDevice and removeDevice are yours. Write the FID somewhere your server can read it, keyed per device under the user, for example a map in users/{uid}/…, so one person with a phone and a laptop gets both. Template369 stores a map of devices under each user in Firestore the same way (on firebase_messaging 15 it still stores registration tokens).
Handle every app state
| App state | Handler | Use it to |
|---|---|---|
| Open and in use | FirebaseMessaging.onMessage | Show an in-app banner or update a badge. Nothing displays by default. |
| Background or closed, message arrives | FirebaseMessaging.onBackgroundMessage (Android and Apple; on web the service worker does this) | Short work with no UI for data messages, such as saving data. The OS shows notification messages itself. |
| Background, user taps | FirebaseMessaging.onMessageOpenedApp | Open the screen the message is about. |
| Closed, user taps | getInitialMessage() | Same, once, at startup. It returns a message only when the tap came from a notification. |
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart';
// Top level, not a method, and marked so release builds keep it.
@pragma('vm:entry-point')
Future<void> onBackgroundMessage(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// No navigation or UI here. Finish within a few seconds.
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
runApp(const MyApp());
}
// Call once the first screen is up and your router is ready.
Future<void> listenForTaps(void Function(RemoteMessage) openFrom) async {
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) openFrom(initial);
FirebaseMessaging.onMessageOpenedApp.listen(openFrom);
}The background handler has rules the docs spell out:
- It "must not be an anonymous function" and "must be a top-level function (e.g. not a class method which requires initialization)".
- From Flutter 3.3 it must carry
@pragma('vm:entry-point'). Without it, release builds can strip the function, and background messages stop working only in production. - On Android it runs in a separate isolate, so "it is not possible to update application state or execute any UI impacting logic". Calling your router or a state controller from it does nothing useful. Navigate from the tap handlers instead.
- Keep it short. A task that runs longer than 30 seconds may be killed.
To show notifications while the app is open on iOS, call FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(alert: true, badge: true, sound: true). On Android, show them yourself from onMessage on a high-priority channel, for example with flutter_local_notifications.
Send from your backend
Send from a trusted server, such as a Cloud Function, with the Admin SDK. Never send from the app, because the credentials would ship to every user. Put routing details in data, so the tap handlers know where to go:
import { getMessaging } from 'firebase-admin/messaging';
// One device
await getMessaging().send({
fid,
notification: { title: 'Invoice paid', body: 'Acme paid invoice #1042' },
data: { screen: 'invoice', id: '1042' },
});
// Every device a user has
const res = await getMessaging().sendEachForMulticast({
fids,
notification: { title: 'Invoice paid', body: 'Acme paid invoice #1042' },
data: { screen: 'invoice', id: '1042' },
});
// Remove the IDs FCM says are gone.
res.responses.forEach((r, i) => {
if (r.error?.code === 'messaging/installation-id-not-registered') {
removeDevice(uid, fids[i]);
}
}); The snippet is an ES module inside an async function; fid, fids and uid come from your device store. One multicast call takes up to 500 IDs, so split larger lists. If you still send registration tokens, the error code to match is messaging/registration-token-not-registered, and the token field accepts an FID during the transition. data values must be strings. Choose the recipient on the server from the signed-in user's own records, never from a user ID the client sends.
Test a send
- Log the FID from
onRegisteredin a debug build. - In the Firebase console, open Messaging, create a notification campaign and choose Send test message. Paste the ID where it asks for a Firebase Installation ID or FCM registration token.
- Repeat with the app open, in the background and fully closed, and tap the notification each time to check routing.
Nothing arrives? Check, in order: the permission was granted, the APNs key is uploaded (iOS), the opt-in flag is set (Android and iOS), and the service worker is registered with your own project's config (web).
Common mistakes
- Navigating from the background handler. It runs without your UI. Store the target in the message's
dataand navigate fromonMessageOpenedAppandgetInitialMessage(). - A handler without
@pragma('vm:entry-point'). It works in debug and fails in release. - Forgetting the cold-start tap.
onMessageOpenedAppdoesn't fire when the tap launches a closed app. HandlegetInitialMessage()too. - One ID per user instead of per device. The newest device overwrites the others, and only one of them gets pushes.
- Never removing dead IDs. Uninstalled apps leave IDs that fail on every send. Delete them when a send reports them unregistered, and on
onUnregistered. - Skipping the web permission. The browser shows its prompt when the app registers or asks for permission. Ask after a click that explains why, not on page load.
- Testing only on the simulator or in debug. Test background and closed-app delivery on a real device with a release build.
Common questions
Do I have to migrate to installation IDs now?
Token APIs still work and are marked for removal in a future release, with no date given. New apps should start on installation IDs. For an existing app, the server's token field accepting FIDs during the transition lets you update the backend first and then ship the app change.
Why don't my foreground notifications show?
That's the default on Android and iOS. Set the iOS presentation options, and on Android show a local notification on a high-priority channel from onMessage.
Can a background handler open a screen?
No. Save what you need and let the user's tap open the app; then route from onMessageOpenedApp or getInitialMessage().
Skip the wiring
Template369 is a Flutter SaaS starter with FCM already set up on iOS, Android and web: the per-device store, the handlers for every app state and the web service worker, next to auth, subscriptions and support chat. Its agent skills include connect-firebase, which points the app, the service worker and the VAPID key at your own Firebase project. For how those skills are written, see Agent skills for a Flutter app.
How we checked
From the Firebase docs for setting up a Flutter client (updated 28 October 2026) and receiving messages, the FCM HTTP v1 message reference, the firebase_messaging changelog and API source (17.0.1), and the firebase-admin 14.5.0 type definitions, all read on 30 October 2026. This area changes quickly, so check the client docs before you ship.