Heap Snapshot System Entries Explained: (closure), (array), (string) and More

The top of your heap snapshot’s Summary view is dominated by groups in parentheses — (array), (string), (closure), (compiled code), (system) — and you need to know which of them are normal engine overhead and which are hiding your leak. This guide belongs to Interpreting Heap Snapshots for Memory Analysis in the Browser DevTools & Performance Profiling Workflows section.

Symptom Root Cause Immediate Action Measurable Impact
(array) retained size grows by MB per flow Backing stores of your arrays, Maps or Sets are growing Expand the group and open retainers of the largest entries Leads to the collection that is never cleared
(string) is the largest group Many retained strings: JSON payloads, logs, cache keys Sort by shallow size; inspect the biggest strings’ retainers Often frees 10–100 MB by dropping cached raw responses
(closure) count grows linearly with a flow New functions created per render and retained by listeners or maps Diff with Comparison view; check context retainers Stops per-interaction closure accumulation
(compiled code) is tens of MB Many distinct functions compiled; large bundles or eval Code-split, remove dead code, avoid new Function in loops Reduces code space and parse cost
(concatenated string) count is high Rope strings from += loops not yet flattened Build with arrays and join(), or flatten once Fewer intermediate objects and smaller retained size

Root Cause: Parentheses Mean “V8 Internal Type”, Not “Harmless”

The Summary view groups objects by constructor name. Objects created by your code or libraries are grouped under their class or constructor: Row, Promise, HTMLDivElement, Object. Objects that do not have a JavaScript constructor — engine-level representations — are grouped under names in parentheses. The parentheses tell you what kind of internal object it is, not whether it matters. Several of these groups are where application leaks actually show up, because your objects are built out of them.

(array) contains internal arrays: the elements backing store of a JavaScript Array, the hash tables inside Map and Set, property backing stores for objects with many properties, and internal descriptor arrays. When your cache Map grows to 50,000 entries, the Map object itself stays small, but its backing store in (array) grows. (string) holds string contents; a retained 8 MB JSON response lives here. (concatenated string) and (sliced string) are V8’s rope and slice representations — a string built by repeated + is a tree of concatenations until it is flattened, and a substring can be a slice that keeps its much larger parent alive, a problem covered in sliced strings retaining large parent strings.

(closure) groups function instances. Every time you evaluate an arrow function or function expression, V8 creates a new closure object pointing to shared code and a context. A component that creates five inline callbacks per render and stores them in a long-lived map makes (closure) grow in lockstep; the captured variables themselves live in system / Context objects. (compiled code) holds bytecode and machine code for functions — it grows with code volume, not with data. (system) covers maps (hidden classes), feedback vectors, and other bookkeeping, and is rarely actionable. Understanding how hidden classes and inline caches affect memory explains why (system) can still grow when objects are created with many different shapes.

The rule: treat (array), (string), (closure) and (concatenated string) as symptoms whose cause is always one level up in the Retainers pane, and treat (compiled code) and (system) as capacity signals about code volume and shape diversity.

What each parenthesised group is made of Six rows map groups to their sources. The array group comes from Array elements, Map and Set tables and large property stores, and is a common leak symptom. The string group is string contents, a common leak symptom. Concatenated string comes from plus-equals loops. Closure is function instances created per evaluation. Compiled code is bytecode and machine code, driven by code size. System is hidden classes and feedback vectors, rarely actionable. Group Built from Leak signal? (array) Array elements, Map/Set tables, property stores often — check owner (string) string contents: JSON, HTML, keys, logs often — big payloads (closure) one per evaluated function expression when count grows (concatenated string) rope nodes from repeated + / += sometimes — churn (compiled code) bytecode + optimised machine code code size, not data (system) hidden classes, feedback vectors, internals rarely actionable

Step-by-Step Fix

  1. Diff instead of reading absolute sizes. Take a baseline snapshot, run the flow five times, take a second snapshot, and open DevTools → Memory → (second snapshot) → Comparison against the first. Verification: parenthesised groups now show # New, # Deleted and Size Delta columns, and stable engine overhead nets out to near zero.
  2. Rank the parenthesised groups by Size Delta. Sort by Size Delta. Note any of (array), (string), (closure) or (concatenated string) that grew by more than a few hundred KB. Verification: you have one or two groups to investigate, not six.
  3. Expand the group and select the largest new entries. Inside (array) or (string), sort by Retained Size and click the top new entry. Verification: the Retainers pane shows the owning object — for an array store this is usually table in Map, elements in Array or properties in Object.
  4. Follow one level up to your code. The owner of the backing store is the real suspect: a Map used as a cache, an array of event records, an object used as a dictionary. Verification: the owner has a constructor or variable name you recognise.
  5. For (closure), read the closure’s context. Click a new closure entry and open its context in the retainer or containment tree to see which variables it captured and where it is registered. Verification: you can name the listener, subscription or callback map holding it.
  6. Apply the matching fix and re-diff. Bound the collection, release the payload after parsing, remove the listener, or flatten the string once. Verification: the group’s Size Delta for the same five-repetition flow is within ±100 KB.
Symptom group to owner A box labelled array group with plus 38 megabytes size delta points upwards to table in Map, which points to responseCache, a Map instance owned by the application's api module. The owner is highlighted as the place to fix by adding eviction. (array) Size Delta +38 MB table in Map backing store edge responseCache Map in api.js — fix here Read the Retainers pane upwards from the symptom the parenthesised group is where bytes live; the owner is where the bug lives

Command and Code Reference

Use case: the (array) + (string) growth pattern and its fix. An API layer caches raw response text and parsed objects forever, so both the Map’s backing store and the strings grow per request.

// Leaky: unbounded Map keyed by URL, holding the raw text AND the parsed object
const responseCache = new Map();
async function getJson(url) {
  if (responseCache.has(url)) return responseCache.get(url).data;
  const text = await (await fetch(url)).text();      // big string → (string)
  const data = JSON.parse(text);
  responseCache.set(url, { text, data });            // Map table → (array)
  return data;
}

// Fixed: keep only parsed data, cap entries, evict oldest (Map keeps insertion order)
const MAX_ENTRIES = 200;
const boundedCache = new Map();
async function getJsonBounded(url) {
  if (boundedCache.has(url)) return boundedCache.get(url);
  const data = await (await fetch(url)).json();      // raw text never retained
  boundedCache.set(url, data);
  if (boundedCache.size > MAX_ENTRIES) {
    boundedCache.delete(boundedCache.keys().next().value); // evict oldest
  }
  return data;
}

Use case: avoid (concatenated string) churn when building large output. Collect parts and join once instead of growing a rope with +=.

// Builds one flat string at the end; no rope of thousands of cons-string nodes
function toCsv(rows) {
  const lines = new Array(rows.length);
  for (let i = 0; i < rows.length; i++) {
    lines[i] = `${rows[i].id},${rows[i].name},${rows[i].total}`;
  }
  return lines.join('\n');
}

Verification and Regression Prevention

Your fix is confirmed when a five-repetition Comparison diff shows (array), (string) and (closure) with size deltas close to zero, and the owning constructor you identified — the Map, the array, the listener registry — no longer appears in the diff at all. Expect (compiled code) and (system) to move a little between runs as V8 optimises and deoptimises functions; small movements there are normal and not a regression.

A few groups deserve a note because they are easy to misread. (sliced string) entries are usually tiny themselves; their cost is the parent string they keep alive, which you will find as the parent edge in the Containment view. (number) groups heap-allocated numbers (doubles that could not be stored inline), and a growing count there usually means an array of floating-point values stored as a generic array rather than a Float64Array. (regexp) and (code) subgroups under compiled code occasionally balloon when a template engine or validation library builds a fresh regular expression per call; hoisting the pattern to module scope fixes both the allocation churn and the retained code. And (Document DOM trees) or Detached prefixed entries are not parenthesised engine types at all — they are DOM-specific groupings that belong to the detached-node workflow rather than to this one.

For prevention, add the size of the watched collections to your debug telemetry (for example log responseCache.size every minute in development) and set a heap budget in CI as described in setting a heap size budget in your CI pipeline. A budget will not tell you which group grew, but paired with a saved snapshot artifact it lets the next engineer open the Comparison view straight away.

Reading engine groups in a five-repetition diff In a five-repetition Comparison diff, array, string and closure groups with size deltas near zero plus an absent owner constructor mean the fix holds. Small movement in compiled code and system entries is normal optimisation noise. Deltas that scale with the number of repetitions mean data is still retained. (array), (string), (closure) in the diff Fixed: owning Map, array or registry absent from the diff delta ≈ 0, owner gone Normal: V8 optimises and deoptimises between runs (compiled code) moves a little Still retained: follow the owner constructor’s retainers delta scales with repetitions

Frequently Asked Questions

Is a large (compiled code) group a memory leak?

Almost never. It reflects how much code has been compiled — bundle size, number of distinct functions, and optimised versions of hot functions. It can be reduced by shipping less JavaScript, and it can grow if code generates new functions with eval or new Function repeatedly, but it does not usually grow with data.

Why does (array) grow when I only added entries to a Map?

A Map stores its entries in an internal hash table, which V8 represents as an internal array. The Map object itself has a small fixed shallow size; the entries’ storage appears under (array). The Retainers pane for the large array entry shows table in Map, which tells you which Map owns it.

What is the difference between (closure) and system / Context?

(closure) entries are the function objects themselves. system / Context entries are the scopes they capture — the variables a closure can still read. A leaked callback shows up as a closure, but the memory it keeps alive is usually in its context, which is where you find the large captured objects.