Skip to content

OutOfMemoryError caching large offerings responses (DeviceCache.cacheOfferingsResponse, ETagPayloadStore.read) #3867

Description

@pmarino

Describe the bug

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:

  1. DeviceCache.cacheOfferingsResponse (DeviceCache L:601) — JSONObject.toString() before the SharedPreferences write fails on a single 37,748,744-byte char[] allocation.
  2. 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.

  1. Environment

    1. Platform: Android
    2. SDK version: v10.14.1 (crash code paths appear unchanged on main / 10.15.1)
    3. 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
    4. Android Studio version: N/A (production crash reports)
    5. 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.
  2. Debug logs that reproduce the issue

    • 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.
  3. 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:
      1. 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)).
      2. Device fails to reach api.revenuecat.com (DNS/SSL/TCP-level IOException) → SDK fails over to GET /v1/offerings on the fallback host.
      3. 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.
      4. 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.
  4. 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)
    

    Related issue: OutOfMemoryError in ETagManager.serialize when caching large /offerings response #3628 (previous OOM in HTTPResultWithETag.serialize, fixed in 10.14.x — no longer occurring for us).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions