← Back to posts Cover image for Mastering Flutter Notifications: Handling FCM Topic Messages on iOS (Even When Closed)

Mastering Flutter Notifications: Handling FCM Topic Messages on iOS (Even When Closed)

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Mastering Flutter Notifications: Handling FCM Topic Messages on iOS (Even When Closed)

As Flutter developers, we rely on Firebase Cloud Messaging (FCM) to deliver notifications to our users. While FCM works well for basic use cases, handling topic-based notifications on iOS—especially when your app is completely terminated—can be surprisingly tricky. You might have noticed that notifications arrive but your local storage (like Hive) isn’t updated, leaving your app’s state out of sync.

The Core Problem: iOS Background Processing Limitations

On iOS, when your app is completely closed (not just in the background), message handling operates under strict constraints. The system may launch your app in a limited background state to process the notification, but this background execution is time-constrained and doesn’t have the same lifecycle as your normal app.

The crucial insight: If your notification handling code depends on your app’s main initialization routine, it won’t execute properly when the app is launched in the background. This is why your Hive boxes might not be initialized, your dependencies aren’t injected, and your storage calls fail silently.

Architecting a Robust Solution

The key is to structure your notification handling so it works independently of your app’s normal startup flow. Here’s a practical approach that has worked well in production apps.

1. Separate Your Notification Handler

Create a dedicated notification service that can initialize critical dependencies on its own:

class NotificationService {
  static Future<void> initializeHive() async {
    final directory = await getApplicationDocumentsDirectory();
    if (!Hive.isBoxOpen('notifications')) {
      await Hive.initFlutter(directory.path);
      await Hive.openBox('notifications');
    }
  }

  static Future<void> saveNotification(Map<String, dynamic> message) async {
    try {
      await initializeHive();
      final box = Hive.box('notifications');
      final notification = {
        'id': DateTime.now().millisecondsSinceEpoch,
        'title': message['notification']?['title'] ?? 'New Alert',
        'body': message['notification']?['body'] ?? '',
        'data': message['data'] ?? {},
        'timestamp': DateTime.now().toIso8601String(),
      };
      await box.add(notification);
    } catch (e) {
      // Consider logging to a platform-specific location
      print('Failed to save notification: $e');
    }
  }
}

2. Configure Firebase Messaging Correctly

Your Firebase setup is critical. Make sure your firebase_options.dart is properly configured for iOS:

// In your Firebase initialization
await Firebase.initializeApp(
  options: DefaultFirebaseOptions.currentPlatform,
);

// Request permissions (important for iOS)
await FirebaseMessaging.instance.requestPermission(
  alert: true,
  badge: true,
  sound: true,
);

// Subscribe to topics
await FirebaseMessaging.instance.subscribeToTopic('announcements');

3. Implement Background Message Handler

This is the most important part. The background handler must be a top-level function that can run independently:

@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Initialize Firebase in the background context
  await Firebase.initializeApp();
  
  // Process and save the notification
  await NotificationService.saveNotification(message.toMap());
  
  // You can also schedule a local notification if needed
  if (message.notification != null) {
    await _showLocalNotification(message);
  }
}

void main() {
  // Set the background handler BEFORE runApp
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  
  runApp(const MyApp());
}

Note the @pragma('vm:entry-point') annotation—this tells Flutter’s tree-shaking to preserve this function even when it appears unused.

4. Handle Foreground and Background States

Set up comprehensive message handlers in your app’s initialization:

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    _setupNotificationHandlers();
  }

  Future<void> _setupNotificationHandlers() async {
    // Handle messages when the app is in foreground
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      NotificationService.saveNotification(message.toMap());
      _showNotificationInApp(message);
    });

    // Handle messages when the app is in background but not terminated
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      NotificationService.saveNotification(message.toMap());
      _navigateToMessage(message);
    });

    // Get any initial message (app opened from terminated state)
    RemoteMessage? initialMessage = 
        await FirebaseMessaging.instance.getInitialMessage();
    if (initialMessage != null) {
      await NotificationService.saveNotification(initialMessage.toMap());
    }
  }
}

Common Pitfalls to Avoid

  1. Assuming Hive is initialized: Always check Hive.isBoxOpen() before using a box in your background handler.

  2. Heavy initialization in background: Keep your background handler lean. If you need to process complex data, save it first and process it when the app comes to foreground.

  3. Missing iOS capabilities: Ensure your ios/Runner/Info.plist includes:

    <key>UIBackgroundModes</key>
    <array>
      <string>remote-notification</string>
    </array>
  4. Not testing properly: Test on a real iOS device (not simulator) with the app completely terminated. Use Xcode console to see background execution logs.

Testing Your Implementation

Create a simple test widget to verify notifications are being saved:

class NotificationDebugView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Saved Notifications')),
      body: ValueListenableBuilder(
        valueListenable: Hive.box('notifications').listenable(),
        builder: (context, box, _) {
          final notifications = box.values.toList().reversed.toList();
          return ListView.builder(
            itemCount: notifications.length,
            itemBuilder: (context, index) {
              final notification = notifications[index];
              return ListTile(
                title: Text(notification['title']),
                subtitle: Text(notification['body']),
                trailing: Text(notification['timestamp']),
              );
            },
          );
        },
      ),
    );
  }
}

Wrapping Up

Handling FCM topic notifications on iOS when the app is closed requires understanding iOS’s background execution model and structuring your code accordingly. By creating independent initialization paths for your background handlers and ensuring your storage layer is accessible without your full app context, you can build reliable notification systems that work consistently across all app states.

Remember to test thoroughly on physical iOS devices, monitor your background execution time, and keep your background handlers as lightweight as possible. With these patterns in place, you’ll have robust notification handling that keeps your app’s data synchronized regardless of its state.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.

Cover image for Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.

Cover image for Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.