Skip to content
Blog

Push Notifications Done Right: FCM and APNs

A practical senior-dev guide to reliable push notifications with Firebase Cloud Messaging and APNs, covering .p8 key setup, Flutter client integration, message types, sending from Cloud Functions, and the delivery pitfalls that waste days.

Published on August 12, 2026

AI Assistant

Push notifications look deceptively simple: send a message, and a banner pops up seconds later on a device you’ve never touched. The reality is a chain of four independent systems — your server, Firebase Cloud Messaging (FCM), Apple Push Notification service (APNs), and the OS itself — and every link can silently fail. After debugging the classic “works on Android, never seen on iOS” mystery a few times, I realized the problem is almost always setup and message-type confusion, never the SDK. This guide walks through push notifications done right: how the delivery chain actually works, how to configure the APNs key (.p8) correctly, how to build the Flutter client, and how to send messages from Cloud Functions. Prerequisites: a Flutter app with Firebase installed, an Apple Developer account, and a physical iOS device (APNs ignores simulators).

How a push actually travels

Before touching code, internalize this chain:

Your Server / Cloud Function
        |  FCM HTTP v1 API (OAuth2)
Firebase Cloud Messaging (FCM)
        |  APNs (HTTP/2, token-based auth)
Apple Push Notification service (APNs)
        |  aps-environment entitlement check
        |  device push token lookup
iOS / Android OS

The flow starts when the app registers: the OS hands the app an APNs device token, the FCM SDK swaps it for an FCM registration token, and your server stores that token. To send, your server posts to FCM, FCM routes to APNs (or Android transport), and APNs delivers only to devices whose token matches your app’s aps-environment entitlement. Any break in this chain — a stale token, a wrong environment, a missing entitlement — and the notification vanishes with no error on the client.

Setting up the APNs key (.p8) and Firebase

Token-based auth is the modern way to talk to APNs. Create one APNs Auth Key in the Apple Developer portal: open Keys, add a key, enable Apple Push Notification service (APNs), and download the .p8. Crucially, this is a one-time download — Apple never shows it again, so store it somewhere safe along with its Key ID and your Team ID from the Membership page.

Then wire it to Firebase:

  1. In the Firebase console, open Project settings > Cloud Messaging.
  2. Under the iOS app, click Upload next to APNs authentication key.
  3. Select the .p8, enter the Key ID and Team ID, and save.

On the Xcode side, add the Push Notifications capability, enable Background Modes > Remote notifications, and confirm aps-environment appears in your entitlements. For Android, nothing is needed in the console beyond registering the app and adding google-services.json.

Flutter client setup

Install the plugin and add your Firebase config (or firebase_options.dart via flutterfire):

flutter pub add firebase_messaging

Initialize messaging and request permission early but politely. iOS shows a system dialog — the prompt itself can tank conversions, so many teams show a pre-prompt first:

Future<void> initMessaging() async {
  final messaging = FirebaseMessaging.instance;

  // Provisional means "quietly send quiet notifications" on iOS 12+.
  NotificationSettings settings = await messaging.requestPermission(
    alert: true, badge: true, sound: true, provisional: true,
  );
  if (settings.authorizationStatus == AuthorizationStatus.denied) return;

  // Stall until the APNs token exists on Apple platforms.
  final apnsToken = await messaging.getAPNSToken();
  final token = await messaging.getToken();
  print('FCM token: $token');

  messaging.onTokenRefresh.listen((fresh) {
    // Upload fresh token to your backend; old one is dead.
  });
}

Ensure this runs in main() before you probe state so the APNs-to-FCM mapping is in place.

Receiving foreground, background, and terminated messages

The three device states each need a different handler. The firebase_messaging package models this cleanly:

// Foreground: no system banner by default. You decide what to show.
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  FlutterLocalNotificationsPlugin? local = ...; // your local handler
  local?.show(...); // render a heads-up notification yourself
});

// Background: top-level handler runs in its own isolate. No UI allowed.
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);

@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
  // Update local storage, call APIs, but do not touch widgets.
}

// Terminated: the message that launched the app lives here.
final initial = await FirebaseMessaging.instance.getInitialMessage();
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
  // Navigate using message.data['route'] etc.
});

Reminder per the receive docs: if the user swipes the app away on iOS, background delivery stops until the app is reopened. That is expected iOS behavior, not a bug.

Notification vs data messages

Notification messages carry a notification block FCM renders for you in the tray when the app is backgrounded; when foregrounded, only onMessage fires. Data messages carry only data key-value pairs and are always delivered to the handler regardless of state. The sweet spot is a notification message with a small data payload to route on tap:

// Cloud Function payload
final message = {
  'token': recipientToken,
  'notification': {'title': 'New message', 'body': msg.text},
  'data': {'conversationId': conversationId, 'type': 'chat'},
};

Rule of thumb: use notification for user-visible alerts, data for background work, and keep the total payload under the 4096-byte FCM limit.

Sending from Cloud Functions

The Admin SDK makes sending trivial — and avoids exposing your FCM credentials to the app. A callable function that fans out to saved tokens: see the Admin SDK guide.

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

exports.notifyFollowers = functions.https.onCall(async (data) => {
  const {recipients, title, body} = data; // recipients: string[]
  const messages = recipients.map((token) => ({
    token,
    notification: {title, body},
    data: {screen: 'feed', ts: String(Date.now())},
  }));
  // sendEach handles up to 500 messages, partial failures included.
  const response = await admin.messaging().sendEach(messages);
  return {success: response.successCount, failed: response.failureCount};
});

Call it from Flutter with FirebaseFunctions.instance.httpsCallable('notifyFollowers'), never with a raw FCM key.

Direct HTTP v1 API

When you cannot use the Admin SDK (non-Node server, custom transport), hit the HTTP v1 endpoint directly. It needs a short-lived OAuth2 access token with the firebase.messaging scope:

curl -X POST -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"message":{"token":"<REGISTRATION_TOKEN>","notification":{"title":"Hi","body":"Hello from v1"}}}' \
  "https://fcm.googleapis.com/v1/projects/<PROJECT_ID>/messages:send"

Unlike the deprecated fcm/send endpoint, v1 supports platform-specific overrides (Android, APNs config) and proper OAuth2 auth.

Token management and delivery pitfalls

Tokens rotate: on reinstall, app downgrade, token refresh, or Android app data clear. onTokenRefresh and messaging:didReceiveRegistrationToken are where you resync. On send, detect UNREGISTERED / registration-token-not-registered errors and prune the token from your database immediately. The classic iOS failures:

  • Build signed for development but key uploaded as production (or vice versa). Development tokens only work in the sandbox environment; production tokens only in production. The .p8 you upload covers both, but every APNs key/relationship must match.
  • Missing Push Notifications capability or aps-environment missing from entitlements — TestFlight rejects the build outright.
  • Foreground notifications silent because you never implemented onMessage rendering.
  • Badge not updating — set aps.badge explicitly in the APNs payload; FCM alone does not increment it.
  • Notification extensions — images on iOS require a Notification Service Extension; App Groups are needed for shared state between the extension and the main app.
  • Stale entitlements when switching between debug and release — tokens must be sent from the same environment they were minted in.

Putting it all together

Tie the pieces into a small production-shaped data flow: the app stores its FCM token against a users/{uid} document in Firestore, a Firestore trigger sends on new documents, and a callable function sends direct to a token list:

// triggers when a user finishes onboarding
exports.saveToken = functions.https.onCall(async (data) => {
  const {uid, token} = data;
  await admin.firestore().collection('users').doc(uid).set(
    {fcmToken: token, updatedAt: admin.firestore.FieldValue.serverTimestamp()},
    {merge: true},
  );
  return {saved: true};
});

// react to a Firestore write: notify the document owner
exports.onNewSignal = functions.firestore
  .document('signals/{signalId}')
  .onCreate(async (snap) => {
    const signal = snap.data();
    if (!signal.receiverUid) return null;
    const receiver = await admin.firestore()
      .collection('users').doc(signal.receiverUid).get();
    const token = receiver.data()?.fcmToken;
    if (!token) return null;
    try {
      await admin.messaging().send({
        token,
        notification: {title: 'New signal', body: signal.title},
        data: {signalId: snap.id},
      });
    } catch (e) {
      if (e.code === 'registration-token-not-registered') {
        await receiver.ref.update({fcmToken: admin.firestore.FieldValue.delete()});
      }
    }
  });

On the Flutter side, this is the whole wiring in main(): initMessaging() (example above), plus getInitialMessage/onMessageOpenedApp for deep links. The happy path — permission granted, token saved, trigger fires, banner shows, tap navigates — covers roughly 95% of real apps.

Conclusion & Next Steps

Reliable push for FCM + APNs comes down to a boring checklist: correct .p8 + Key ID + Team ID, matching build environment, Push Notifications capability enabled, all three message states handled, and a server that prunes dead tokens. Nail the chain and notifications become boring infrastructure instead of a mystery box. Next, explore topics and condition targeting for segmented sends, Notification Service Extensions for rich media, and FCM Analytics for delivery funnels.

References / Sources