hermes: Memory leak when using fetch requests in react native

Bug Description

https://github.com/facebook/react-native/issues/39100

Issue above is opened on react-native repo but after investigation I made I can confirm that it is related definitely to hermes. On react-native 0.69 there is no memory leak when hermes is turned off.

I’ve rechecked it on latest version and behaviour is the same - memory leak with turned on hermes, proper ram cleaning when hermes is turned on. Provided demo in the issue above is for the newer react native version.

  • I have run gradle clean and confirmed this bug does not occur with JSC

Hermes version: 0.11 React Native version (if any): 0.69, 0.72.5 OS version (if any): Platform (most likely one of arm64-v8a, armeabi-v7a, x86, x86_64):

Steps To Reproduce

To check example with memory leak:

  1. Open perf monitor
  2. Press start button
  3. Check ram usage

To check example without memory leak:

  1. Add :hermes_enabled => false in Podfile
  2. Do steps 1-3 from above

code example: https://github.com/clemensmol/rn-fetch-memoryleak

The Expected Behavior

No memory leak when doing fetch requests

About this issue

  • Original URL
  • State: closed
  • Created 9 months ago
  • Comments: 15 (8 by maintainers)

Commits related to this issue

Most upvoted comments

Hey @Vadko, thanks for preparing this repro. I have been trying to reproduce the issue, and do see a steep sustained increase in memory consumption without the call to globalThis.gc() on both Android and iOS. (I modified your repro to invoke fetch every 50ms instead of 500ms to make the issue more severe)

I then modified your example to periodically invoke the GC (every 100 ms). With that change, the Android memory increase seemed to be resolved. On iOS, I still observed a very gradual leak. Looking at it under XCode’s allocation profiler, the primary culprit is the CFString (immutable) class of allocations although I also see the NSURLInternal size increasing over time. I don’t know what the source of these allocations are.

However, that gradual leak also appears under JSC, which means that isn’t a Hermes specific issue.

So to summarise some inferences:

  1. Hermes is not running the GC frequently enough, which is causing network requests to accumulate and take up memory. This is a known difference from JSC, that (for now) can be addressed by manually calling globalThis.gc().
  2. There seems to be a real memory leak somewhere in the networking code for React Native. The leak is clearly present in the repro you shared in the two allocation classes I identified above, and appears under both Hermes and JSC. Someone familiar with React Native should investigate the source of these allocations.

Hi!

We’re facing a similar issue in one of our apps with low memory usage constraints, and can indeed reproduce with the example that @vadko provided 👍

I’ve changed the example to make more API calls (full code here)

Essentially, I:

  • start the app
    • RAM is ~130MB
  • start making 100s of API calls
    • RAM goes up to ~400MB (or higher if we don’t stop)
  • stop making API calls
    • RAM stagnates at ~400MB
  • trigger manually JS GC
    • RAM goes down again to ~130MB

Im still having a hard time understanding why Hermes isn’t garbage collecting as expected. Garbage collection does seem to be happening as shown in the details below, but is not fully collecting everything.

The fact that running gc manually fixes the RAM increase makes it seem to me that there’s no memory leak, and perhaps no issue on React Native fetch side?

Plus, as shown in the details below, in debug, there seems to be a discrepancy by the JS size reported by the Hermes debugger (which increases as expected) and the JS size reported by HermesInternal.getInstrumentedStats() (which doesn’t)

Would love some help investigating further though 🙏 It also seems linked to https://github.com/facebook/hermes/issues/982

Experiment details

Device

Measures shown below are taken with a Pixel 8 Android 14, however we’ve also tested with Hades incremental on a Android 9 device

Here’s the result of HermesInternal.getRuntimeProperties() for the Pixel:

{
  'Bytecode Version': 94,
  'Builtins Frozen': false,
  'VM Experiments': 0,
  Build: 'Release',
  GC: 'hades (concurrent)',
  'OSS Release Version': 'for RN 0.72.6',
  'CommonJS Modules': 'None'
}

1. First, let’s start the app

Metrics gathered from adb shell dumpsys meminfo and HermesInternal.getInstrumentedStats()

Metric Value
RAM used: 126MB
Java Heap 3MB
Native Heap 10MB
Hermes JS allocated bytes 2MB
Hermes Total JS allocated bytes 2MB

This is the full result of HermesInternal.getInstrumentedStats()

{ js_VMExperiments: 0,
  js_numGCs: 0,
  js_gcCPUTime: 0,
  js_gcTime: 0,
  js_totalAllocatedBytes: 1759136,
  js_allocatedBytes: 1759136,
  js_heapSize: 8388608,
  js_mallocSizeEstimate: 0,
  js_vaSize: 8388608,
  js_markStackOverflows: 0 }

2. Then let’s make a few 100s API calls and stop after a while

RAM rises up progressively. Note that it would go up until crashing the app or receiving a memory pressure event and finally running Hades Garbage collection.

Metric Value
RAM used: 429MB
Java Heap 212MB
Native Heap 103MB
Hermes JS allocated bytes 5MB
Hermes Total JS allocated bytes 214MB
{ js_VMExperiments: 0,
  js_numGCs: 112,
  js_gcCPUTime: 0.3280000000000002,
  js_gcTime: 0.3430000000000002,
  js_totalAllocatedBytes: 213710856,
  js_allocatedBytes: 5517344,
  js_heapSize: 12582912,
  js_mallocSizeEstimate: 0,
  js_vaSize: 12582912,
  js_markStackOverflows: 0 }

The number of GC triggered also went up on JS side, which is surprising! Garbage collection does seems to happen which explains why js allocated bytes stays low. But is the stat correct? As shown at the end of the report, there’s a discrepancy with the one reported in the Hermes debugger for instance

On Java side, the garbage collector is also trying to free memory but can’t (and increases heap size), probably because the JS side holds the instances.

3. Wait a few minutes

In case garbage collection happens, but nope, no changes The number of GC on JS side is the same.

4. Trigger manually JS garbage collection

Metric Value
RAM used: 133MB
Java Heap 4MB
Native Heap 19MB
Hermes JS allocated bytes 1MB
Hermes Total JS allocated bytes 214MB
{ js_VMExperiments: 0,
  js_numGCs: 116,
  js_gcCPUTime: 0.4140000000000002,
  js_gcTime: 0.4300000000000002,
  js_totalAllocatedBytes: 214355544,
  js_allocatedBytes: 958096,
  js_heapSize: 8388608,
  js_mallocSizeEstimate: 0,
  js_vaSize: 8388608,
  js_markStackOverflows: 0 }

Running in debug mode

Making the same experiment in debug and checking out the Hermes debugger in Flipper, we can see the heap snapshot increases in size, while HermesInternal.getInstrumentedStats() doesn’t seem to report it.

Biggest difference between my 2 heap snapshots are the 78k strings allocated, corresponding to the request payload:

image

  • Stats after starting the app:

RAM used: 217MB

{ js_VMExperiments: 0,
  js_numGCs: 1,
  js_gcCPUTime: 0.002,
  js_gcTime: 0.002,
  js_totalAllocatedBytes: 6121376,
  js_allocatedBytes: 3227552,
  js_heapSize: 8388608,
  js_mallocSizeEstimate: 0,
  js_vaSize: 8388608,
  js_markStackOverflows: 0 }
  • Stats after making 100s of API calls and stop

RAM used 490MB

{ js_VMExperiments: 0,
  js_numGCs: 173,
  js_gcCPUTime: 2.457999999999995,
  js_gcTime: 2.573999999999996,
  js_totalAllocatedBytes: 220705392,
  js_allocatedBytes: 9991808,
  js_heapSize: 20971520,
  js_mallocSizeEstimate: 0,
  js_vaSize: 20971520,
  js_markStackOverflows: 0 }

Got it. We will try to reproduce it internally. Thanks for creating the repro!

@Almouro Thank you for sharing these detailed findings. They exhibit very unusual memory behaviour, and were exactly the evidence I needed for a full investigation of what is going on.

In particular, the heap snapshots showing that Hermes is allocating and retaining very large strings that are only freed by explicit calls to the GC suggested that something was affecting the GC’s ability to collect the large strings.

I’ve spent some time investigating this and I believe the root cause of the behaviour you’re observing is a very subtle interaction between WeakMaps and the GC. I have a relatively simple mitigation that addresses the collection of those strings, and improves memory consumption dramatically in your repro.

That said, this isn’t a general solution for the problem in this issue. The underlying issue with untracked native resources remains, and the repro in the initial report in this issue still stands.

How did you find that it was 48 bytes of JS vs the 250KB of native in this example

Objects in Hermes are around 48 bytes, and the 250KB was based on the size of the blob being downloaded. This is just a rough estimate, for the purpose of discussion.

can it increase as much as it can until it reaches the max heap size?

It certainly can, but only if the application needs a lot of working memory. In this case, the GC was overestimating the amount of working memory the application needed because it couldn’t collect the large strings.

Closing this since the WeakMap leak has been fixed, and we have added an API for tracking external memory in aae2c4260781178d7b2ca169811b3bfca9f924d2. That allows you to inform the GC that a given object retains some native memory.