Why String Concatenation Inflates V8 Heap Usage

Strings are the one data type where the memory a value occupies has almost nothing to do with its length, and heaps dominated by strings are usually holding data nobody realises is still referenced. This page sits under object, string and collection memory costs in JavaScript Memory Fundamentals & Runtime Mechanics and explains the three representations that cause it.

Symptom Root Cause Immediate Action
Snapshot Statistics shows strings above 40% Parent bodies retained by small slices Copy extracted fields into fresh strings
A 20-character id retains megabytes Sliced string pointing into a response body Break the link with an explicit copy
Heap spikes at a regular expression call Flattening a deep cons tree into one buffer Flatten deliberately and once, not implicitly and repeatedly
Memory grows while building a large report Every operand of every concatenation retained Push into an array and join once at the end
Two identical strings appear twice in the heap Runtime-built strings are not interned Reuse a canonical instance for repeated keys

Root Cause: Three Representations, One Type

To JavaScript, a string is a string. To V8, it is one of several internal representations chosen for performance, and each has a different memory profile.

A sequential string is the simple case: a header plus the characters, one or two bytes each depending on whether the content is Latin-1 or requires two-byte encoding. Its memory equals its length, which is what everyone assumes is always true.

A cons string is produced by concatenation. Rather than copying, V8 allocates a small node holding pointers to the left and right halves. Building a 10 MB report by appending 100 000 fragments therefore allocates 100 000 small nodes and retains every fragment, because each is still referenced by the tree. The operation is fast and the incremental allocation is small; the retention is total.

A sliced string is produced by slice, substring or substr beyond a small length threshold. It holds a pointer to its parent plus an offset and a length. This is what makes extracting a field from a large response so dangerous: a 20-character identifier pulled from a 2 MB body keeps the whole body alive for as long as the identifier is referenced.

Flattening is the resolution. When an operation needs contiguous characters — a regular expression, a comparison, passing to a native API — V8 walks the tree and allocates a single sequential string for the whole result. That is a sudden allocation of the full length, which is why memory can spike at a line that appears to be reading rather than writing.

Three ways V8 stores a string A sequential string holds its own characters. A cons string is a small node pointing at a left and right half, both of which stay alive. A sliced string is a small node holding a pointer, offset and length into a much larger parent string, which it keeps alive in full. The same value, three memory shapes Sequential header + characters memory = length. As expected. Cons (a + b) node left half right half Sliced ptr+off+len parent · 2 MB response body All three report the same .length and behave identically to your code Only the heap snapshot shows the difference: retained size far above the character count is the tell, and the retainer chain leads straight to the parent that is being kept alive.

Step-by-Step Fix

  1. Check the strings share. DevTools path: Memory → Heap snapshot → Statistics. Expected: a share above about 40% on an application that is not text-processing means retention rather than data.
  2. Sort strings by retained size. DevTools path: Summary → expand (string) → sort by Retained Size. Expected: the worst offenders retain orders of magnitude more than their own length.
  3. Follow the retainer chain. Expected: the chain ends at a response body, a file read or a template — the parent the slice points into.
  4. Copy at the extraction point. Replace the stored slice with an independent string. Verification: the parent disappears from the next snapshot.
  5. Batch report building. Replace repeated += with push and one join. Verification: peak heap during report generation drops to roughly the size of the output rather than the sum of every intermediate.

Command & Code Reference

The extraction fix. The comment matters as much as the code, because the copy looks redundant to anyone who does not know about sliced strings.

const body = await response.text();      // 2.4 MB

// WRONG: a 24-character id that pins the entire 2.4 MB body.
const id = body.slice(offset, offset + 24);
cache.set(id, { at: Date.now() });       // body now lives as long as the cache entry

// RIGHT: force an independent allocation so the parent can be collected.
// The concat-and-slice pattern is the clearest way to demand a fresh buffer.
const idCopy = (' ' + body.slice(offset, offset + 24)).slice(1);
cache.set(idCopy, { at: Date.now() });   // body is now collectable

Building large output without retaining every fragment.

// Every += builds a cons node and keeps both operands alive; the tree is
// only resolved when something forces a flatten.
function buildReportBad(rows) {
  let out = '';
  for (const r of rows) out += `${r.id},${r.name},${r.total}\n`;
  return out;                            // retains 100 000 fragments
}

// One array, one join: intermediate fragments are collectable as soon as
// join() has copied them, and peak memory is ~2× the output rather than
// the sum of the whole tree.
function buildReportGood(rows) {
  const parts = new Array(rows.length);
  for (let i = 0; i < rows.length; i++) {
    const r = rows[i];
    parts[i] = `${r.id},${r.name},${r.total}\n`;
  }
  return parts.join('');
}

Measuring the difference, which is the only way to convince a reviewer the copy is not superstition.

// node --expose-gc strings.js
function retainedAfter(fn) {
  global.gc();
  const before = process.memoryUsage().heapUsed;
  const kept = fn();                     // the value we hold on to
  global.gc();
  const after = process.memoryUsage().heapUsed;
  return { keptLength: kept.length, retainedKb: (after - before) / 1024 };
}

const big = 'x'.repeat(2_000_000);
console.log(retainedAfter(() => big.slice(0, 24)));          // ~1 950 KB
console.log(retainedAfter(() => (' ' + big.slice(0, 24)).slice(1))); // ~0 KB
1 000 extracted ids, sliced versus copied Storing a thousand twenty-four character identifiers as slices of their parent bodies retains one point nine gigabytes, because each slice pins a two megabyte response. Copying each identifier into an independent string retains forty-eight kilobytes for the same data. Same 1 000 identifiers, 24 characters each — two ways of keeping them stored as slices 1 950 MB retained — each id pins its own 2 MB response body copied into fresh strings 48 KB retained A 40 000× difference produced by one line that looks like it does nothing.

Verification & Regression Prevention

The verification is the retainer chain, not the number: take a snapshot after the fix, filter for the parent’s constructor — usually (string) with a large shallow size — and confirm it is gone rather than merely smaller. A parent that is still present with a different retainer means there is a second slice somewhere.

To prevent recurrence, put the copy behind a named helper — internField, detachString, whatever fits your codebase — and use it at every parsing boundary. A named function survives refactoring in a way that a bare (' ' + s).slice(1) does not, because the next reader can look it up instead of deleting it as dead weight. Where the data volume justifies it, add the retained-size assertion from the measurement snippet above to the test suite for your parser.

Where to put the copy Three boundaries: parsing a network response, reading a file into fields, and extracting values from a large template. At each one the extracted value is copied before it is stored, so the large source can be collected as soon as parsing finishes. Copy at the boundary, not later — later means the parent is already retained Network response → parsed record every field kept beyond the request is copied; the body is collectable when parsing returns File read → row objects a CSV parser that slices without copying retains the whole file per stored row Template or document → extracted attribute attribute values cached for the session must not point into the source document

FAQ

Is += in a loop actually slow or memory-heavy?

Neither, usually. V8 builds a cons string per concatenation — a small node pointing at two halves — so the loop is fast and allocates little. What it does build is a deep tree that retains every operand, so the cost lands when something flattens it or when you keep the result while expecting the pieces to be freed.

Why does a 20-character substring retain 2 MB?

Because slice and substring produce a sliced string: a small node holding a pointer to the parent plus an offset and length. The characters are never copied, so the parent cannot be collected while the slice is alive. Copying the slice into a fresh string breaks that link and releases the parent.

How do I force a string to be flattened or copied?

Any operation that must produce contiguous characters will do it — a regular expression match, a JSON round-trip, or normalize(). There is no dedicated API, which is why the idiomatic fix is an explicit copy such as (' ' + s).slice(1), chosen for clarity and commented, because the intent is not obvious to the next reader.