Braze Android SDK Version
40.1.1 (also reproduced on 43.0.0)
Steps To Reproduce
- Log custom events faster than dispatch drains them — e.g. ~10-15 events/sec while scrolling a feed with impression tracking. Dispatch drains at most 32 events per request (sustained ~1 request/30s under the server-delivered rate limit), so the pending-event map grows without bound (verified past 2MB / 8,000+ events — no cap observed). It persists across sessions.
- Keep logging once the map is ~1.5MB+: the heap fills with dozens of concurrent full-copy serializations of the map (each a map-sized char[]), each pinned by a parked runBlocking worker on the shared coroutine pool.
- Restart the app with a large persisted map: the heap reaches ~106MB within 44 seconds of launch from the same mechanism.
Why this happens (verified via bytecode of 40.1.1 and 43.0.0):
- All pending events are stored under a single DataStore preferences key as one serialized JSON map (
{uuid: eventJson}).
- Every
logCustomEvent triggers: read whole map → add one event → re-serialize the ENTIRE map → commit via runBlocking (DataStoreProvider.write$android_sdk_base_release).
Expected Behavior
Logging an event should have O(event) cost: appending one event should not re-serialize the entire pending store, should not block shared pool threads with runBlocking, and heap usage should stay flat regardless of how many events are queued. Per-event keys or append-only storage with non-blocking commits would achieve this — similar in spirit to the 43.0.0 fix for synchronous disk I/O in Card.logImpression().
Actual Incorrect Behavior
Measured on 40.1.1: with a 1.9MB pending map, Dalvik PSS climbs to ~311MB with 71 blocked DefaultDispatcher-worker threads; ~67% of a 400MB heap dump is dozens of concurrent full copies of the map, each pinned by a parked runBlocking worker. Production apps at the 512MB largeHeap limit crash with OutOfMemoryError — worst right after launch, since the persisted map immediately re-enters the write path. UI throughput also collapses (severe jank / ANRs) because the shared Default/IO pool is starved by blocked workers.
Reproduced on 43.0.0: the pending map grew linearly without bound (16B → 1.7MB, 8,000+ events); once past ~1.5MB, PSS jumped from ~70MB to 280–315MB with 68 blocked workers, and a 30-second scroll loop took 15+ minutes to complete.
Additional note: 43.0.0 silently discarded pending events written by 40.1.1 (2,346 events dropped at first load, only 47 dispatched) — apps upgrading with a backlog lose queued analytics.
We can share heap-dump statistics on request.
Verbose Logs
V Braze .com.braze.storage.DataStoreProvider: Writing data:{"<uuid>":"{\"name\":\"ce\",...}", ...entire pending map re-serialized on every single event write...}
I Braze .h: Max number of events per dispatch reached: 32 . No more events will be included in this dispatch
D Braze .j: Adding event to storage with uid <uuid>
D Braze .j: Deleting event from storage with uid <uuid>
Additional Information
Environment: Android app with largeHeap (512MB limit), minSdk 28, Kotlin 2.4.10. Reproduced on an Android 14 emulator and observed across our production fleet (OOM rate roughly doubled after our event volume increased).
How we measured: pending-map file size sampled via adb, heap composition via hprof analysis (two dumps: 160MB at launch+44s, 412MB at peak), thread census via ps -T. Happy to share the histogram numbers, retained-size breakdowns, or reproduction scripts.
Braze Android SDK Version
40.1.1 (also reproduced on 43.0.0)
Steps To Reproduce
Why this happens (verified via bytecode of 40.1.1 and 43.0.0):
{uuid: eventJson}).logCustomEventtriggers: read whole map → add one event → re-serialize the ENTIRE map → commit viarunBlocking(DataStoreProvider.write$android_sdk_base_release).Expected Behavior
Logging an event should have O(event) cost: appending one event should not re-serialize the entire pending store, should not block shared pool threads with runBlocking, and heap usage should stay flat regardless of how many events are queued. Per-event keys or append-only storage with non-blocking commits would achieve this — similar in spirit to the 43.0.0 fix for synchronous disk I/O in Card.logImpression().
Actual Incorrect Behavior
Measured on 40.1.1: with a 1.9MB pending map, Dalvik PSS climbs to ~311MB with 71 blocked DefaultDispatcher-worker threads; ~67% of a 400MB heap dump is dozens of concurrent full copies of the map, each pinned by a parked runBlocking worker. Production apps at the 512MB largeHeap limit crash with OutOfMemoryError — worst right after launch, since the persisted map immediately re-enters the write path. UI throughput also collapses (severe jank / ANRs) because the shared Default/IO pool is starved by blocked workers.
Reproduced on 43.0.0: the pending map grew linearly without bound (16B → 1.7MB, 8,000+ events); once past ~1.5MB, PSS jumped from ~70MB to 280–315MB with 68 blocked workers, and a 30-second scroll loop took 15+ minutes to complete.
Additional note: 43.0.0 silently discarded pending events written by 40.1.1 (2,346 events dropped at first load, only 47 dispatched) — apps upgrading with a backlog lose queued analytics.
We can share heap-dump statistics on request.
Verbose Logs
V Braze .com.braze.storage.DataStoreProvider: Writing data:{"<uuid>":"{\"name\":\"ce\",...}", ...entire pending map re-serialized on every single event write...} I Braze .h: Max number of events per dispatch reached: 32 . No more events will be included in this dispatch D Braze .j: Adding event to storage with uid <uuid> D Braze .j: Deleting event from storage with uid <uuid>Additional Information
Environment: Android app with largeHeap (512MB limit), minSdk 28, Kotlin 2.4.10. Reproduced on an Android 14 emulator and observed across our production fleet (OOM rate roughly doubled after our event volume increased).
How we measured: pending-map file size sampled via adb, heap composition via hprof analysis (two dumps: 160MB at launch+44s, 412MB at peak), thread census via ps -T. Happy to share the histogram numbers, retained-size breakdowns, or reproduction scripts.