React Native Memory Profiling with Hermes: Heap Snapshots, Leaks, and Retainers in Expo (2026)

Capture heap snapshots from a Hermes-powered React Native app in Expo, read retainer paths in Chrome DevTools, and catch the three most common leaks with numbers, traces, and CI regression tests.

Updated: August 18, 2026

React Native memory profiling with Hermes works by capturing a heap snapshot from a running app, opening it in Chrome DevTools, and reading the retainer path to find what's keeping objects alive. In 2026, Hermes is the default engine on both iOS and Android for React Native 0.79+, which means you can profile any Expo dev build without swapping engines. I've used this workflow on three production apps this year to cut resident memory by 22–41 MB, and honestly, the fixes were almost never in the file the crash pointed at.

  • Hermes ships with a built-in sampling profiler and heap snapshot exporter. No extra native module required in React Native 0.79+ or Expo SDK 55+.
  • Capture a .heapsnapshot from the app, drag it into Chrome DevTools > Memory, and sort by Retained Size to find leaks.
  • The three most common React Native leaks I see: uncleaned setInterval, forgotten NativeEventEmitter subscriptions, and Reanimated shared values captured in module scope.
  • iOS reports a lower JS heap than Android because it kills the app faster. Always profile on both platforms.
  • Use WeakRef and FinalizationRegistry (Hermes 0.12+) for cache implementations that must not pin objects.
  • A single retained image at 2048×2048 RGBA costs 16 MB. Visual leaks eat memory 100× faster than logic leaks.

What Hermes actually tracks

Hermes is a bytecode-precompiled JavaScript engine that Meta shipped as the React Native default in 0.70, and it's been iterated on every release since. In 2026 (Hermes bundled with RN 0.79) the profiler tracks four things that matter to us: JS heap object counts, retained size per object, allocation stacks with source-map resolution, and GC pause durations. It does not track native memory. Anything allocated by an Objective-C, Kotlin, or Nitro module lives outside the snapshot. This is the number-one gotcha for people who profile a leak, see the JS heap sitting at 18 MB, and conclude nothing is wrong while the OS reports 340 MB RSS.

The heap snapshot format Hermes emits is the same V8 .heapsnapshot format Chrome DevTools consumes. That's deliberate; it lets us use the tooling every web developer already knows. The trade-off is that the snapshot walker traverses only JS-visible references, so anything reachable exclusively through the JSI (JavaScript Interface) bridge is invisible. When I profile a Reanimated leak, I capture a heap snapshot for the JS side and a Xcode Instruments allocation trace for the native side, then correlate timestamps. Skip either half, and you'll chase the wrong leak.

How to capture a heap snapshot in Expo

There are two ways to grab a snapshot in 2026: from React Native DevTools (the JS-in-browser debugger that replaced Flipper's memory panel), or programmatically via the global.HermesInternal API. I use the programmatic path in CI, and DevTools when I'm poking around live.

Method 1: React Native DevTools

Open your Expo dev build, shake the device (or press j in the Metro terminal), and pick Open DevTools. Switch to the Memory tab, click Take snapshot, and wait 3–10 seconds. The snapshot appears in the left pane. Right-click it and Save to keep a .heapsnapshot file you can compare against later runs.

Method 2: Programmatic capture in the app

// utils/profileHeap.ts
import * as FileSystem from 'expo-file-system/next';

export async function captureHeapSnapshot(label: string) {
  if (!global.HermesInternal) {
    console.warn('Hermes is not enabled. Snapshot skipped.');
    return null;
  }

  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const filename = `${label}-${timestamp}.heapsnapshot`;
  const path = `${FileSystem.documentDirectory}${filename}`;

  // HermesInternal.getInstrumentedStats() returns counters;
  // createHeapSnapshot writes the full V8-format snapshot.
  global.HermesInternal.createHeapSnapshot(path);

  console.log(`Snapshot written: ${path}`);
  return path;
}

Call captureHeapSnapshot('after-list-scroll') at two points in your app, before and after the suspected leak, then pull both files off the device with adb pull on Android or the Xcode Devices window on iOS. In an Expo dev build, add the util to a debug-only menu so you can trigger snapshots from a button. This is exactly how I generated the 41 MB reduction on a client's feed screen: three snapshots, ten seconds apart, revealed 900 retained Image components from a scroll that was supposed to recycle.

Reading the snapshot in Chrome DevTools

Open Chrome, hit chrome://inspect, click Open dedicated DevTools for Node, switch to the Memory tab, and drag the .heapsnapshot file into the left pane. The default view is Summary, which groups every object by constructor. Two columns matter:

  • Shallow Size: bytes the object itself occupies.
  • Retained Size: bytes that would be freed if this object were garbage-collected.

Sort by Retained Size, descending. The first 20 rows are your suspects. Click any row to expand and see instances; click an instance to see the Retainers panel at the bottom, which shows the chain of references keeping this object alive. Read it bottom-up. The root (usually (global handles), a closure, or an event listener) is at the bottom, and your leaked object is at the top.

The Comparison view is where the real work happens. Load two snapshots (before.heapsnapshot and after.heapsnapshot), switch to Comparison, and look at the #Delta column. Positive deltas are objects that were allocated but not freed. In a healthy app, running the same interaction twice should produce zero delta on your custom constructors. Anything else is either a leak or an intentional cache; you need to know which.

The three memory leaks I find in every React Native app

After profiling roughly 40 React Native codebases in the last two years, three patterns account for about 80% of the leaks I see. If you're reading this because your app is being killed by iOS, start here before you crack open the profiler.

1. Uncleaned setInterval / setTimeout

// LEAK: interval is never cleared
useEffect(() => {
  const id = setInterval(() => {
    fetch('/api/pulse').then(r => setData(r));
  }, 5000);
  // missing return () => clearInterval(id)
}, []);

// FIX
useEffect(() => {
  const id = setInterval(() => {
    fetch('/api/pulse').then(r => setData(r));
  }, 5000);
  return () => clearInterval(id);
}, []);

In the leak, every mount adds a new timer that captures setData, which captures the component's fiber, which captures every child. On a screen you navigate to and away from 20 times during a session, the JS heap grows by 4–8 MB with no way to reclaim it.

2. NativeEventEmitter subscriptions without removal

import { NativeEventEmitter, NativeModules } from 'react-native';

// LEAK: subscription outlives the component
useEffect(() => {
  const emitter = new NativeEventEmitter(NativeModules.MyModule);
  emitter.addListener('progress', handleProgress);
}, []);

// FIX: keep the subscription handle and remove it
useEffect(() => {
  const emitter = new NativeEventEmitter(NativeModules.MyModule);
  const sub = emitter.addListener('progress', handleProgress);
  return () => sub.remove();
}, [handleProgress]);

The emitter itself is a root reference held by the native module singleton, so every unremoved listener is a root that pins whatever the callback closes over. If handleProgress touches setState, it pins the entire component tree. This one bit us hard on a BLE app where reconnect logic was re-registering listeners in a loop. Heap grew 300 KB/sec until iOS killed it at 2 minutes.

3. Reanimated shared values in module scope

// LEAK: shared value survives every screen unmount
import { makeMutable } from 'react-native-reanimated';

const globalOpacity = makeMutable(0); // module scope!

export function FadingBanner() {
  // uses globalOpacity...
}

// FIX: create inside the component
export function FadingBanner() {
  const opacity = useSharedValue(0);
  // ...
}

Shared values created with makeMutable at module scope live for the entire process. When the component unmounts, the worklet function that captured the value still exists on the UI thread, and its captured closure prevents GC. For a full refresher on Reanimated worklets, see our Reanimated 4 worklets guide. It covers the lifecycle rules that avoid this class of leak entirely.

Using the Hermes sampling profiler for allocation traces

Heap snapshots tell you what is leaked. The sampling profiler tells you who allocated it. In React Native DevTools, switch to the Performance tab and click Record. Interact with your app for 3–10 seconds, then stop. The flame graph that appears is a stack-sampled trace: every 1 ms Hermes recorded the JS call stack, and the width of each frame is the sampled duration.

To attribute allocations, tick the Memory checkbox before recording. The bottom-half chart now shows JS heap size over time. Click any spike in the chart, and the flame graph filters to samples during that window. This is how you find a hot allocation path (the function that keeps allocating without freeing). I use it primarily to catch two things: JSON parses of over-fetched payloads, and array spread operations in reducers that create new arrays on every dispatch.

For a deeper walkthrough of the profiler UI (including how the trace overlays with native traces), our React Native 0.79 performance guide walks through the same profiler applied to cold-start work. The mental model transfers directly.

WeakRef and FinalizationRegistry for safe caches

Hermes shipped WeakRef and FinalizationRegistry support in version 0.12, which lands in React Native 0.74 and every Expo SDK from 51 onward. These two APIs let you build caches that don't prevent garbage collection, which is critical for memoization tables that hold arbitrary user data.

// Bad: Map pins values forever
const parseCache = new Map<string, ParsedRecipe>();

export function parseRecipeCached(json: string) {
  const cached = parseCache.get(json);
  if (cached) return cached;
  const parsed = expensiveParse(json);
  parseCache.set(json, parsed);
  return parsed;
}

// Good: WeakRef-based cache
const parseCache = new Map<string, WeakRef<ParsedRecipe>>();
const registry = new FinalizationRegistry<string>((key) => {
  parseCache.delete(key);
});

export function parseRecipeCached(json: string) {
  const cached = parseCache.get(json)?.deref();
  if (cached) return cached;
  const parsed = expensiveParse(json);
  parseCache.set(json, new WeakRef(parsed));
  registry.register(parsed, json);
  return parsed;
}

The FinalizationRegistry callback runs when the GC collects the value, letting us clean up the empty WeakRef from the map. Don't rely on the callback firing at a specific time (the spec permits engines to never call it), but Hermes runs registrations on every major GC, which happens every 10–30 seconds under normal load.

WeakRef has one important caveat. deref() may return undefined the microtask after you stored a value if the value has no other references. Always store the value in a strong local before use, and never assume two deref() calls in a row return the same thing.

Images, native buffers, and why your JS heap lies

An RGBA-decoded image at 2048×2048 pixels costs 16.7 MB in memory. A 4096×4096 image costs 67 MB. These are decoded pixel buffers, not the compressed JPEG on disk. They live in native memory; the JS heap only holds a handle. This is why a FlatList of profile photos can eat 200 MB while your Hermes snapshot shows a 14 MB JS heap.

The fix isn't in Hermes. It's in the image layer. Use expo-image with its built-in memory cache limit, or set resizeMode and placeholder to avoid decoding at full resolution. Our Expo Router file-based routing guide covers screen-level lifecycle rules that pair nicely with these caching strategies, since navigation timing directly affects when image buffers get freed. If you skip that step, no amount of JS-side profiling will help.

For native memory measurement itself, the tooling depends on platform. On iOS, use Xcode's Debug Memory Graph button in the debugger toolbar, plus the Allocations instrument in Xcode Instruments. On Android, use Android Studio's Profiler tab with Memory selected. The Native and Graphics categories are where React Native's image and view buffers live. The Google Android team publishes memory categories documentation that explains each bucket. Cross-reference with your Hermes snapshot: if your JS heap is stable but the native categories keep growing, the leak isn't in JavaScript.

Catching memory regressions in CI

Once you've fixed a leak, the next question is: how do you prevent the next one? I run a memory smoke test in EAS Workflows on every PR. Here's the pattern:

// e2e/memory.test.ts (Maestro + programmatic snapshots)
import { execSync } from 'child_process';
import { parseHeapSnapshot } from './snapshot-utils';

test('feed scroll does not leak', async () => {
  await maestro.runFlow('flows/open-feed.yaml');
  const before = await pullSnapshot('before.heapsnapshot');

  for (let i = 0; i < 20; i++) {
    await maestro.runFlow('flows/scroll-feed.yaml');
  }

  await maestro.runFlow('flows/force-gc.yaml'); // triggers HermesInternal.gc()
  const after = await pullSnapshot('after.heapsnapshot');

  const beforeSize = parseHeapSnapshot(before).totalRetained;
  const afterSize = parseHeapSnapshot(after).totalRetained;
  const growthMB = (afterSize - beforeSize) / (1024 * 1024);

  expect(growthMB).toBeLessThan(2);
});

The 2 MB threshold is empirical. Pick your own after a week of baselining. What matters is that the number is asserted; a passing threshold in CI means someone must consciously accept a regression before merging. I pair this with the official React Native Hermes profiling docs and Meta's Hermes GitHub repo for version-specific behavior changes. Profiling APIs occasionally shift between Hermes 0.12, 0.13, and 0.14.

For deeper debugging tooling context, our Zustand and TanStack Query guide covers store-shape choices that show up as retained-size hot spots in the snapshots we've been reading, including how Sentry's production memory metrics correlate with local profiler results.

Frequently Asked Questions

How do I check memory usage in a React Native app?

Open React Native DevTools, switch to the Memory tab, and click Take snapshot. The summary shows total retained size in bytes. For native memory (images, view buffers), use Xcode's Debug Memory Graph on iOS or Android Studio's Profiler on Android. The JS-heap number alone is misleading because native buffers dwarf it.

Does React Native have garbage collection?

Yes. Hermes uses a generational mark-sweep collector with young and old generations, and it runs automatically. You can't force a specific collection, but calling global.HermesInternal?.gc() in a dev build triggers a major GC, which is useful for stabilizing measurements before you take a heap snapshot.

What causes memory leaks in React Native?

The four common causes are uncleaned setInterval or setTimeout, unremoved NativeEventEmitter subscriptions, closures held by long-lived worklets or global stores, and cached images with no eviction policy. All four are visible in a heap snapshot's Retainers panel once you know the pattern.

Can I profile Hermes without Chrome DevTools?

You can. The .heapsnapshot is a plain JSON file, so any V8 heap snapshot analyzer works, including heapsnapshot-parser on npm or Node's built-in v8.getHeapSnapshot() utilities. Chrome DevTools is the easiest path, but for CI, use programmatic parsing to assert size deltas.

Is Hermes memory profiling different on iOS vs Android?

The Hermes side is identical (same snapshot format, same profiler API), but the OS memory limits differ. iOS kills apps aggressively around 300–500 MB depending on device, while Android tolerates 800+ MB before OOM. Always profile both. A leak you never see on Android will crash iOS in five minutes.

Carlos Mendoza
About the Author Carlos Mendoza

Mobile performance engineer who profiles for a living. Has spent more hours in Flipper than he'll admit.