-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix(messaging, android): prevent OOM by limiting stored notifications #8771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
@sean5940 is attempting to deploy a commit to the Invertase Team on Vercel. A member of the Team first needs to authorize it. |
mikehardy
left a comment
There was a problem hiding this 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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
...oid/src/main/java/io/invertase/firebase/messaging/ReactNativeFirebaseMessagingStoreImpl.java
Outdated
Show resolved
Hide resolved
| if (appInfo.metaData != null) { | ||
| int configValue = | ||
| appInfo.metaData.getInt("rn_firebase_messaging_max_stored_notifications", 20); | ||
| MAX_SIZE_NOTIFICATIONS = Math.min(configValue, 100); |
There was a problem hiding this comment.
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
Lines 40 to 79 in b388022
| 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
There was a problem hiding this comment.
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).
...oid/src/main/java/io/invertase/firebase/messaging/ReactNativeFirebaseMessagingStoreImpl.java
Outdated
Show resolved
Hide resolved
While preferences.remove(firstRemoteMessageId) is invoked when the notification list exceeds MAX_SIZE_NOTIFICATIONS, the editor never calls apply()/commit(). 2025-11-25.10.53.27.mov |
|
@sean5940 your analysis appears correct, regardless of the other feedback I gave, your conversion of the "if" to a "while", and adding an |
|
@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,. |
|
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? |
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? |
c5caa92 to
fae6843
Compare
e86d62a to
56553db
Compare
|
🤔
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
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 Lines 31 to 43 in 2ca86df
So, 1- store the message as messageId + contents key/value pair 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 However, the Judging from your recording above, a regex like (untested...) Then you can do a look up in the notificationIds list, and if it is not there, 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 |
mikehardy
left a comment
There was a problem hiding this 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": { |
There was a problem hiding this comment.
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
| "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.", |
There was a problem hiding this comment.
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.
| "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.", |
| "type": "number", | ||
| "minimum": 20, | ||
| "maximum": 100 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| "type": "number", | |
| "minimum": 20, | |
| "maximum": 100 | |
| "type": "number" |
| 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; |
There was a problem hiding this comment.
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
Description
This PR addresses an
OutOfMemoryError(OOM) crash on Android caused byReactNativeFirebaseMessagingStoreImplstoring up to 100 notifications inSharedPreferences.When a device receives many notifications with large payloads, the
SharedPreferencesfile grows significantly. SinceSharedPreferencesloads the entire file into memory, this can exceed the heap size on some devices, leading to a crash.Related Issue:
Closes #8770
Changes
Reduced Default Limit: The default
MAX_SIZE_NOTIFICATIONShas been reduced from 100 to 20.getInitialNotificationtypically only needs the single notification that launched the app. A buffer of 20 is sufficient for most use cases while significantly reducing memory pressure.Configurable Limit: Added support for configuring the limit via
AndroidManifest.xml.rn_firebase_messaging_max_stored_notificationsin theirAndroidManifest.xmlto adjust this limit without forking the library.Safety Cap: Added a hard cap of 100 to the configurable limit.
Aggressive Cleanup: Changed the cleanup logic from an
ifstatement to awhileloop.whileloop ensures the storage is immediately drained down to the limit upon receiving the next message.Verification
getInitialNotificationstill functions correctly with the reduced limit.AndroidManifest.xml.whileloop correctly cleans up excess notifications.Stack Trace (Reference)