Why Your FCM Token Is null on iOS — and the Five-Line Fix

The race condition between APNS and FCM that the docs never warn you about


You ship a Flutter app with push notifications. Android works perfectly. On iOS, half your users never get notifications, and your analytics show a chunk of them have a null FCM token in your backend.

The bug is almost always the same line:

final token = await FirebaseMessaging.instance.getToken();

Looks innocent. On iOS it is a race condition.


What is actually happening

On iOS, Firebase Cloud Messaging cannot give you an FCM token until APNS has given iOS an APNS token. APNS registration is asynchronous: it starts when your app calls requestPermission(), and the device token is delivered later through the AppDelegate callback didRegisterForRemoteNotificationsWithDeviceToken.

If you call getToken() before that callback fires, three things can happen:

  1. You get back null.
  2. You hit the exception "apns-token-not-set".
  3. The call hangs silently and your splash screen sits there forever.

The Flutter plugin tries to handle this for you, but the race is real. On a slow network, on a cold start, on a re-installed app, on a carrier with sluggish APNS gateways — you will lose.


The naive fixes that don't survive production

// ❌ Flaky. Sometimes 1s is enough, sometimes 8s isn't.
await Future.delayed(const Duration(seconds: 3));
final token = await messaging.getToken();
// ❌ Wastes battery. Some users never have APNS registered (no internet).
while (true) {
  final t = await messaging.getToken();
  if (t != null) return t;
}

Both ignore the actual signal you are waiting for: the APNS token itself.


The five-line fix

Wait explicitly for the APNS token, with a bounded retry:

import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';

Future<String?> getFcmTokenSafely() async {
  final messaging = FirebaseMessaging.instance;

  final settings = await messaging.requestPermission();
  if (settings.authorizationStatus == AuthorizationStatus.denied) {
    return null;
  }

  if (Platform.isIOS) {
    String? apnsToken;
    for (var i = 0; i < 10; i++) {
      apnsToken = await messaging.getAPNSToken();
      if (apnsToken != null) break;
      await Future.delayed(const Duration(milliseconds: 500));
    }
    if (apnsToken == null) return null; // genuinely failed
  }

  return messaging.getToken();
}

Ten retries × 500 ms = a 5-second worst case. In practice the loop exits on the first or second iteration. The function returns null only when permission is denied or APNS truly cannot register — both of which are real failure modes you should report to your backend, not retry past.


Always subscribe to refresh

The token can change at any time — app reinstall, restored backup, FCM rotation. Subscribe once at startup:

messaging.onTokenRefresh.listen((newToken) {
  // PATCH /me/push-token { token: newToken }
});

This is the only reliable way to keep your backend in sync. The token returned by getToken() is a snapshot; onTokenRefresh is the source of truth.


The four production checklist items

Before blaming code, verify the iOS side:

  1. Push Notifications capability is enabled in Xcode → Signing & Capabilities.
  2. Background ModesRemote notifications is checked.
  3. The aps-environment entitlement is present in your provisioning profile (development or production).
  4. You are not testing on the iOS Simulator running below iOS 16 — APNS does not work there. iOS 16+ simulators paired with Apple Silicon Macs do support push.

90% of "FCM doesn't work on iOS" bug reports turn out to be one of those four. The token race is the other 10% — and it is the one your code can actually fix.


TL;DR

You can see the full helper, plus a PushTokenSyncService that ships the token to a backend on every refresh, in flux_advanced — my open-source Flutter Clean Architecture sample.

If this article saved you an evening of print(token) debugging, a ⭐ on the repo is the only payment I ask.