React Native, Push Notification & Headaches

Amost every app has push notification in this day and age. You would expect a such a simple thing would be straight forward. Well, it's not true for react native apps and let met tell you why

React Native, Push Notification & Headaches
Photo by Adli Wahid / Unsplash

If you have been developing mobile apps for a while, push notification is not a new concept for you. In fact, almost every app has push notification in this day and age that I am pretty sure you has developed them at one point in your career. You would expect a such a simple thing would be straight forward for mobile development toolkits. Well, it's not true for react native apps and let met tell you why below

My experience has been with Android OS and data-only payloads so I cannot say the same for iOS apps since I haven't touched that part.

To have push notification code written in JavaScript, we would need a native Android service that receives message and we would need to pass this to JavaScript side of our app with a native event emitter. Sounds simple, here's an example service that I cook up.

public class MyFirebaseService extends FirebaseMessagingService {

    @Override
    public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
        super.onMessageReceived(remoteMessage);

        ReactContext reactContext = ((MainApplication) getApplication()).getReactNativeHost()
                .getReactInstanceManager().getCurrentReactContext();

        reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                .emit("fcm_message", Helper.remoteMessageToWritableMap(remoteMessage));
    }
}

We can then receive these events through subscription and process the payload that is sent by Firebase.

import { NativeEventEmitter } from 'react-native';

export const onMessageRecievedEventHandling = (
  onMessageRecieved: (data: unknown) => void,
): (() => void) => {
  const eventEmitter = new NativeEventEmitter();

  const subscription = eventEmitter.addListener(
    'fcm_message',
    onMessageRecieved,
  );

  return () => subscription.remove();
};

Looks pretty easy and you would think this works, right? Wrong! it doesn't work when your app is removed from recent menu. That is because when your app is not active, there is no JavaScript bridge active as well. This in turn causes getJSModule() to throws NullPointerException. To solve this problem, we can turn to using Headless JS Service. With this, the app can execute JavaScript code even when it's not in active state. Instead of sending a native event directly, we will start another service that emits the event for us.

public class MyFirebaseService extends FirebaseMessagingService {

    @Override
    public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
        super.onMessageReceived(remoteMessage);
        ReactContext reactContext = ((MainApplication) getApplication()).getReactNativeHost()
                .getReactInstanceManager().getCurrentReactContext();

        Intent backgroundIntent =
                new Intent(getApplicationContext(), MyFirebaseHeadlessService.class);
        backgroundIntent.putExtra("message", remoteMessage);
        getApplicationContext().startService(backgroundIntent);
    }
}
MyFirebaseService.java
public class MyFirebaseHeadlessService extends HeadlessJsTaskService {

    private final String TASK_KEY = "MyFirebaseHeadlessService";

    @Nullable
    @Override
    protected HeadlessJsTaskConfig getTaskConfig(Intent intent) {
        Bundle extras = intent.getExtras();
        if (extras == null) return null;
        RemoteMessage remoteMessage = intent.getParcelableExtra("message");

        return new HeadlessJsTaskConfig(
                TASK_KEY,
                Helper.remoteMessageToWritableMap(remoteMessage),
                60000,
                true);
    }
}
HeadLessService.java
AppRegistry.registerHeadlessTask('MyFirebaseHeadlessService', () => async (remoteMessage) => {
    console.log('remoteMessage', remoteMessage);
  });
App.ts

Now if you put your app into inactive state by removing from recent menu, it should works now, correct? Wrong again, it doesn't work if your message doesn't have priority high flag. Starting from Android O and above, you can no longer start a background service when your app is not active.

With Android 8.0, there is a complication; the system doesn't allow a background app to create a background service.

This limitation is allowed when your Firebase message has high priority flag in it. As a developer however, we should not be abusing high priority flag as it can put our app onto restricted list, and disable if we send more than 5 high priority notification in a day. But wait, there might be another way for us to overcome this limitation. Instead of starting the headless JS service as a background service, we can start it as a foreground service. This allows us to ignore the background service limitation.

Intent backgroundIntent =
        new Intent(getApplicationContext(), MyFirebaseHeadlessService.class);
backgroundIntent.putExtra("message", remoteMessage);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    getApplicationContext().startForegroundService(backgroundIntent);
} else {
   getApplicationContext().startService(backgroundIntent);
}
MyFirebaseService#onMessageReceived
public class MyFirebaseHeadlessService extends HeadlessJsTaskService {

    @Override
    public void onCreate() {
        super.onCreate();
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "notification-channel")
                .setContentTitle("Foreground service")
                .setContentText("Running fg service");
        startForeground(1, builder.build());
    }
    
    //The rest is same
}

The downside to this approach is that when you run the app, your user will see the foreground service notification for a few seconds. Plus, the foreground service can cause higher battery consumption than a background service. Worst of all, it still won't work for Android 12 and above.

Apps that target Android 12 (API level 31) or higher can't start foreground services while running in the background, except for a few special cases

So how will we make push notification works in our react native app while following best practices by not abusing high priority flag. There is only one answer to this, put the push notification code on native side, instead of JavaScript. Since we are no longer limited by having to run headless JavaScript service, we can show notification directly in onMessageRecieved.  In this case, We need to also consider  implementation when user interact with a notification, such as routing to a destination with deeplink, executing network query when clicking on action buttons, etc. Another thing to note is the logistics part of the project. Since we now needs native developers to maintain this piece of code, that means we need to start building native capability or hire native developers. But this is expected of every react native apps that becomes ambitious; you can't just develop everything on JavaScript. Or another solution is to just throw out best practices and abuse high priority flag.