Push Notifications in Flutter with Firebase: A Practical Guide
How to integrate push notifications in Flutter with Firebase Cloud Messaging: FlutterFire setup, handling permissions, tokens and the three app states, with up-to-date code.
Push Notifications in Flutter with Firebase: A Practical Guide
Push notifications are one of the most effective ways to bring users back into your app: a reminder, a shipped order, a new message. In Flutter, the standard way to handle them is Firebase Cloud Messaging (FCM), which reaches both Android and iOS from a single codebase.
In this guide we'll walk step by step through setting up push notifications in a Flutter app and handling them in the three states the app can be in: open, in the background, and fully closed.
Prerequisites: a working Flutter app, a Firebase account (free), and — for iOS — an Apple Developer account and a physical iPhone. Push notifications don't arrive on the simulator; that's an Apple restriction.
1. Connect the app to Firebase with FlutterFire
Before you start, you'll need a project already created in the Firebase Console and the Firebase CLI installed, with login done:
# Install the Firebase CLI (requires Node.js) and sign in
npm install -g firebase-tools
firebase loginOnce that's done, install the FlutterFire CLI and run it: it's what registers the app on Firebase and automatically generates the configuration file.
dart pub global activate flutterfire_cli
flutterfire configure --project=your-firebase-project-idThe command creates lib/firebase_options.dart, containing all the options needed for initialization, and configures the selected platforms (Android, iOS, web).
2. Add the dependencies
In pubspec.yaml add the three packages you need (use the latest versions from pub.dev):
dependencies:
firebase_core: ^latest
firebase_messaging: ^latest
flutter_local_notifications: ^latestThen install with flutter pub get. The flutter_local_notifications package is needed to display notifications while the app is in the foreground, as we'll see shortly.
3. Initialize Firebase
Initialization must happen before any other Firebase call, so it goes in main():
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';
// Must be a top-level function (outside any class)
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Message received while the app is in the background or closed
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
runApp(const MyApp());
}Watch out for a detail that costs people hours: the background message handler must be a top-level function annotated with @pragma('vm:entry-point'), because it runs in a separate isolate. If you put it inside a class, it won't work.
4. Configure Android and iOS
Android. FCM requires minSdkVersion 23. From Android 13 onwards you also need the runtime POST_NOTIFICATIONS permission: you'll request it together with the others in the next step.
iOS. Open the project in Xcode (ios/Runner.xcworkspace) and enable the Push Notifications and Background Modes capabilities (ticking Remote notifications). Then generate an APNs authentication key (a .p8 file) from the Apple Developer Center and upload it to Firebase, under Project settings → Cloud Messaging → Apple app configuration. Without this step, nothing will arrive on iOS.
5. Request permission and get the token
Every device has a unique registration token: it's the address you send messages to. Save it on your backend so you can target that specific device.
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
final token = await messaging.getToken();
// Send the token to your backend
}
// The token can change: catch the refresh and re-sync
messaging.onTokenRefresh.listen((newToken) {
// Update the token on the backend
});
6. Initialize the plugin and create the channel
To make notifications appear while the app is open, the flutter_local_notifications plugin needs two things: to be initialized and to have a channel to rely on. On Android the channel is mandatory from version 8 onwards — every notification must belong to a channel already registered in the system, otherwise it's simply ignored.
It's best to gather everything into a single function, so you run it just once when the app starts:
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
// The Android channel must be declared once (required from Android 8)
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'high_importance_channel',
'Important notifications',
description: 'Channel for the most important notifications',
importance: Importance.max,
);
Future<void> setupNotifications() async {
// 1. Initialize the plugin: Android icon + iOS settings
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await flutterLocalNotificationsPlugin.initialize(initSettings);
// 2. Create the channel on Android (without it, the foreground notification won't appear)
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
// 3. On iOS: show the notification even when the app is open
await FirebaseMessaging.instance
.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
);
}Once that's done, call setupNotifications() inside main():
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await setupNotifications();
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);One thing to watch with the channel: the id you assign it (high_importance_channel) must be reused exactly every time you show a notification on that channel. If you change it even slightly, Android treats it as a new channel and loses the priority and sound you set here.
7. Handle messages in the three states
This is where the real work is. Flutter handles notifications differently depending on where the app is.
App in the foreground. On Android, when the app is open, FCM doesn't show the notification automatically: you receive it through a stream and display it yourself with flutter_local_notifications. On iOS you don't need any of this, because the foreground display is already handled by setForegroundNotificationPresentationOptions.
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
// Mainly needed on Android: on iOS the foreground notification is already handled
final notification = message.notification;
if (notification != null) {
flutterLocalNotificationsPlugin.show(
notification.hashCode,
notification.title,
notification.body,
const NotificationDetails(
android: AndroidNotificationDetails(
'high_importance_channel',
'Important notifications',
importance: Importance.max,
priority: Priority.high,
),
),
);
}
});Tapping the notification. When the user taps a notification, you usually want to take them to a specific screen (the right chat, for example). There are two cases to cover:
// App opened from a fully closed state
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_handleTap(initialMessage);
}
// App in the background and brought to the foreground by the tap
FirebaseMessaging.onMessageOpenedApp.listen(_handleTap);App in the background or closed. This is handled by the _firebaseBackgroundHandler you registered in main(): when the message contains a notification part, the system shows the notification in the status bar on its own.
8. Send a test notification
The quickest way to test: go to Firebase Console → Cloud Messaging → Send test message and paste the device token.
From your own server, use FCM's HTTP v1 API. The old API with the "server key" has been retired: today you need an OAuth2 access token generated from a service account. The request looks like this:
POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send
Authorization: Bearer <ACCESS_TOKEN>
Content-Type: application/json
{
"message": {
"token": "<DEVICE_TOKEN>",
"notification": {
"title": "Hi there!",
"body": "You have a new notification"
}
}
}The access token is obtained from the service account key (the JSON file you can download from Project settings → Service accounts), typically with the help of a library like google-auth on the backend.
Common mistakes (and how to avoid them)
Three problems almost everyone hits. The duplicate notification in the foreground: it happens when you also show the local notification for messages that already contain a notification part handled by the system — only show the local one for data-only messages. No notifications on iOS: it's almost always a missing APNs key, or you're testing on the simulator. Data-only messages don't appear on Android: messages without a notification part aren't shown automatically, so you have to display them yourself in the background handler.
Frequently asked questions
Do push notifications work on the iOS simulator?
No. Push notifications don't arrive on the iOS simulator — that's an Apple restriction. To test them you need a physical iPhone with a valid APNs configuration. On Android, though, the emulator with Google Play services works just fine.
Why aren't any notifications arriving on iOS?
In the vast majority of cases it's a missing APNs key, or one that hasn't been uploaded to Firebase, or you're testing on the simulator. Check that you've generated the .p8 file, uploaded it under Cloud Messaging, and enabled the Push Notifications and Background Modes capabilities in Xcode.
How do I handle data-only messages (without a notification part)?
Messages that contain only the data field aren't shown automatically by either platform: you receive them in your handler (onMessage in the foreground, the background handler when the app is closed) and you build the notification yourself with flutter_local_notifications. They're useful when you want to decide in code whether, when, and how to show the notification.
Do notifications arrive even when the app is fully closed?
Yes, if the message contains a notification part: in that case the operating system shows it in the status bar, without your app running. When the user taps the notification, the app reopens and you retrieve the message with getInitialMessage.
In short
Setting up push notifications in Flutter with FCM takes a few precise steps: connect the app with FlutterFire, handle permissions and the token, and — the part that really makes the difference — handle the three app states correctly. Once it's set up properly, it works reliably on iOS and Android from a single codebase.
Want to add reliable push notifications to your app, maybe connected to your management system or backend? Tell us about your project: we'll help you do it the right way.