Dev Dave

Push Notifications in Flutter with FCM: Guide

Push Notifications in Flutter with FCM: Guide

By David TopoikaFlutter & backend developerPublished August 19, 2026
FlutterFirebasePush NotificationsMobile App

Push notifications are one of those features that look like a checkbox and turn into a week. The Flutter code is small; the platform details — APNs, permission prompts, Android channels, and what happens when the app is dead — are where the time goes. This is the practical setup I use to get Firebase Cloud Messaging working end to end in a Flutter app, and the gotchas worth knowing before you hit them.

In short: add firebase_messaging, request permission, grab the device token and store it server-side, then wire three listeners — foreground, background, and notification-tap — and let a data payload drive deep links. The rest is handling the platform edges cleanly.

Why FCM is still the default choice for push in Flutter apps

Firebase Cloud Messaging is the path of least resistance for Flutter push, and for good reasons. It's cross-platform out of one API — FCM talks to APNs under the hood on iOS and to its own transport on Android, so you write one Dart integration instead of two native ones. It's first-party: the FlutterFire firebase_messaging plugin is maintained alongside the rest of Firebase, so it tracks OS changes reasonably well. It's free at the volumes most apps care about, it handles device tokens and topic subscriptions for you, and it slots in next to the Firebase services many Flutter apps already use.

There are alternatives — OneSignal, or going fully native — but unless you have a specific reason, FCM is the baseline every Flutter dev should know. It's the approach behind the push notifications in MyChurch App, where getting an announcement to reliably reach a congregation was the whole point.

Setting up Firebase and registering device tokens (iOS + Android gotchas)

Add firebase_core and firebase_messaging, run flutterfire configure to generate firebase_options.dart, and initialize Firebase before you touch messaging. Then request permission and read the token:

final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(alert: true, badge: true, sound: true);
 
final token = await messaging.getToken();
if (token != null) await sendTokenToServer(token);

The gotchas live on each platform:

iOS needs real push plumbing before a token will ever arrive. Upload an APNs authentication key (.p8) to your Firebase project, enable the Push Notifications and Background Modes → Remote notifications capabilities in Xcode, and test on a physical device (simulator support is recent and finicky). Without the APNs key, getToken() silently gives you nothing.

Android since API 33 (Android 13) requires the runtime POST_NOTIFICATIONS permission — requestPermission() handles the prompt on current plugin versions, but if you skip it, notifications just never show and nothing errors. Make sure google-services.json is in place and define a default notification channel (more on that below).

Handling foreground vs. background vs. terminated-state notifications

This is the part that trips people up, because FCM behaves differently depending on app state and message type.

A notification message (one with a notification block) is drawn automatically by the OS in the system tray when your app is backgrounded or terminated. A data message (payload under data) is always delivered to your code so you control everything — but on iOS it needs content-available and has delivery limits. Most real apps send both: a notification block for the tray, plus data for routing.

Your code hooks three states:

// Background & terminated: MUST be a top-level or static function,
// marked as an entry point — it runs in its own isolate.
@pragma('vm:entry-point')
Future<void> _bgHandler(RemoteMessage message) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  // keep this light; the isolate is short-lived
}
 
FirebaseMessaging.onBackgroundMessage(_bgHandler);
FirebaseMessaging.onMessage.listen((m) { /* foreground — see below */ });
FirebaseMessaging.onMessageOpenedApp.listen(_handleTap); // tapped from background

The catch that surprises everyone: foreground messages are not shown automatically. When the app is open, onMessage fires but nothing appears on screen — you display it yourself, usually with flutter_local_notifications. On iOS you also call setForegroundNotificationPresentationOptions(alert: true, …) if you want the system banner while foregrounded.

Storing and refreshing device tokens server-side without duplicate sends

A device token is the address you send to, and getting the storage wrong is what produces the classic bug: a user gets the same notification twice (or five times). The fix is the same discipline I use for payments — let the database be the guarantee, not your app logic.

Store one row per token with a unique constraint on the token itself, linked to the user and device. That single constraint kills most duplicate sends: the same token can't be stored twice. Then keep it fresh:

  • Tokens rotate. Listen to onTokenRefresh and upsert the new one so your server never holds a stale address.
  • On logout, delete that device's token — otherwise the next user on a shared phone gets the previous user's pushes.
  • Prune dead tokens. When you send, FCM tells you which tokens are registration-token-not-registered; delete those so you stop paying to message uninstalled apps.
messaging.onTokenRefresh.listen(sendTokenToServer); // upsert on the server

A user with three devices has three tokens, and you send to all of them — that's not a duplicate, that's correct. Duplicates come from stale or doubly-stored tokens, and the unique constraint plus pruning is what prevents them.

Deep-linking a notification tap to the right screen

A notification that dumps the user on the home screen wastes the tap. Put the destination in the message's data payload — say { "route": "/events/42" } — and route on it. Two entry points matter, for two app states:

void _handleTap(RemoteMessage message) {
  final route = message.data['route'];
  if (route != null) navigatorKey.currentState?.pushNamed(route);
}
 
// App was backgrounded, then the user tapped:
FirebaseMessaging.onMessageOpenedApp.listen(_handleTap);
 
// App was fully terminated, launched by the tap — check once on startup:
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) _handleTap(initial);

Forgetting getInitialMessage() is the common miss — without it, taps that cold-start the app land nowhere. If you use go_router, resolve the route through the router instead of a raw navigator key.

Common pitfalls — iOS permission prompts, Android channels, token expiry

A quick field guide to the things that eat afternoons:

  • iOS permission timing. Ask at a moment the user understands why, not on first launch. Once a user denies, you can't re-prompt in-app — they have to enable it in Settings, and most won't.
  • Android notification channels. On Android 8+, a heads-up notification needs a channel. Create one with flutter_local_notifications and declare a default channel in the manifest, or your notifications arrive silently with no banner.
  • Token expiry. Never treat a token as permanent or cache it forever. Rely on onTokenRefresh, and handle the not-registered error server-side.
  • The background handler. It must be top-level (not a class method) and marked @pragma('vm:entry-point'), or it won't run in release builds.
  • "It works in the foreground but not when closed." Usually a missing notification block (data-only on iOS) or a background handler that isn't wired correctly.

Minimal code snippet — initializing FCM and handling a foreground message

Here's the whole thing wired together — initialization, permission, token, and the foreground listener — as a starting point you can drop in and grow:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';
 
@pragma('vm:entry-point')
Future<void> _bgHandler(RemoteMessage message) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
}
 
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(_bgHandler);
  await _setupPush();
  runApp(const MyApp());
}
 
Future<void> _setupPush() async {
  final messaging = FirebaseMessaging.instance;
 
  await messaging.requestPermission(alert: true, badge: true, sound: true);
  await messaging.setForegroundNotificationPresentationOptions(
    alert: true, badge: true, sound: true, // iOS foreground banner
  );
 
  final token = await messaging.getToken();
  if (token != null) await sendTokenToServer(token);
  messaging.onTokenRefresh.listen(sendTokenToServer);
 
  FirebaseMessaging.onMessage.listen((message) {
    final n = message.notification;
    if (n != null) {
      // show it yourself, e.g. via flutter_local_notifications
      debugPrint('Foreground: ${n.title}${n.body}');
    }
  });
}

For the full API surface — payload formats, topic messaging, and the server SDK for sending — the official Firebase Cloud Messaging docs are the reference to keep open.

Want push done right in your Flutter app?

Push is easy to make work in a demo and easy to get subtly wrong in production — duplicate sends, dead tokens, taps that go nowhere. I build and debug FCM into Flutter apps regularly, server side included. If you want it working reliably the first time, reach out, or find me on Fiverr and Upwork.

Want help with something similar?

If this article matches a problem you're facing, I can help you scope it and ship it — starting with a quick consultation.

Related Articles

OutApp: A Flutter Event Discovery App
OutApp: A Flutter Event Discovery App

March 10, 2026

OutApp: A Flutter Event Discovery App

How I built OutApp, a Flutter app for discovering, exploring, and managing events, delivered for a Fiverr client with a focus on clean, intuitive UX.

FlutterMobile AppUI/UXFreelance
Auto Cllan: A Multi-Sided Vehicle Marketplace
Auto Cllan: A Multi-Sided Vehicle Marketplace

February 15, 2026

Auto Cllan: A Multi-Sided Vehicle Marketplace

How I led development of Auto Cllan, a Flutter marketplace connecting car and bike buyers, sellers, shop owners, and workshops for buying, selling, an...

FlutterMobile AppMarketplaceREST APIs
MyChurch: A Flutter Church Management App
MyChurch: A Flutter Church Management App

January 20, 2026

MyChurch: A Flutter Church Management App

Building MyChurch for Bitwise Solutions — a Flutter app that helps churches manage events and announcements and keep members connected, with role-base...

FlutterMobile AppFirebasePush Notifications

Get In Touch

Let's Work Together

I'm currently open to new opportunities and collaborations. Whether you have a project in mind or just want to say hello, feel free to reach out!

Dev Dave

© 2026 Topoika. All rights reserved.

Privacy

This site uses cookies to understand how visitors use it and to improve the experience. You can accept or reject analytics cookies at any time — read the privacy note.