Skip to content

Conversation

@sean5940
Copy link

Description

This PR addresses an OutOfMemoryError (OOM) crash on Android caused by ReactNativeFirebaseMessagingStoreImpl storing up to 100 notifications in SharedPreferences.

When a device receives many notifications with large payloads, the SharedPreferences file grows significantly. Since SharedPreferences loads the entire file into memory, this can exceed the heap size on some devices, leading to a crash.

Related Issue:
Closes #8770

Changes

  1. Reduced Default Limit: The default MAX_SIZE_NOTIFICATIONS has been reduced from 100 to 20.

    • Reasoning: getInitialNotification typically only needs the single notification that launched the app. A buffer of 20 is sufficient for most use cases while significantly reducing memory pressure.
  2. Configurable Limit: Added support for configuring the limit via AndroidManifest.xml.

    • Developers can now set rn_firebase_messaging_max_stored_notifications in their AndroidManifest.xml to adjust this limit without forking the library.
    <meta-data
      android:name="rn_firebase_messaging_max_stored_notifications"
      android:value="50" />
  3. Safety Cap: Added a hard cap of 100 to the configurable limit.

    • Even if a developer sets the value higher in the manifest, it will be capped at 100 to prevent re-introducing the OOM risk.
  4. Aggressive Cleanup: Changed the cleanup logic from an if statement to a while loop.

    • Reasoning: If a user already has >20 notifications stored (e.g., 100 from the previous version), the previous logic only removed one old notification per new message. The while loop ensures the storage is immediately drained down to the limit upon receiving the next message.

Verification

  • Verified locally that getInitialNotification still functions correctly with the reduced limit.
  • Verified that the limit is correctly read from AndroidManifest.xml.
  • Verified that the safety cap prevents values > 100.
  • Verified that the while loop correctly cleans up excess notifications.

Stack Trace (Reference)

java.lang.OutOfMemoryError: Failed to allocate a 24 byte allocation with 425280 free bytes and 415KB until OOM...
    at android.app.SharedPreferencesImpl$EditorImpl.commitToMemory(SharedPreferencesImpl.java:539)
    at io.invertase.firebase.messaging.ReactNativeFirebaseMessagingStoreImpl.storeFirebaseMessage(ReactNativeFirebaseMessagingStoreImpl.java:43)

@vercel
Copy link

vercel bot commented Nov 24, 2025

@sean5940 is attempting to deploy a commit to the Invertase Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant
Copy link

CLAassistant commented Nov 24, 2025

CLA assistant check
All committers have signed the CLA.

@sean5940 sean5940 changed the title fix(messaging, android): prevent OOM by limiting stored notifications & add config fix(messaging, android): prevent OOM by limiting stored notifications Nov 24, 2025
Copy link
Collaborator

@mikehardy mikehardy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mentioned in more detail in specific comments but a summary:

1- I really like the idea of making the serialized notification count configurable

But, I believe strongly this should use our existing code though, and if you want to make existing boot-crash-loop devices recoverable you'll need to add error handling to that common code

2- I do not believe we should change the default, we should avoid a potentially breaking change

3- I like the trim-until-we-are-the-value vs one-at-a-time change

I think there's the core of something that can go in here if you work through the info I put out for item 1 here in this list

private static final String S_KEY_ALL_NOTIFICATION_IDS = "all_notification_ids";
private static final int MAX_SIZE_NOTIFICATIONS = 100;
private final String DELIMITER = ",";
private static int MAX_SIZE_NOTIFICATIONS = 20;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe changing this is a good idea - not for 100% technical reasons - 100 may not be practical for every case - you obviously have an OOM so this is critical to address somehow - but this feature has existed for 5 years (ref: sean5940@a7cafc9) and you are the first report of a problem.

So while 100 may be impractical sometimes, it's once in 5 years out of maybe tens of thousands of apps and millions and millions of users.

Weigh that versus even the slim possibility of some app relying on this behavior (or some unknown amount less than 100 and greater than 20) and that implies this would be a breaking change.

I don't think a fix here is worth a breaking change given it has existed for 5 years without any other issues.

So I am a "no" on changing this constant

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to maintain a maximum value of 100, based on comments
41ca8ef

if (appInfo.metaData != null) {
int configValue =
appInfo.metaData.getInt("rn_firebase_messaging_max_stored_notifications", 20);
MAX_SIZE_NOTIFICATIONS = Math.min(configValue, 100);
Copy link
Collaborator

@mikehardy mikehardy Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to get a configurable item from firebase.json (transmitted through AndroidManifest.xml) you should use the existing + tested utilities we provide for all modules.

Here is an example that controls a configuration item from one key via priority order of shared prefs, then firebase.json then AndroidManifest

private static final String KEY_APPCHECK_TOKEN_REFRESH_ENABLED = "app_check_token_auto_refresh";
private static HashMap<String, FirebaseAppCheck.AppCheckListener> mAppCheckListeners =
new HashMap<>();
ReactNativeFirebaseAppCheckProviderFactory providerFactory =
new ReactNativeFirebaseAppCheckProviderFactory();
static boolean isAppCheckTokenRefreshEnabled() {
boolean enabled;
ReactNativeFirebaseJSON json = ReactNativeFirebaseJSON.getSharedInstance();
ReactNativeFirebaseMeta meta = ReactNativeFirebaseMeta.getSharedInstance();
ReactNativeFirebasePreferences prefs = ReactNativeFirebasePreferences.getSharedInstance();
if (prefs.contains(KEY_APPCHECK_TOKEN_REFRESH_ENABLED)) {
enabled = prefs.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, true);
Log.d(LOGTAG, "isAppCheckCollectionEnabled via RNFBPreferences: " + enabled);
} else if (json.contains(KEY_APPCHECK_TOKEN_REFRESH_ENABLED)) {
enabled = json.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, true);
Log.d(LOGTAG, "isAppCheckCollectionEnabled via RNFBJSON: " + enabled);
} else {
enabled = meta.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, true);
Log.d(LOGTAG, "isAppCheckCollectionEnabled via RNFBMeta: " + enabled);
}
if (BuildConfig.DEBUG) {
if (!json.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, false)) {
enabled = false;
}
Log.d(
LOGTAG,
"isAppCheckTokenRefreshEnabled after checking "
+ KEY_APPCHECK_TOKEN_REFRESH_ENABLED
+ ": "
+ enabled);
}
Log.d(LOGTAG, "isAppCheckTokenRefreshEnabled final value: " + enabled);
return enabled;
}

There's a potential problem here that loading the SharedPrefs at all - even just to fetch this configuration value - may cause an OOM.

Worse - and potentially causing an unrecoverable boot crash loop however - our crashlytics implements an Android Provider (which starts before all other parts of app) - and it uses SharedPreferences for configuration so if people have that installed and have this OOM problem there is no way around it besides having user 'Clear Storage' in app settings on device. Even an attempt to purge old messages that cause an OOM

I regret now that these messages were not put into their own shared preferences file

So: the solution is to modify the usage of SharedPreferences in our app module to do these things:

  • catch OutOfMemoryError: not something an app should normally do but this is recoverable, if we catch it
  • in the catch block there, call system.gc and try one more time, catching again
  • in the final catch block, assume we are in an unrecoverable boot crash loop and our only alternative is to either have the user manually clear storage for the app, or for us to programmatically wipe the shared preferences. Neither is great, but one is automated. So the second OutOfMemoryError catch block where SharedPreferences is accessed should purge the shared preferences file. the clear/apply style should work https://stackoverflow.com/a/3687333/9910298

At that point you will be using shared code and actually improving it so that all paths through it are safe from OutOfMemoryError and users may recover

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed feedback. I’ve updated the implementation to read the configuration value using the approach you mentioned at the beginning (SharedPreferences → firebase.json → AndroidManifest).

41ca8ef

@sean5940
Copy link
Author

sean5940 commented Nov 25, 2025

@mikehardy

@Override
  public void storeFirebaseMessage(RemoteMessage remoteMessage) {
    try {
      String remoteMessageString =
          reactToJSON(remoteMessageToWritableMap(remoteMessage)).toString();
      //      Log.d("storeFirebaseMessage", remoteMessageString);
      UniversalFirebasePreferences preferences = UniversalFirebasePreferences.getSharedInstance();
      preferences.setStringValue(remoteMessage.getMessageId(), remoteMessageString);
      // save new notification id
      String notifications = preferences.getStringValue(S_KEY_ALL_NOTIFICATION_IDS, "");
      notifications += remoteMessage.getMessageId() + DELIMITER; // append to last

      // check and remove old notifications message
      List<String> allNotificationList = convertToArray(notifications);
      if (allNotificationList.size() > MAX_SIZE_NOTIFICATIONS) {
        String firstRemoteMessageId = allNotificationList.get(0);
        preferences.remove(firstRemoteMessageId); // ------ here
        notifications = removeRemoteMessage(firstRemoteMessageId, notifications);
      }
      preferences.setStringValue(S_KEY_ALL_NOTIFICATION_IDS, notifications);
    } catch (JSONException e) {
      e.printStackTrace();
    }
  }

While preferences.remove(firstRemoteMessageId) is invoked when the notification list exceeds MAX_SIZE_NOTIFICATIONS, the editor never calls apply()/commit().
As a result, the entry stored under remoteMessage.getMessageId() is never actually removed from io.invertase.firebase.xml, so the file keeps growing.
Once the XML becomes large enough, calling preferences.setStringValue(S_KEY_ALL_NOTIFICATION_IDS, notifications) can trigger OOM because the entire SharedPreferences file must be loaded into memory before the update.
Is this understanding correct?

2025-11-25.10.53.27.mov

@mikehardy
Copy link
Collaborator

@sean5940 your analysis appears correct, regardless of the other feedback I gave, your conversion of the "if" to a "while", and adding an apply() there is an immediate bug fix. I will post a PR and release it independently of this here so that it not blocked and the fix can be out with no delay

@mikehardy
Copy link
Collaborator

@sean5940 I can merge and release the PR quickly, but review always helps. If you could take a look?

I would still like to pursue the idea of making the storage limit configurable here, and working through the idea of OOM recovery by catching an OOM error. Although I think the 5th commit in my PR - "purge old messages before storing new" may actually recover people sufficiently,.

@mikehardy
Copy link
Collaborator

This was mostly superceded by #8776 which I wanted to get out as soon as possible because it was in "crash fix" territory

The remaining part of this is the configurable maximum message parameter - what do you think @sean5940 is it still worth it to implement that, or do you want to see if #8776 clears out the problem?

@sean5940
Copy link
Author

sean5940 commented Nov 28, 2025

This was mostly superceded by #8776 which I wanted to get out as soon as possible because it was in "crash fix" territory

The remaining part of this is the configurable maximum message parameter - what do you think @sean5940 is it still worth it to implement that, or do you want to see if #8776 clears out the problem?

@mikehardy

One concern I have is that the shared preferences file is already extremely large, which significantly increases the risk of OOM errors. I’m exploring ways to deal with this — do you have any suggestions on how we might mitigate the issue?

For example, if we assume a situation where the XML file manages only 100 IDs while several thousand FCM messages are stored, the cleanup process can operate only on those 100 known IDs. As a result, any data associated with IDs outside that range cannot be deleted.

I’m not seeing a clear way to address this at the library level. In this situation, would it make sense for the app to check the file size in Application.onCreate() and delete it when it becomes abnormally large?

@sean5940 sean5940 marked this pull request as draft November 28, 2025 08:46
@sean5940 sean5940 force-pushed the fix/android-oom-messaging-store branch from c5caa92 to fae6843 Compare November 28, 2025 08:49
@sean5940 sean5940 force-pushed the fix/android-oom-messaging-store branch from e86d62a to 56553db Compare November 28, 2025 09:37
@sean5940 sean5940 marked this pull request as ready for review November 28, 2025 09:39
@sean5940 sean5940 requested a review from mikehardy November 28, 2025 09:40
@mikehardy
Copy link
Collaborator

mikehardy commented Nov 28, 2025

🤔

do you have any suggestions on how we might mitigate the issue

Don't send huge data chunks in your messages, have the data in some other location and send unambiguous references to it, for fetch after getting the notification? But that's a design issue and not really your question

For example, if we assume a situation where the XML file manages only 100 IDs while several thousand FCM messages are stored, the cleanup process can operate only on those 100 known IDs. As a result, any data associated with IDs outside that range cannot be deleted.

Ah, but if you mean that somehow the "accounting" (that is, the assumption in the code that each and every actual message stored has an id stored as well, with no ids for messages that don't exist and no messages without an id entry) is broken then I can see what you mean and we would need some sort of cleanup process.

Indeed, this is the easiest to understand representation of the logic that is currently deployed it is after my refactor for clarity but before any logic changes from the PR I just merged

preferences.setStringValue(remoteMessage.getMessageId(), remoteMessageString);
// save new notification id
String notificationIds = preferences.getStringValue(S_KEY_ALL_NOTIFICATION_IDS, "");
notificationIds += remoteMessage.getMessageId() + DELIMITER; // append to last
// check and remove old notifications message
List<String> allNotificationList = convertToArray(notificationIds);
if (allNotificationList.size() > MAX_SIZE_NOTIFICATIONS) {
String firstRemoteMessageId = allNotificationList.get(0);
preferences.remove(firstRemoteMessageId);
notificationIds = removeRemoteMessageId(firstRemoteMessageId, notificationIds);
}
preferences.setStringValue(S_KEY_ALL_NOTIFICATION_IDS, notificationIds);

So,

1- store the message as messageId + contents key/value pair
2- then store the messageId list
3- then purge one old message if there are more than the limit

We can assume that 1 may work, and 2 may fail which would lead to an "accounting" failure and an orphaned message yes.

I believe a combination of getAll() on the SharedPreferences, followed by a search for keys that are a) in the known pattern of firebase message ids* and b) are not in the list of messageIds would allow for cleaning old messages. Similarly if there is an id but no related message, that id could be removed from the list, though that is less likely since the messages are stored first.

However, the * is important - perhaps you are sending messages from some other source?

Judging from your recording above, a regex like (untested...) ^\d\:\d{16}%\d{17}$ should be a good match for message ids so you assert that a particular SharedPreferences key is a message (assuming you test that and make sure I got it correct...)

Then you can do a look up in the notificationIds list, and if it is not there, remove().apply() on it.
There is a note in the API docs for getAll() to be careful with how you treat the Map that is returned, so you will likely want to just use it to get all the keys for storage into an fresh intermediate variable as one step, for direct key-by-key processing as a second pass.

This would be a positive change in my opinion, if you implemented it I would be happy to merge it. If you didn't I would probably implement it myself but with a considerable delay - it could be weeks

Copy link
Collaborator

@mikehardy mikehardy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the config change - nice to give people control over this one
As mentioned I don't think a clamp is a positive move but that's an easy change

And if you wanted to implement an all-messageIds-iterator in a separate method like preenMessageStorage or similar then call it before storing a new message, I'd be open to that

"description": "On iOS, indicating how to present a notification in a foreground app.",
"type": "array"
},
"rn_firebase_messaging_max_stored_notifications": {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we prefix with the specific name traditionally - it is reasonable to argue that since SharedPreferences is a global namespace we should be more specific but we're using messaging_ so far for messaging items, and consistency does have value

Suggested change
"rn_firebase_messaging_max_stored_notifications": {
"messaging_max_stored_notifications": {

"type": "array"
},
"rn_firebase_messaging_max_stored_notifications": {
"description": "Controls how many notifications are retained on-device for background storage. Priority order is SharedPreferences -> firebase.json -> AndroidManifest. Values outside the 20-100 range are clamped to prevent excessive deletions or OOM.",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not believe in restricting developers capabilities without reason. Someone may have reason to set it to zero for zero data retention for some reason. Someone may have reason to set it to 1000 (small messages!) for their particular use case.

Our job is to pick a sensible default then allow people to do what they want, and just expose the risks.

100 was already selected as a reasonable default for most cases (and as mentioned has caused no problems for 5 years...) so we have done that. The rest is up to developers.

Suggested change
"description": "Controls how many notifications are retained on-device for background storage. Priority order is SharedPreferences -> firebase.json -> AndroidManifest. Values outside the 20-100 range are clamped to prevent excessive deletions or OOM.",
"description": "Controls how many notifications are retained on-device for background storage. Configuration priority order is SharedPreferences -> firebase.json -> AndroidManifest. Default 100. Very small values may cause unwanted excessive deletions, large values or large message contents run the risk of OutOfMemoryErrors.",

Comment on lines +124 to +126
"type": "number",
"minimum": 20,
"maximum": 100
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"type": "number",
"minimum": 20,
"maximum": 100
"type": "number"

Comment on lines -21 to +30
private static final int MAX_SIZE_NOTIFICATIONS = 100;
private final String DELIMITER = ",";
private static final int DEFAULT_MAX_SIZE_NOTIFICATIONS = 100;
private static final int MIN_SIZE_NOTIFICATIONS = 20;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the edit for the key description - please do not restrict developers via "good intentions" as those are frequently just our idea of what a developer needs but sometimes restrict valid use cases.

Just maintain the 100 original default, take the value in from configuration (if set) and log the final setting and source it came from for diagnostics just in case. No clamping

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[🐛] [Android] OOM Crash due to excessive notification storage in SharedPreferences

3 participants