Sliced Strings Retaining Large Parent Strings

You extract a 36-character ID from each 2 MB API response and cache only the IDs, yet memory grows by megabytes per request and snapshots show huge strings retained by tiny ones. This guide from Object Shapes, Strings and Collection Memory Costs, in JavaScript Memory Fundamentals & Runtime Mechanics, explains V8’s sliced-string representation, how to recognise parent-string retention in a heap snapshot, and how to make short strings independent of their sources.

Symptom Root Cause Immediate Action Measurable Impact
Cached short strings keep MB-sized strings alive substring/slice/regex captures may be sliced strings pointing into the parent Force a flat copy before long-term storage Parent strings collectable; memory per entry = the short string
(sliced string) entries with large retained size Slices reference their parent via a parent edge Follow the parent edge in the snapshot to the big string Confirms the cause in minutes
Log lines or IDs parsed from big payloads accumulate memory Parsed fields are slices of the raw text Parse with JSON.parse or copy extracted fields Raw payload no longer retained
Memory grows only with large inputs Slices are only created above a minimum length Test with production-sized payloads Reproduces the retention in tests
Map keys from substrings retain source files Keys stored as slices Normalise keys to flat strings when inserting Source text released

Root Cause: Substrings Can Share Memory With Their Parent

V8 has several internal string representations. Most strings are sequential (flat): the characters are stored contiguously in the string object. To make common operations cheap, V8 also uses representations that point into other strings. A cons string (shown as (concatenated string) in snapshots) represents a + b as a pair of pointers until it is flattened, covered in why string concatenation inflates V8 heap usage. A sliced string represents a substring as a pointer to its parent plus an offset and length, instead of copying the characters.

Slicing is a good trade-off in the common case: extracting a 5 KB section of a 50 KB document without copying is faster and uses less memory while both are alive. V8 only creates sliced strings above a small minimum length (short substrings are copied, because the slice object would be as big as the copy). The problem is lifetime. A slice keeps its parent alive, so if you store a slice long-term while the parent would otherwise be garbage, the entire parent stays in memory. Extract a 40-character token from a 2 MB response, put it in a cache, and the cache effectively retains 2 MB per entry.

Where do slices come from? String.prototype.substring, slice, substr, and results of some regular expression operations (captured groups can be slices of the input) are the typical sources; split and trim results may be slices too, depending on length and engine version. JSON.parse produces new strings for values, not slices of the JSON text, so parsing properly is safer than extracting fields with string operations. Because the exact rules are engine internals that change between versions, the reliable approach is to detect retention in snapshots and to flatten strings you intend to keep.

In a heap snapshot, sliced strings appear in the (sliced string) group. Their shallow size is tiny, but their retained size may be enormous, and the Containment view or retainer tree shows a parent edge to the large string. That pattern — small shallow size, large retained size, a parent edge — is the signature, and heap snapshot system entries explained lists it among the parenthesised groups worth checking.

How a 40-character slice retains 2 MB Top: a cache holds three short token strings. Each is a sliced string with a parent pointer to the full 2 megabyte response text it was extracted from, so the cache retains about 6 megabytes. Bottom: after forcing flat copies, each cached token owns its 40 characters, and the response strings are collected, so the cache retains a few hundred bytes. Sliced: each token keeps its whole response alive token A (slice) token B (slice) response A text — 2 MB, retained response B text — 2 MB, retained parent Flattened: each token owns its characters token A (flat) token B (flat) responses collected — nothing points to them

Step-by-Step Fix

  1. Look for sliced strings with large retained size. Take a heap snapshot after the growth, type sliced into the class filter, and sort (sliced string) by Retained Size. Verification: entries with tiny shallow size and large retained size exist.
  2. Follow the parent edge. Select one and expand it in the Containment view or retainer tree to see its parent. Verification: the parent is a large string such as a raw response, file contents or log buffer.
  3. Find where the slice is stored. Read the slice’s own retainers to the cache, map or array holding it. Verification: you identify the code that extracts the substring and stores it.
  4. Store a flat copy instead. Before storing long-term, force an independent copy (see the code below), or better, obtain the value through JSON.parse or a real parser rather than string slicing. Verification: new snapshots show the stored strings as ordinary strings without a parent edge.
  5. Drop the raw text early. Do not keep the raw response string in scope longer than parsing requires. Verification: large (string) entries disappear from snapshots after requests complete.
  6. Re-run the workload. Repeat the same volume of requests. Verification: memory growth per cached entry equals the entry’s own size, not the size of its source.
Token cache memory: slices versus flat strings A cache of ten thousand 40-character tokens extracted from distinct 200 kilobyte responses retains about 1.9 gigabytes when the tokens are sliced strings, because every response stays alive. With flattened tokens it retains about 0.6 megabytes. 10,000 cached tokens from distinct 200 KB responses Sliced tokens ~1.9 GB retained (the responses) Flat tokens ~0.6 MB retained

Command and Code Reference

Use case: force a flat, independent copy of a short string. There is no official API for this; these idioms work in current V8 but are implementation-dependent, so verify with a snapshot.

// Option 1: round-trip through a Buffer/TextEncoder (always a real copy)
const decoder = new TextDecoder();
const encoder = new TextEncoder();
function detach(str) {
  return decoder.decode(encoder.encode(str));        // new flat string, no parent
}

// Option 2 (Node.js): Buffer round-trip, same effect
function detachNode(str) {
  return Buffer.from(str, 'utf8').toString('utf8');
}

// Use at the point of long-term storage
const token = detach(rawResponse.substring(start, start + 40));
tokenCache.set(userId, token);

Use case: parse instead of slicing. Values produced by JSON.parse are new strings and do not keep the JSON text alive.

// Before: string surgery on the raw body keeps the body alive via a slice
const raw = await res.text();
const i = raw.indexOf('"token":"') + 9;
const tokenSlice = raw.slice(i, raw.indexOf('"', i));   // may be a sliced string

// After: parse, keep only the field; the raw text becomes garbage
const { token } = await res.json();
tokenCache.set(userId, token);

Verification and Regression Prevention

Confirm the fix in a snapshot: the cache’s retained size should be close to the sum of the stored strings’ own sizes, (sliced string) entries with large retained sizes should be gone, and large raw payload strings should not survive after requests finish. Because slice creation depends on string length, test with production-sized inputs — a unit test with a 200-byte fixture will not create slices at all.

Prevent regressions by centralising long-term string storage — caches, registries, log buffers — behind helpers that normalise values (for example detach() on insert), and by preferring parsers over manual indexOf/slice extraction for structured data. A periodic heap-snapshot check in a soak test that asserts no sliced string retains more than a threshold (for example 1 MB) catches new occurrences automatically.

Reading (sliced string) entries after the fix After the fix, a cache’s retained size should be close to the sum of its stored strings’ own sizes. Sliced string entries with large retained sizes mean some substrings still point into their parent. Large raw payload strings surviving after requests finish mean a slice is still stored somewhere. Snapshot after production-sized input Fixed: no parents kept alive retained ≈ sum of strings A path still stores an unflattened substring big (sliced string) remains Find the holder via the parent’s retainers payloads outlive requests

Edge Cases and Gotchas

Engine behaviour varies

Which operations produce slices, and above what length, differs between V8 versions and between engines. Treat the flattening helpers as best-effort and verify with snapshots after runtime upgrades.

Cons strings retain their parts too

A concatenation result that has not been flattened keeps both halves alive. Storing prefix + hugeString.slice(…) can therefore retain the huge string through two layers. Flatten the final string before storing it.

Keys in Maps and objects

Property keys used on objects are usually internalised (deduplicated) strings, which are flat, but Map keys are stored as given. Normalise Map keys that come from substrings of large texts.

Logging libraries

Loggers that buffer log records may store substrings of request bodies or headers. If a logger’s buffer retains request bodies, flatten or truncate fields when creating the record.

Frequently Asked Questions

Does substring copy the characters in JavaScript?

Not necessarily. In V8, substrings above a small length may be represented as sliced strings that reference the parent string, which keeps the parent alive. The language guarantees only the value, not how it is stored.

How do I see sliced strings in DevTools?

In a heap snapshot’s Summary view, filter for sliced to find the (sliced string) group. Selecting an entry and expanding it in the Containment view shows its parent edge to the source string, and its retained size shows how much it keeps alive.

Is this a bug in V8?

No, it is a deliberate optimisation that saves time and memory when both strings are alive together. It becomes a problem only when a small slice outlives a large parent, which is an application-level lifetime issue.

Which operations are safe from this problem?

Values produced by JSON.parse, TextDecoder.decode and Buffer-to-string conversions are new strings. Template literals and concatenation with short strings produce new strings once flattened. When in doubt, verify with a snapshot rather than relying on a rule.