← Back to posts Cover image for Beyond Firebase: Building Custom Push Notifications in Flutter (Android & iOS)

Beyond Firebase: Building Custom Push Notifications in Flutter (Android & iOS)

· 6 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

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:

  1. Device Registration: It provides a unique token that identifies a specific app installation on a device.
  2. 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

  1. Token Management: Device tokens can change. You must implement logic in your app to refresh the token and update your backend whenever onTokenRefresh is called (Android) or the APNs token changes (iOS).
  2. 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.
  3. Background Execution: Pure background fetching on Android without FCM is challenging. The workmanager package 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.
  4. 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

Cover image for Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.