# What You’ll Build (and Why Production Is Different)#
This guide covers Flutter push notifications FCM APNs end-to-end, with a production mindset: not just “it shows a banner”, but delivery, routing, token lifecycle, and observability.
In production, push systems fail for boring reasons: expired tokens, misconfigured APNs keys, wrong environment, missing background handlers, or brittle deep-link routing. The difference between a push feature and a push platform is reliability.
You’ll implement:
- FCM + APNs setup for Android and iOS
- A token lifecycle strategy that survives app reinstalls, logouts, and device changes
- Topics and segmentation patterns that scale beyond “send to all”
- Deep linking from notifications across foreground, background, and terminated states
- Troubleshooting checklists and deliverability tips you can hand to your team
You can pair this with our Firebase foundation guide: Flutter Firebase tutorial.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Flutter | 3.19+ | Use latest stable if possible |
| Dart | 3+ | Comes with Flutter |
| Firebase project | — | FCM enabled |
| iOS | Xcode 15+ | Apple Developer account required for APNs |
| Android | minSdk 21+ | Recommended for modern behavior |
| Packages | Latest | firebase_core, firebase_messaging, optional flutter_local_notifications |
ℹ️ Note: On iOS, notifications are ultimately delivered by APNs. FCM acts as your provider and mapping layer, but your iOS app must be correctly entitled and signed, or delivery will silently fail.
# Architecture: FCM, APNs, and Your Backend#
A reliable production setup usually looks like this:
| Component | Responsibility | Production concern |
|---|---|---|
| Flutter app | Request permission, manage tokens, handle taps, route deep links | State handling, token refresh, UX |
| FCM | Device registration, topic messaging, delivery bridge to APNs | APNs key setup, message format |
| APNs | iOS delivery | Entitlements, environment, headers, push type |
| Backend | User targeting, segments, idempotency, audit logs | Security, retries, analytics |
Recommendation: Send from Backend, Not from the App#
Sending pushes directly from the app is hard to secure and impossible to audit properly. In production, treat push as a backend capability with:
- authenticated send APIs
- rate limits
- message templates
- delivery metrics per campaign
For security hardening, align your push endpoints with your broader checklist: Web application security checklist.
# Step 1: Configure Firebase and FCM#
Android: Add Firebase and Register Your App#
- 1Create an Android app in Firebase with your exact
applicationId. - 2Download
google-services.jsonintoandroid/app. - 3Apply the Google services plugin and ensure dependencies match your Gradle version.
In Android 13 and newer, you must request notification permission at runtime (Flutter plugin helps, but you still need to call it explicitly).
iOS: Add Firebase and APNs Credentials#
- 1Create an iOS app in Firebase with the exact bundle ID.
- 2Download
GoogleService-Info.plistintoios/Runner. - 3In Apple Developer:
- enable Push Notifications capability for the App ID
- generate an APNs Auth Key (recommended)
- 4In Firebase console:
- upload APNs Auth Key
.p8 - set Key ID and Team ID
Use APNs Auth Key for long-lived reliability. Certificates expire and cause sudden outages.
⚠️ Warning: One of the most common production failures is using the wrong bundle ID or provisioning profile. If the signing identity does not match the entitlements, APNs registration may succeed locally but delivery will fail in production.
# Step 2: Add Packages and Initialize Messaging#
Add packages:
flutter pub add firebase_core firebase_messagingInitialize Firebase and configure background handling.
// main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// Keep this minimal: log, enqueue work, update local storage.
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}Request Permission and Configure Foreground Presentation (iOS)#
Future<void> setupPushPermissions() async {
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false,
);
// iOS: show notifications while app is in foreground
await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
);
// You can log settings.authorizationStatus for analytics.
}Production note: permission opt-in rates vary widely by category. Many consumer apps see opt-in around 50 to 80 percent with a strong value prompt, while cold prompts can be significantly lower. Use a pre-permission screen if you depend on notifications for core workflows.
# Step 3: Token Lifecycle That Doesn’t Break Campaigns#
Token handling is where most systems degrade over time.
What Can Change a Token#
FCM registration tokens are not stable identifiers. They can rotate due to:
- app reinstall or data clear
- OS restore or device change
- app update and internal rotation
- user toggling notification settings
- iOS APNs token changes that lead to new FCM token mapping
You must treat tokens as mutable and keep them in sync with your backend.
Minimal Production Token Strategy#
| Event | App action | Backend action |
|---|---|---|
| App start after login | fetch token | upsert token under user ID |
| Token refresh | send new token | mark old token inactive |
| Logout | delete token locally | remove association with user |
| Uninstall | no callback | detect via send failures and prune |
Implement:
getToken()at startuponTokenRefreshlistener- server-side upsert keyed by
userId + deviceId
Future<void> syncTokenWithBackend(String userId) async {
final messaging = FirebaseMessaging.instance;
final token = await messaging.getToken();
if (token != null) {
await sendTokenToBackend(userId: userId, fcmToken: token);
}
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
await sendTokenToBackend(userId: userId, fcmToken: newToken);
});
}Keep the backend endpoint idempotent. If your mobile app sends the same token multiple times, it should not create duplicates.
💡 Tip: Store tokens with metadata: platform, app version, locale, timezone, and last-seen timestamp. This enables cleanup jobs and smarter segmentation without extra client calls.
Backend Data Model for Tokens#
| Field | Type | Why it matters |
|---|---|---|
| id | UUID | Internal reference |
| user_id | string | Targeting and privacy |
| device_id | string | Multi-device support |
| fcm_token | string | Delivery address |
| platform | enum | iOS or Android behavior differs |
| app_version | string | Debugging rollouts |
| enabled | boolean | Opt-out handling |
| last_seen_at | timestamp | Cleanup and freshness |
Token Cleanup and Pruning#
FCM send responses will tell you when a token is invalid. Make a nightly job that:
- disables tokens that hard-fail
- removes tokens not seen in 60 to 180 days (your policy)
- keeps audit logs for compliance
If you do not prune, you’ll pay with lower deliverability and noisier metrics.
# Step 4: Topics and Segments That Scale#
A topic-only strategy rarely survives beyond the first marketing campaign. A segment-only strategy can overload your backend with fanout.
Use a hybrid approach:
When Topics Make Sense#
Topics are best for:
- broad interest groups like
sports,promotions,product_updates - global operational pushes like
status_incidents - extremely simple subscriptions you control in-app
Topic subscriptions in app:
Future<void> subscribeToTopics() async {
await FirebaseMessaging.instance.subscribeToTopic('product_updates');
await FirebaseMessaging.instance.subscribeToTopic('promotions');
}When Backend Segmentation Is Better#
Use backend segmentation when you need:
- multi-attribute targeting like country + plan + activity window
- frequency caps like max 2 pushes per day
- A B tests and holdout groups
- compliance rules, for example marketing consent by region
| Strategy | Complexity | Targeting precision | Operational risk |
|---|---|---|---|
| Topics only | Low | Medium | Medium, topics can grow messy |
| Backend segments only | High | High | High, fanout costs and bugs |
| Hybrid | Medium | High | Low to medium with discipline |
Practical Segment Examples#
- “Active in last 7 days and churn risk high”
- “Paid users on version less than 3.4.0”
- “Croatia locale and opted in to marketing”
In production, the biggest win is usually frequency capping. It reduces opt-outs and improves trust more than any copy change.
# Step 5: Deep Links from Notifications (Without Broken Routing)#
A notification is only valuable if it brings the user to the right screen.
Payload Design: Data First#
Prefer data payload fields you control, then map them to routes:
| Key | Example | Purpose |
|---|---|---|
type | order_update | Routing and analytics |
deep_link | myapp://orders/123 | Navigation target |
id | 123 | Entity lookup |
campaign_id | spring_2026_01 | Attribution |
dedupe_id | user123-ord123-v2 | Idempotency |
Avoid encoding complex JSON strings. Keep payload small and predictable.
Handling Foreground, Background, and Terminated#
You must handle three flows:
- 1Foreground: message arrives while app is open
- 2Background: user taps notification
- 3Terminated: app starts from tap
Core listeners:
void setupMessageHandlers() {
FirebaseMessaging.onMessage.listen((message) {
// Foreground: you may show an in-app banner or local notification.
handleMessage(message, openedFromTap: false);
});
FirebaseMessaging.onMessageOpenedApp.listen((message) {
// Background: user tapped notification.
handleMessage(message, openedFromTap: true);
});
}
Future<void> handleInitialMessage() async {
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
handleMessage(initial, openedFromTap: true);
}
}Routing Safely After App Startup#
Deep-link navigation often fails because the app tries to navigate before:
- user session is loaded
- router is ready
- required data is hydrated
Use a queue: store the deep link, then route once your app is ready.
String? pendingDeepLink;
void handleMessage(RemoteMessage message, {required bool openedFromTap}) {
final deepLink = message.data['deep_link'];
if (deepLink is String && deepLink.isNotEmpty) {
pendingDeepLink = deepLink;
}
}
void onAppReadyNavigate() {
final link = pendingDeepLink;
pendingDeepLink = null;
if (link == null) return;
// Parse and route using your navigation solution.
// Example: go_router or Navigator.
}🎯 Key Takeaway: Treat notification navigation like any other deep link: validate inputs, delay routing until the app is ready, and make it idempotent.
Deep Link Security#
Never trust notification data blindly. Attackers can replay or spoof intents in some contexts. Validate:
- allowed paths
- required parameters
- user authorization to view the entity
This aligns with the same principle as web: validate on the server and on the client. Use our security checklist mindset: Web application security checklist.
# Step 6: Background Processing and Reliability Patterns#
Background Handler Limits#
The background handler is not for heavy work. Keep it to:
- logging
- writing to local storage
- scheduling a local task
- syncing a small payload
If you need guaranteed background execution, design for eventual consistency: update server state and let the app fetch updates on next open.
Use Local Notifications for Foreground UX Consistency#
On Android and iOS, foreground pushes may not display system UI unless you configure it. Many teams use flutter_local_notifications to display a consistent banner when onMessage fires.
If you do this, keep two rules:
- never double-notify, only show local notification when the remote notification does not already display
- reuse the same payload keys so tap handling remains consistent
Idempotency and Deduplication#
Mobile networks are unreliable. Users can tap twice. Delivery can be retried. Your app should treat notification-driven actions as idempotent.
Use a dedupe_id and store recently processed IDs for 24 hours:
- if already processed, ignore
- if not, process and store
This reduces duplicate navigation and repeated dialogs.
# Deliverability and Reliability: What Actually Moves the Needle#
Reliability is mostly operational discipline, not code.
Practical Deliverability Checklist#
| Area | What to do | Why it helps |
|---|---|---|
| Token freshness | prune stale tokens, track last seen | reduces invalid sends and noise |
| Permission UX | delay permission until value is clear | increases opt-in rate |
| Quiet hours | respect user local time | reduces opt-outs |
| Frequency caps | enforce per user and per campaign | reduces fatigue |
| Payload size | keep data small | reduces failures and parsing bugs |
| Monitoring | alert on send failures and drop in delivery | catches outages fast |
Measure What Matters#
At minimum, track:
- sends attempted
- accepted by FCM
- invalid token rate
- opens from push
- opt-out rate
- time to open
A “good” invalid token rate depends on your churn and reinstall profile, but if it trends upward steadily, your token lifecycle is broken.
For performance when the app opens from a push, make sure the target screen stays fast. If a deep link lands on a heavy page, users bounce. Review performance basics here: Flutter performance optimization for 60 FPS.
# Troubleshooting Checklists (Copy-Paste for Your Runbooks)#
iOS Checklist: Push Not Delivered#
- 1Confirm Push Notifications capability is enabled for the App ID.
- 2Confirm correct provisioning profile used for the build.
- 3Confirm APNs Auth Key uploaded to Firebase and Team ID matches.
- 4Confirm device has permission granted in iOS Settings.
- 5Confirm
setForegroundNotificationPresentationOptionsis set for foreground display. - 6Confirm bundle ID matches Firebase app registration exactly.
- 7Confirm you are not testing on a simulator for real APNs behavior.
- 8Confirm message includes correct fields for your use case:
- use data payload for routing
- include notification payload if you want system UI without local notifications
Android Checklist: Notification Not Showing#
- 1Android 13 and newer: confirm POST_NOTIFICATIONS permission granted.
- 2Confirm app has a default notification channel and importance is high.
- 3Confirm you are not suppressing notifications in foreground without local notifications.
- 4Confirm device battery optimization is not restricting the app severely.
- 5Confirm payload includes
notificationfields if you rely on system UI.
Token and Backend Checklist: “It Works for Some Users”#
- 1Verify the backend stores multiple tokens per user for multi-device.
- 2Verify logout removes token association.
- 3Verify token refresh updates the backend.
- 4Verify you are not overwriting tokens across users.
- 5Verify invalid tokens are pruned after FCM responses.
- 6Verify segmentation filters are correct and audited.
Deep Link Checklist: Opens App but Not the Right Screen#
- 1Verify
getInitialMessage()is handled for terminated state. - 2Verify
onMessageOpenedAppis handled for background state. - 3Verify routing waits until session and router are ready.
- 4Verify deep links are validated and mapped to existing routes.
- 5Verify tap handling is idempotent and deduped.
# Production Message Formats (Examples)#
Example: Data-Only for Custom Handling#
{
"message": {
"token": "{{fcm_token}}",
"data": {
"type": "order_update",
"deep_link": "myapp://orders/123",
"campaign_id": "ops_2026_04",
"dedupe_id": "user42-order123-v1"
}
}
}Example: Notification + Data for System UI plus Routing#
{
"message": {
"token": "{{fcm_token}}",
"notification": {
"title": "Order shipped",
"body": "Tracking is available now."
},
"data": {
"type": "order_update",
"deep_link": "myapp://orders/123"
}
}
}Keep server-side template variables like {{fcm_token}} explicit, and validate all data before sending.
# Key Takeaways#
- Implement a real token lifecycle: upsert on login, listen to refresh, disassociate on logout, and prune invalid tokens server-side.
- Use a hybrid targeting strategy: topics for broad interests, backend segments for precise targeting, frequency caps, and compliance.
- Handle all app states:
onMessagefor foreground,onMessageOpenedAppfor background taps, andgetInitialMessage()for terminated launches. - Design payloads for routing: small, stable keys like
type,deep_link,campaign_id, anddedupe_idto support idempotency and analytics. - Treat reliability as operations: monitor invalid token rate, delivery drops, and push-open latency, and keep a runbook with iOS and Android checklists.
# Conclusion#
Flutter push notifications are straightforward to demo and surprisingly easy to break in production. If you build token lifecycle discipline, predictable payloads, deep-link routing across all states, and basic deliverability monitoring, you get a system that scales with your user base instead of degrading over time.
If you want Samioda to review your current setup or implement a production-grade push pipeline with segmentation and deep links, contact us and we’ll help you ship notifications that users actually trust and open.
FAQ
More in Mobile Development
All →Flutter Offline-First Apps: Local Storage, Sync Strategies, and Conflict Resolution
Build reliable offline-first Flutter apps with robust local persistence, background sync, and conflict resolution. Includes a reference architecture and a practical testing checklist for flaky networks.
Flutter Performance Optimization: How We Keep Apps at 60fps (Profiling + Fixes)
A repeatable Flutter performance optimization workflow using DevTools to diagnose jank, then apply targeted fixes: rebuild reduction, rendering improvements, image pipelines, and isolates — with budgets and checklists.
Flutter App Architecture That Scales: Clean Architecture vs Feature-First (With Real Folder Structures)
A practical guide to Flutter app architecture in 2026: compare Clean Architecture and Feature-First, see real folder structures, dependency boundaries, and testing strategies, and choose the right approach for your team and release cadence.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Flutter + Firebase: Complete Tutorial for 2026 (Auth, Firestore, Functions, Deploy)
A step-by-step flutter firebase tutorial for 2026: set up Firebase, add authentication, build Firestore CRUD, write Cloud Functions, and deploy a production-ready app.
Flutter State Management in 2026: Riverpod vs Bloc vs Provider
A practical comparison of Riverpod, Bloc, and Provider for flutter state management 2026—performance, DX, testing, architecture, and when to choose each.
Flutter Offline-First Apps: Local Storage, Sync Strategies, and Conflict Resolution
Build reliable offline-first Flutter apps with robust local persistence, background sync, and conflict resolution. Includes a reference architecture and a practical testing checklist for flaky networks.