Push Notifications
4 min read
Platform Integration
| Payload \ App state | Foreground | Background | Terminated |
|---|---|---|---|
notification: only | onMessage (no system UI; you must show local notification) | System banner; tap → onMessageOpenedApp | System banner; tap launches app → getInitialMessage |
data: only (silent) | onMessage (no banner unless you show one) | onBackgroundMessage (limited time) | OS may run onBackgroundMessage with limits |
| Both | Both behaviours combine (system banner + your handler) | System banner with the notification payload; data accessible on tap | Same |
The two payload types matter: if you want full control over how it looks (custom layout, image, action buttons), use data-only + flutter_local_notifications. If you're OK with system defaults, use notification: payload.
Code in action
// MUST be top-level + AOT-safe
@pragma('vm:entry-point')
Future<void> onBackgroundMessage(RemoteMessage msg) async {
await Firebase.initializeApp();
await DataSync.handle(msg.data); // limited window — be fast
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
runApp(const MyApp());
}
class NotificationService {
final _fcm = FirebaseMessaging.instance;
Future<void> init() async {
// 1. Permission (iOS + Android 13+)
final settings = await _fcm.requestPermission();
if (settings.authorizationStatus != AuthorizationStatus.authorized) return;
// 2. Token (send to your server, refresh on rotation)
final token = await _fcm.getToken();
await api.registerDevice(token);
_fcm.onTokenRefresh.listen(api.registerDevice);
// 3. Foreground messages — show your own UI
FirebaseMessaging.onMessage.listen((msg) {
LocalNotifications.show(
title: msg.notification?.title ?? '',
body: msg.notification?.body ?? '',
payload: msg.data,
);
});
// 4. Tapped in background → app was alive in background
FirebaseMessaging.onMessageOpenedApp.listen(_handleTap);
// 5. Tapped while terminated → app was relaunched
final initial = await _fcm.getInitialMessage();
if (initial != null) _handleTap(initial);
}
void _handleTap(RemoteMessage msg) {
final route = msg.data['route'] as String?;
if (route != null) router.go(route); // deep link
}
}
Best practices
| Practice | Why |
|---|---|
| Request permission at the right moment, not on launch | Higher grant rates; iOS HIG guidance |
| Show foreground notifications via local notifications | FCM doesn't show banner in foreground by default |
Deep link from notification → use data payload with route info | Land users on the right screen |
| Re-send token on refresh and on logout/login | Token can change; ensure server has the right one |
| Use topics for broadcast, individual tokens for personal | Topics scale; tokens target |
Server-side: send apns-priority: 10, priority: high for user-facing | Lower priority = OS may delay or batch |
| iOS: configure background modes ("Remote notifications") | Without it, silent push won't wake the app |
| Use notification channels on Android 8+ for categorisation | User can mute categories independently |
Common mistakes to avoid
❌ Expecting the system to show a banner in foreground
It doesn't (by default). Show your own via flutter_local_notifications.
❌ Calling Navigator from a background callback
No tree exists. Persist intent (shared prefs / pending nav state) and
navigate when the app comes to foreground.
❌ Treating delivery as guaranteed
Both FCM and APNs are best-effort. For mission-critical events,
pair push with a server-side fallback (sync on open).
❌ Hard-coding token expectations
Tokens rotate (reinstall, restore from backup, language change).
Listen to onTokenRefresh, always.
❌ Forgetting onBackgroundMessage requires AOT entry point
@pragma('vm:entry-point') — without it, release builds fail silently.
❌ Sending PII in the visible notification body
"John Smith paid you $50" shows on the lock screen.
Use less specific text + open the app for details.
❌ Not testing terminated-state taps
getInitialMessage is easy to forget. Test by force-quitting the app
and tapping a fresh notification.
Interview follow-ups
-
What's the difference between
notification:anddata:payloads?notification:is a structured payload (title, body, icon) that the system displays automatically.data:is opaque key/value pairs your app processes. Use data-only for full control (custom UI, silent sync, deep link intents). Most production apps send both — system handles display, data handles routing. -
How do you deep-link from a notification tap? Include a
route(or similar) in thedata:payload. InonMessageOpenedAppandgetInitialMessage, parse it and callrouter.go(route). For terminated state, you may need to wait for app init before navigating — show a splash, then deep link. -
What's the difference between FCM and APNs? FCM is Google's cross-platform service; APNs is Apple's. FCM on iOS delegates to APNs under the hood — you still configure APNs credentials in Firebase. Same on Android except FCM is native. From Flutter's perspective,
firebase_messagingabstracts both. -
How do you handle notification preferences per user? Server-side: store preference flags, only push when the user has opted in. Client-side: use FCM topics (subscribe/unsubscribe based on prefs) plus Android notification channels (let the user mute categories at the OS level). Always provide an in-app settings screen.
How helpful was this content?
Please sign in to rate this article.