A notification may arrive while the app is open, in the background, or terminated. The operating system may display it, the app may display it, or both may try.
That is why notification bugs often look inconsistent: duplicate banners in one state, no navigation in another, and a stale token on one device.
I treat notifications as a pipeline:
permission → token registration → delivery → presentation → tap → destination
Each stage has its own failure behavior.
Keep device identity separate from the push token#
An FCM token can rotate. It is not a permanent device ID.
Register both:
class NotificationDevice {
const NotificationDevice({
required this.deviceId,
required this.pushToken,
required this.platform,
});
final String deviceId;
final String pushToken;
final String platform;
}
Generate a stable device UUID once and store it securely. Update its push token after login, permission changes, app resume when appropriate, and token refresh.
FirebaseMessaging.instance.onTokenRefresh.listen(
(token) => registration.register(pushToken: token),
);
Deduplicate concurrent registrations so several startup triggers do not issue identical requests.
Understand the three app states#
Firebase documents distinct message handling for Flutter in its receive messages guide.
Foreground#
Listen to the message stream. The operating system may not show the same visible notification behavior as background delivery, so the app can update content or display a local notification.
FirebaseMessaging.onMessage.listen((message) {
notificationCoordinator.handleForeground(message);
});
Background#
A top-level background handler performs limited work. It must not depend on a widget tree or arbitrary app state.
Terminated#
Read the initial message after startup and defer navigation until authentication and the router are ready.
Use one parser for foreground taps, background taps, and initial messages.
Avoid duplicate presentation#
Notification payloads and data-only payloads behave differently across platforms and app states. Decide whether the operating system or your code displays the message.
If a foreground message should look like a system notification, use flutter_local_notifications. Do not also display a second in-app banner for the same message.
Give each message a stable ID when possible and keep a short deduplication window.
Turn payloads into typed actions#
sealed class NotificationAction {
const NotificationAction();
}
final class OpenCourse extends NotificationAction {
const OpenCourse(this.courseId);
final String courseId;
}
NotificationAction? parseAction(Map<String, dynamic> data) {
return switch (data['type']) {
'course' when data['courseId'] is String =>
OpenCourse(data['courseId'] as String),
_ => null,
};
}
The router receives OpenCourse rather than raw FCM data. Invalid payloads can be logged and ignored safely.
Schedule in the user's timezone#
Local reminders need timezone-aware scheduling. Store the intended local time and timezone, then calculate the zoned occurrence.
Do not schedule “9 a.m.” by adding a fixed duration to UTC. Daylight-saving changes and travel make that incorrect.
When timezone or reminder settings change, cancel or replace the previous schedule using stable notification IDs.
Ask for permission in context#
Permission prompts work better after explaining the benefit: course reminders, live-session updates, or download completion. Track denied, provisional, and authorized states according to the platform.
The app should remain useful when permission is denied. A server registration attempt without a usable push token should not block login.
Test the matrix#
Test on real Android and iOS devices:
- foreground receipt;
- background receipt;
- terminated launch;
- notification and data-only payloads;
- token rotation;
- logout and another user login;
- denied permissions;
- duplicate delivery;
- invalid or deleted destination;
- scheduled notification across timezone changes.
A notification feature is complete only when receipt, presentation, and navigation agree in every app state.