Beyond Firebase: Building Custom Push Notifications in Flutter (Android & iOS)
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
Taking Control: Implementing Custom Push Notifications in Flutter
Firebase Cloud Messaging (FCM) is the default choice for push notifications in Flutter, and for good reason—it handles the complex bridge between your server and device tokens seamlessly. But what if you need an alternative? Perhaps cost is a concern, you require enhanced data privacy by keeping everything in-house, or you simply want more control over the delivery pipeline. Building a custom notification system is entirely feasible, and this guide will walk you through a robust approach for both Android and iOS.
The Core Challenge: Replacing the Middleman
The primary job of a service like FCM is twofold:
- Device Registration: It provides a unique token that identifies a specific app installation on a device.
- Message Routing: It receives messages from your server and delivers them to the target device using platform-specific APIs (Apple Push Notification service - APNs for iOS, Firebase Cloud Messaging for Android).
To go custom, we must handle these responsibilities ourselves. The architecture shifts: your Flutter app will directly communicate with your backend to register its token and, crucially, your backend will send notifications directly to APNs and FCM’s bare-metal equivalents.
Step 1: Acquiring Device Tokens
First, your app needs to request permission and fetch a unique push token. This process is platform-specific, so we’ll use the excellent flutter_local_notifications package for the local notification part and platform channels or dedicated plugins for token retrieval.
For Android: You can use the firebase_messaging package just to get the token (without using FCM for sending) or explore lower-level alternatives. A more direct approach involves native Android code to get the FCM registration token, as it’s still the required channel for push on Android.
For iOS: You must interact with APNs. This typically involves native iOS code to request permissions and fetch the APNs device token.
Here’s a conceptual Flutter-side structure to manage this:
import 'package:flutter/services.dart';
class PushNotificationService {
static const MethodChannel _channel = MethodChannel('com.example/app_push');
// Call this method during app initialization
Future<void> initialize() async {
// Request permissions (handled via platform channels or plugins)
try {
final String? token = await _channel.invokeMethod('getDeviceToken');
if (token != null) {
// Send this token to your backend server for storage
await _registerTokenWithBackend(token);
}
} on PlatformException catch (e) {
print("Failed to get device token: '${e.message}'.");
}
}
Future<void> _registerTokenWithBackend(String token) async {
// Implement your API call here
// await http.post(Uri.parse('https://yourbackend.com/register-token'),
// body: {'token': token, 'platform': Platform.operatingSystem});
}
}
Your native Android/iOS code (in Kotlin/Java and Swift, respectively) would handle the permission requests and token generation, sending the result back via the method channel.
Step 2: Sending Notifications from Your Backend
Once your backend has stored the device tokens, it’s time to send notifications. This happens outside your Flutter app.
- For iOS: Your server must create a signed JWT (JSON Web Token) and send a POST request directly to the APNs API (
https://api.push.apple.com:443/3/device/<device_token>). The payload must be formatted according to APNs specifications. - For Android: Your server sends a POST request to FCM’s legacy HTTP API (
https://fcm.googleapis.com/fcm/send), including the device token and your server key. Yes, this still uses FCM infrastructure, but you’re cutting out the Firebase client SDKs and admin SDKs, managing the communication yourself.
Example backend payload (Node.js concept):
// For Android (FCM)
const androidMessage = {
to: 'ANDROID_DEVICE_TOKEN',
notification: {
title: 'Custom Notification',
body: 'This came directly from our server!',
},
data: { // For handling when app is in foreground/background
route: '/news',
id: '123',
},
};
// For iOS (APNs - using `apns2` library example)
const iosNotification = {
aps: {
alert: {
title: 'Custom Notification',
body: 'This came directly from our server!',
},
sound: 'default',
},
customData: {
route: '/news',
id: '123',
},
};
Step 3: Handling Received Notifications in Flutter
When your app is in the foreground, you won’t receive the notification automatically. You need to listen to the platform-level message stream and display a local notification using flutter_local_notifications.
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
final FlutterLocalNotificationsPlugin localNotifications = FlutterLocalNotificationsPlugin();
Future<void> setupNotificationHandlers() async {
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings();
const InitializationSettings settings = InitializationSettings(android: androidSettings, iOS: iosSettings);
await localNotifications.initialize(settings);
// Listen to incoming data messages from platform channels (when app is in foreground)
// This assumes you've set up a method channel or plugin that streams messages.
// _pushChannel.receiveBroadcastStream().listen((dynamic message) {
// _showLocalNotification(message);
// });
}
Future<void> _showLocalNotification(Map<String, dynamic> message) async {
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
'custom_channel_id', 'Custom Notifications',
importance: Importance.max, priority: Priority.high,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails();
const NotificationDetails platformDetails = NotificationDetails(android: androidDetails, iOS: iosDetails);
await localNotifications.show(
0,
message['notification']['title'],
message['notification']['body'],
platformDetails,
payload: json.encode(message['data']), // Pass custom data
);
}
For background and terminated states on iOS, the APNs payload will wake your app. On Android, you might need a background service to poll your server for new notifications if you avoid FCM entirely—this is more complex and battery-intensive. A common hybrid approach is to use FCM for Android background delivery (as it’s free and battery-optimized) and APNs for iOS, while keeping your business logic and user data entirely on your own servers.
Common Pitfalls to Avoid
- Token Management: Device tokens can change. You must implement logic in your app to refresh the token and update your backend whenever
onTokenRefreshis called (Android) or the APNs token changes (iOS). - Certificate & Key Management (iOS): For APNs, you need to maintain either a Certificate (simpler) or a Key (more flexible, recommended). Keep them secure on your backend and rotate them before they expire.
- Background Execution: Pure background fetching on Android without FCM is challenging. The
workmanagerpackage can be used for periodic polling, but this is not real-time and has strict limitations. For true, reliable push, using FCM for Android and APNs for iOS is often the most pragmatic custom stack. - Payload Size: APNs has a maximum payload size (currently 4KB). Keep your notification data concise.
Building a custom push notification system requires more initial setup and ongoing maintenance than using Firebase. However, the benefits in cost control, data sovereignty, and architectural independence can be significant for the right project. By understanding the platform-specific requirements and carefully handling token lifecycles, you can create a reliable, custom notification experience for your users.
This blog is produced with the assistance of AI by a human editor. Learn more
Related Posts
Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations
Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.
Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues
Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.
Solving Flutter Web Memory Leaks: A Practical Guide to Identifying and Fixing Performance Issues
Flutter Web applications can suffer from increasing memory usage over time, leading to performance degradation. This post will delve into common causes of memory leaks in Flutter Web, provide practical debugging techniques using browser developer tools and Dart DevTools, and offer actionable strategies to identify and fix these issues for a smoother user experience.