skip to content

In React Native, how do you compare Hermes heap snapshots to prove that a screen leaks, and how do you find what retains it?

level: seniorimportance: should knowfreq 30%

answer

  1. warm up, then baseline
  2. repeat the action N times
  3. counts growing by multiples of N
  4. retained size, not shallow size
  5. follow retainers to the root

basics

~20 s

Take a baseline snapshot after warming up, open and close the screen a fixed number of times, take another, and compare: objects whose count grows with the repetitions are leaking. Their retainer path shows which listener, timer, cache or closure holds them.

solid answer

~50 s

In a debug build I open React Native DevTools, visit the destination screen of the travel feed once to warm caches, and take a **baseline** heap snapshot. Then I open and close that screen, say, five times and take a second snapshot. Comparing the two, anything that grew by about five instances per type — my screen's closures, its data arrays, its fetched items — is a leak candidate; things that grew once are caches. I sort by **retained size** to find what holds the most memory, then read the **retainers** path from the object up to a root: typically a subscription list in an event emitter, a timer, or a module-level cache, holding a closure that captured the screen's state. After fixing, I repeat the same steps and expect no growth per visit. Sizes differ from release, but what retains what does not.

code

typescript · 15 lines
typescript
type Listener = (destinationId: string) => void;

const listeners = new Set<Listener>();

export const recentlyViewed = {
  subscribe(listener: Listener): () => void {
    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  },
  markSeen(destinationId: string): void {
    listeners.forEach(listener => listener(destinationId));
  },
};

go deeper

for a junior

Know that a heap snapshot lists JavaScript objects and that comparing two of them shows what grew.

for a middle

Explain the warm-up, baseline, repeat and compare procedure, and the difference between shallow and retained size.

for a senior

Show how you follow retainers from a leaked object to the subscription, timer, cache or closure that holds it, and how you verify the fix.

for a principal

Discuss turning the procedure into a repeatable leak check for critical flows and deciding which memory growth is acceptable caching.

## What a heap snapshot is A **heap snapshot** is a full picture of every object in the JavaScript heap at one moment — in React Native, the **Hermes** heap — with the references between them. React Native DevTools can record one from its Memory panel in a development build. A single snapshot tells you what exists; **comparing** snapshots tells you what accumulates, which is what a leak is. Key terms: - **Shallow size** — the memory an object occupies itself. - **Retained size** — the memory that would be freed if the object were collected, including everything only it keeps alive. - **Retainers** — the chain of references from a GC root (globals, the module registry, native-held callbacks) down to the object. As long as one chain exists, the object cannot be collected. ## The comparison procedure 1. **Build and open a debug build** with React Native DevTools attached — DevTools is disabled in release builds. 2. **Warm up.** Open and close the suspect screen once. First visits fill caches and load modules, and those one-time allocations are not leaks. 3. **Take a baseline snapshot.** 4. **Repeat the action a fixed number of times** — open and close the destination screen five times, ending in the same state as the baseline. 5. **Take a second snapshot** and compare it with the baseline. ## Reading the comparison | Pattern between snapshots | Likely meaning | |---|---| | a type grows by about N for N repetitions | one object leaked per visit — a strong leak signal | | a type grows once, then stays flat | a cache or lazily created singleton | | a type grows and shrinks between runs | ordinary garbage not yet collected | Then: - **Filter for your own names** — the screen component, its data types, named handler functions — rather than engine internals. Named functions are easier to find than anonymous ones. - **Sort by retained size** to find which leaked objects hold the most memory; a small closure can retain a large feed array. - **Open the retainers** and walk up to a root. The first reference you own is the bug. ## Typical retainer paths in React Native - **An event subscription never removed**: the emitter's listener registry → your handler closure → the screen's state setter and data. Fix: keep the subscription from `AppState.addEventListener`, `Keyboard.addListener` or `Dimensions.addEventListener` and call `remove()` in cleanup. - **A timer never cleared**: the timer registry → the interval callback → everything it captures. Fix: `clearInterval` / `clearTimeout` in cleanup. - **A module-level cache**: a module's top-level `Map` or array → every item ever loaded. Fix: bound it or key it by something that gets evicted. - **A long-lived closure**: a callback handed to a global service (a store, a logger, a native module) that captured the whole screen scope. Fix: unregister it, or capture only the values it needs. ## Retained closures, specifically A **closure** keeps alive the variables it references from its enclosing scopes. A handler that references `items` keeps the entire `items` array reachable, and that array may hold every destination the user scrolled past, with nested objects for each. The handler itself is tiny — its **shallow size** is small — which is why sorting by **retained size** is essential: it reveals that a few bytes of function are holding megabytes of data. ## A worked example from the travel feed Suppose each visit to a destination screen registers a callback with a module-level `recentlyViewed` service so it can refresh a "seen" badge, and never unregisters it. The comparison after five visits shows five more copies of the screen's handler closure, five more copies of its destination objects, and five more photo-gallery arrays. The retainers path reads: module `recentlyViewed` → its listeners `Set` → handler closure → `gallery` array. The fix is to return an unsubscribe function from the service and call it in the effect cleanup — then the same five visits leave nothing behind. ## Verifying the fix and its limits - **Re-run the same procedure** after the fix: the per-visit growth should be gone. - **Track trends in release** with `performance.memory.usedJSHeapSize`, which works outside DevTools, during a repeated-navigation run. - **Remember the scope**: snapshots cover the JavaScript heap only. Decoded images and native views are outside it; if process memory grows while snapshots do not, use a native memory profiler. - **Debug sizes are not release sizes**: dev-mode code allocates extra objects. The retainer chains are what carry over.

  • Why warm up before taking the baseline React Native heap snapshot?
    The first visit to a screen loads modules, fills caches and creates singletons that stay for the app's lifetime. If the baseline is taken before that, those one-time allocations look like growth in the comparison. Warming up first means the only difference left between snapshots is what each extra visit adds.
  • A leaked handler shows a tiny shallow size in the snapshot. Why can it still be the main problem?
    Shallow size counts only the function object itself. Its retained size includes everything reachable only through it — the captured state, the feed array, each item's nested data. Sorting by retained size exposes a small closure holding megabytes, which is exactly how leaked listeners look.

saying these in an interview costs you the question

  • One heap snapshot is enough to prove a leak
  • Sort by shallow size to find what holds the most memory
  • Take the baseline before ever visiting the screen
  • Heap snapshots include decoded image bitmaps
  • Capture the snapshots from a release build with DevTools attached