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:
- You get back
null. - You hit the exception
"apns-token-not-set". - 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:
- Push Notifications capability is enabled in Xcode → Signing & Capabilities.
- Background Modes → Remote notifications is checked.
- The
aps-environmententitlement is present in your provisioning profile (developmentorproduction). - 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
- Don't call
getToken()directly. CallgetAPNSToken()first and wait. - Bound your retry to ~5 seconds and treat persistent
nullas a real failure. - Subscribe to
onTokenRefreshonce, at app startup, and forget about it. - Verify capabilities in Xcode before writing more code.
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.