Mobile Development
FlutterPush NotificationsFCMAPNsFirebaseMobileDeep LinkingReliabilityGuide

Flutter Push Notifications in Production: FCM + APNs, Deep Links, and Reliability (2026 Guide)

AO
Adrijan Omićević
·14 min read

# 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#

RequirementVersionNotes
Flutter3.19+Use latest stable if possible
Dart3+Comes with Flutter
Firebase projectFCM enabled
iOSXcode 15+Apple Developer account required for APNs
AndroidminSdk 21+Recommended for modern behavior
PackagesLatestfirebase_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:

ComponentResponsibilityProduction concern
Flutter appRequest permission, manage tokens, handle taps, route deep linksState handling, token refresh, UX
FCMDevice registration, topic messaging, delivery bridge to APNsAPNs key setup, message format
APNsiOS deliveryEntitlements, environment, headers, push type
BackendUser targeting, segments, idempotency, audit logsSecurity, 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#

  1. 1
    Create an Android app in Firebase with your exact applicationId.
  2. 2
    Download google-services.json into android/app.
  3. 3
    Apply 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#

  1. 1
    Create an iOS app in Firebase with the exact bundle ID.
  2. 2
    Download GoogleService-Info.plist into ios/Runner.
  3. 3
    In Apple Developer:
    • enable Push Notifications capability for the App ID
    • generate an APNs Auth Key (recommended)
  4. 4
    In 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:

Bash
flutter pub add firebase_core firebase_messaging

Initialize Firebase and configure background handling.

Dart
// 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)#

Dart
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#

EventApp actionBackend action
App start after loginfetch tokenupsert token under user ID
Token refreshsend new tokenmark old token inactive
Logoutdelete token locallyremove association with user
Uninstallno callbackdetect via send failures and prune

Implement:

  • getToken() at startup
  • onTokenRefresh listener
  • server-side upsert keyed by userId + deviceId
Dart
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#

FieldTypeWhy it matters
idUUIDInternal reference
user_idstringTargeting and privacy
device_idstringMulti-device support
fcm_tokenstringDelivery address
platformenumiOS or Android behavior differs
app_versionstringDebugging rollouts
enabledbooleanOpt-out handling
last_seen_attimestampCleanup 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:

Dart
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
StrategyComplexityTargeting precisionOperational risk
Topics onlyLowMediumMedium, topics can grow messy
Backend segments onlyHighHighHigh, fanout costs and bugs
HybridMediumHighLow 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.

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:

KeyExamplePurpose
typeorder_updateRouting and analytics
deep_linkmyapp://orders/123Navigation target
id123Entity lookup
campaign_idspring_2026_01Attribution
dedupe_iduser123-ord123-v2Idempotency

Avoid encoding complex JSON strings. Keep payload small and predictable.

Handling Foreground, Background, and Terminated#

You must handle three flows:

  1. 1
    Foreground: message arrives while app is open
  2. 2
    Background: user taps notification
  3. 3
    Terminated: app starts from tap

Core listeners:

Dart
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.

Dart
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.

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#

AreaWhat to doWhy it helps
Token freshnessprune stale tokens, track last seenreduces invalid sends and noise
Permission UXdelay permission until value is clearincreases opt-in rate
Quiet hoursrespect user local timereduces opt-outs
Frequency capsenforce per user and per campaignreduces fatigue
Payload sizekeep data smallreduces failures and parsing bugs
Monitoringalert on send failures and drop in deliverycatches 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#

  1. 1
    Confirm Push Notifications capability is enabled for the App ID.
  2. 2
    Confirm correct provisioning profile used for the build.
  3. 3
    Confirm APNs Auth Key uploaded to Firebase and Team ID matches.
  4. 4
    Confirm device has permission granted in iOS Settings.
  5. 5
    Confirm setForegroundNotificationPresentationOptions is set for foreground display.
  6. 6
    Confirm bundle ID matches Firebase app registration exactly.
  7. 7
    Confirm you are not testing on a simulator for real APNs behavior.
  8. 8
    Confirm 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#

  1. 1
    Android 13 and newer: confirm POST_NOTIFICATIONS permission granted.
  2. 2
    Confirm app has a default notification channel and importance is high.
  3. 3
    Confirm you are not suppressing notifications in foreground without local notifications.
  4. 4
    Confirm device battery optimization is not restricting the app severely.
  5. 5
    Confirm payload includes notification fields if you rely on system UI.

Token and Backend Checklist: “It Works for Some Users”#

  1. 1
    Verify the backend stores multiple tokens per user for multi-device.
  2. 2
    Verify logout removes token association.
  3. 3
    Verify token refresh updates the backend.
  4. 4
    Verify you are not overwriting tokens across users.
  5. 5
    Verify invalid tokens are pruned after FCM responses.
  6. 6
    Verify segmentation filters are correct and audited.
  1. 1
    Verify getInitialMessage() is handled for terminated state.
  2. 2
    Verify onMessageOpenedApp is handled for background state.
  3. 3
    Verify routing waits until session and router are ready.
  4. 4
    Verify deep links are validated and mapped to existing routes.
  5. 5
    Verify tap handling is idempotent and deduped.

# Production Message Formats (Examples)#

Example: Data-Only for Custom Handling#

JSON
{
  "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#

JSON
{
  "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: onMessage for foreground, onMessageOpenedApp for background taps, and getInitialMessage() for terminated launches.
  • Design payloads for routing: small, stable keys like type, deep_link, campaign_id, and dedupe_id to 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

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.