You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We're seeing OutOfMemoryError crashes on low-memory Android devices in two SDK code paths that materialize the full offerings response in memory. This is a follow-up to #3628: the HTTPResultWithETag.serialize OOM fixed in 10.14.x no longer occurs for us (thank you!), but the same underlying condition (a very large offerings payload) now crashes in:
DeviceCache.cacheOfferingsResponse (DeviceCache L:601) — JSONObject.toString() before the SharedPreferences write fails on a single 37,748,744-byte char[] allocation.
ETagPayloadStore.read (ETagPayloadStore L:85) — CharsetDecoder.decode() over the stored payload file fails on a 39,002,984-byte CharBuffer allocation.
Both paths hold several full copies of the payload in memory at once (e.g. for the cache write: the parsed JSONObject tree, the serializer's doubling buffer, and the resulting String), so peak memory usage is a multiple of payload size — and payload size grows with offerings × paywalls × locales. The findings below describe what we believe pushed our project across the allocation threshold now, but the thresholds themselves would apply to any sufficiently large offerings configuration, from either host.
Two findings that may help with triage:
a) The crashes seem to correlate with the fallback host, whose offerings payload appears to be roughly 1.8× the main API's. All crash events we sampled log a connection-level failure to api.revenuecat.com (DNS resolution, SSL handshake, TCP connect, or connection closed) shortly before the crash, and one stack trace passes through HTTPClient.performRequest$performRequestToFallbackURL, so these devices appear to be failing over to api-production.8-lives-cat.io. We compared the two endpoints with curl (representative headers, not the SDK itself, so treat sizes as approximate):
Measurement
api.revenuecat.com/v1/subscribers/{id}/offerings
api-production.8-lives-cat.io/v1/offerings
Size
~1.0MB (gzip)
~14.6MB (served uncompressed)
Compact serialized length
~5.55M chars
~9.93M chars
The content (offerings, paywalls, locales) was otherwise identical, but in the fallback response every paywall's paywall_components contains both components_localizations and an additional components_localizations_resolved field that near-duplicates it. The main API response does not include the _resolved field. We don't know whether the duplication or the lack of compression on the fallback host is intentional, so we're flagging both as context. From our reading of HTTPClient.getHeaders, the SDK sends the same header set to both hosts, so the size difference appears to be a property of the fallback snapshot itself rather than anything request-driven — though our measurements were made with curl rather than the SDK, so we may be missing something here.
This is likely why the crashes cluster on fallback-served devices: the failed 37,748,744-byte allocation is a StringBuilder doubling step that only occurs once the serialized string exceeds ~9.44M chars — the fallback payload (~9.93M chars) reaches it, while the main-API payload (~5.55M chars) cannot.
b) Once the large payload is cached, devices can crash again even fully offline. In OfferingsManager.fetchAndCacheOfferings, when the fetch fails and the SDK falls back to the disk-cached response (loadedFromDiskCache = true), createAndCacheOfferings still calls OfferingsCache.cacheOfferings, which re-serializes and re-writes the same JSON it just loaded (deviceCache.cacheOfferingsResponse(finalJsonToCache)). One sampled event shows both hosts unresolvable plus Error fetching offerings. Using disk cache. followed by the OOM in cacheOfferingsResponse — consistent with this loop. It looks like a device in this state can keep failing on each retry until a smaller payload is fetched successfully or the cache is cleared.
Environment
Platform: Android
SDK version: v10.14.1 (crash code paths appear unchanged on main / 10.15.1)
OS version: Android 10–15 observed; crashes are concentrated on devices with low heap limits (all sampled events report a 128MB dalvik.vm.heapgrowthlimit: Tecno, Infinix, Itel, Moto E20, and similar), both 32-bit and 64-bit ARM
Android Studio version: N/A (production crash reports)
How widespread: ~300 fatal events across the two signatures in the last 7 days, spiking after we published additional paywalls on 7/29. All sampled events are on low-RAM devices with 128MB heap growth limits.
These are production crashes, so we don't have SDK debug-level logs. Log output captured by Crashlytics from the sampled events shows, shortly before each crash, one of:
[Purchases] - WARN ⚠️ Unable to start a network connection due to a network configuration issue: Unable to resolve host "api.revenuecat.com": No address associated with hostname
... SSL handshake aborted: ...
... failed to connect to api.revenuecat.com/<ip> (port 443) ...
and in the offline-loop case additionally: Unable to resolve host "api-production.8-lives-cat.io" and [Purchases] - WARN Error fetching offerings. Using disk cache.
Steps to reproduce, with a description of expected vs. actual behavior
We can't reproduce in-app on our test devices, but the observed chain is:
Project has many current/active offerings with paywalls (ours: 60 active offerings, 38 with V2 paywall configs, up to 25 locales each. In our main-API response, the inlined paywall_components account for ~89% of the payload, and the components_localizations tables within them are the bulk of that (~70% of the total payload)).
Device fails to reach api.revenuecat.com (DNS/SSL/TCP-level IOException) → SDK fails over to GET /v1/offerings on the fallback host.
Fallback response (~14.6MB uncompressed in our project; serializes to ~9.93M chars) is parsed and passed to DeviceCache.cacheOfferingsResponse, whose JSONObject.toString() requires a ~37.7MB contiguous allocation mid-serialization → OOM on 128MB-heap devices. The ETag store read path OOMs on the same payload from the other direction.
Once cached, the payload is re-serialized on every subsequent failed fetch (see finding b) → the crash can repeat even offline.
The server-side size difference was straightforward to verify: fetch both endpoints for the same project and compare (we used an Android X-Platform/X-Version header).
Expected: the SDK can cache and read offerings responses without multi-tens-of-MB contiguous allocations, regardless of which host served them or how large the offerings configuration grows. (The fallback host's larger, uncompressed payload compounds the impact for us — but even our main-API payload is ~11MB as an in-memory string, which doesn't leave much headroom on 128MB-heap devices as configurations grow.)
Actual: fatal OOMs in the two paths above.
Other information
Crash (1) — DeviceCache.cacheOfferingsResponse:
Fatal Exception: java.lang.OutOfMemoryError: Failed to allocate a 37748744 byte allocation with 6291456 free bytes and 33MB until OOM, target footprint 105687152, growth limit 134217728
at java.util.Arrays.copyOf(Arrays.java:3578)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:177)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:753)
at java.lang.StringBuilder.append(StringBuilder.java:257)
at org.json.JSONStringer.string(JSONStringer.java:354)
at org.json.JSONStringer.value(JSONStringer.java:261)
at org.json.JSONObject.writeTo(JSONObject.java:734)
at org.json.JSONObject.toString(JSONObject.java:702)
at com.revenuecat.purchases.common.caching.DeviceCache.cacheOfferingsResponse$purchases_defaultsRelease(DeviceCache.kt:601)
Crash (2) — ETagPayloadStore.read:
Fatal Exception: java.lang.OutOfMemoryError: Failed to allocate a 39002984 byte allocation with 9739248 free bytes and 9510KB until OOM, target footprint 134217728, growth limit 134217728
at java.nio.HeapCharBuffer.<init>(HeapCharBuffer.java:87)
at java.nio.CharBuffer.allocate(CharBuffer.java:380)
at java.nio.charset.CharsetDecoder.decode(CharsetDecoder.java:858)
at com.revenuecat.purchases.common.networking.ETagPayloadStore.read(ETagPayloadStore.kt:85)
at com.revenuecat.purchases.common.networking.ETagManager.getStoredResult$purchases_defaultsRelease(ETagManager.kt:208)
at com.revenuecat.purchases.common.networking.ETagManager.getHTTPResultFromCacheOrBackend$purchases_defaultsRelease(ETagManager.kt:176)
at com.revenuecat.purchases.common.HTTPClient.performCall(HTTPClient.kt:469)
at com.revenuecat.purchases.common.HTTPClient.performRequest(HTTPClient.kt:205)
at com.revenuecat.purchases.common.HTTPClient.performRequest$performRequestToFallbackURL(HTTPClient.kt:182)
at com.revenuecat.purchases.common.HTTPClient.performRequest(HTTPClient.kt:231)
at com.revenuecat.purchases.common.Backend$getOfferings$call$1.call(Backend.kt:431)
at com.revenuecat.purchases.common.Dispatcher$AsyncCall.run(Dispatcher.kt:48)
Describe the bug
We're seeing
OutOfMemoryErrorcrashes on low-memory Android devices in two SDK code paths that materialize the full offerings response in memory. This is a follow-up to #3628: theHTTPResultWithETag.serializeOOM fixed in 10.14.x no longer occurs for us (thank you!), but the same underlying condition (a very large offerings payload) now crashes in:DeviceCache.cacheOfferingsResponse(DeviceCache L:601) —JSONObject.toString()before the SharedPreferences write fails on a single 37,748,744-bytechar[]allocation.ETagPayloadStore.read(ETagPayloadStore L:85) —CharsetDecoder.decode()over the stored payload file fails on a 39,002,984-byteCharBufferallocation.Both paths hold several full copies of the payload in memory at once (e.g. for the cache write: the parsed
JSONObjecttree, the serializer's doubling buffer, and the resultingString), so peak memory usage is a multiple of payload size — and payload size grows with offerings × paywalls × locales. The findings below describe what we believe pushed our project across the allocation threshold now, but the thresholds themselves would apply to any sufficiently large offerings configuration, from either host.Two findings that may help with triage:
a) The crashes seem to correlate with the fallback host, whose offerings payload appears to be roughly 1.8× the main API's. All crash events we sampled log a connection-level failure to
api.revenuecat.com(DNS resolution, SSL handshake, TCP connect, or connection closed) shortly before the crash, and one stack trace passes throughHTTPClient.performRequest$performRequestToFallbackURL, so these devices appear to be failing over toapi-production.8-lives-cat.io. We compared the two endpoints withcurl(representative headers, not the SDK itself, so treat sizes as approximate):api.revenuecat.com/v1/subscribers/{id}/offeringsapi-production.8-lives-cat.io/v1/offeringsThe content (offerings, paywalls, locales) was otherwise identical, but in the fallback response every paywall's
paywall_componentscontains bothcomponents_localizationsand an additionalcomponents_localizations_resolvedfield that near-duplicates it. The main API response does not include the_resolvedfield. We don't know whether the duplication or the lack of compression on the fallback host is intentional, so we're flagging both as context. From our reading ofHTTPClient.getHeaders, the SDK sends the same header set to both hosts, so the size difference appears to be a property of the fallback snapshot itself rather than anything request-driven — though our measurements were made withcurlrather than the SDK, so we may be missing something here.This is likely why the crashes cluster on fallback-served devices: the failed 37,748,744-byte allocation is a
StringBuilderdoubling step that only occurs once the serialized string exceeds ~9.44M chars — the fallback payload (~9.93M chars) reaches it, while the main-API payload (~5.55M chars) cannot.b) Once the large payload is cached, devices can crash again even fully offline. In
OfferingsManager.fetchAndCacheOfferings, when the fetch fails and the SDK falls back to the disk-cached response (loadedFromDiskCache = true),createAndCacheOfferingsstill callsOfferingsCache.cacheOfferings, which re-serializes and re-writes the same JSON it just loaded (deviceCache.cacheOfferingsResponse(finalJsonToCache)). One sampled event shows both hosts unresolvable plusError fetching offerings. Using disk cache.followed by the OOM incacheOfferingsResponse— consistent with this loop. It looks like a device in this state can keep failing on each retry until a smaller payload is fetched successfully or the cache is cleared.Environment
main/ 10.15.1)dalvik.vm.heapgrowthlimit: Tecno, Infinix, Itel, Moto E20, and similar), both 32-bit and 64-bit ARMDebug logs that reproduce the issue
[Purchases] - WARN ⚠️ Unable to start a network connection due to a network configuration issue: Unable to resolve host "api.revenuecat.com": No address associated with hostname... SSL handshake aborted: ...... failed to connect to api.revenuecat.com/<ip> (port 443) ...Unable to resolve host "api-production.8-lives-cat.io"and[Purchases] - WARN Error fetching offerings. Using disk cache.Steps to reproduce, with a description of expected vs. actual behavior
paywall_componentsaccount for ~89% of the payload, and thecomponents_localizationstables within them are the bulk of that (~70% of the total payload)).api.revenuecat.com(DNS/SSL/TCP-levelIOException) → SDK fails over toGET /v1/offeringson the fallback host.DeviceCache.cacheOfferingsResponse, whoseJSONObject.toString()requires a ~37.7MB contiguous allocation mid-serialization → OOM on 128MB-heap devices. The ETag store read path OOMs on the same payload from the other direction.X-Platform/X-Versionheader).Other information
Crash (1) —
DeviceCache.cacheOfferingsResponse:Crash (2) —
ETagPayloadStore.read:Related issue:
OutOfMemoryErrorinETagManager.serializewhen caching large /offerings response #3628 (previous OOM inHTTPResultWithETag.serialize, fixed in 10.14.x — no longer occurring for us).