An array holds 200 short IDs, about 4 KB of text. After a forced garbage collection, the heap is still 200 MB bigger:
kept: 200 strings, 3.9 KB of text
heap: +200.0 MB
Each ID was cut out of a 1 MB JSON string with slice(). No closures, no listeners, no globals hold the big strings.
The mental model: a bookmark, not a photocopy
When you slice() a long string, V8 often doesn't copy the characters. It gives you a bookmark: "page 7, 20 characters long, in that book."
That's fast, and usually fine. But as long as you keep the bookmark, the whole book has to stay on the shelf.
Keep a slice of a big string, and you may be keeping the big string.
V8 calls this bookmark a sliced string.
How it works
Strings in JavaScript can never change, so V8 can safely point into the original instead of copying. The slice is a small object holding a pointer to the parent, an offset and a length:
What the kept string actually is
1. kept[0] — What your code sees: a 20-character ID
2. Sliced string — A small header: pointer, offset, length
3. Parent string — The whole 1 MB body, kept alive by that pointer
The garbage collector sees a live pointer to the parent, so it can't free it.
Your code gets no hint at all. kept[0].length is 20, and printing or comparing it works normally.
There's a cutoff. Very short pieces get copied, because a bookmark wouldn't save any memory:
| Kept length | Parent string |
|---|---|
| 12 characters | freed |
| 13 characters | kept alive |
Node 26.13 (V8 14.6), measured with process.memoryUsage().heapUsed after two gc() calls.
That's why this kind of leak can stay hidden for months, then appear when your IDs switch to UUIDs.
slice isn't the only way to get a bookmark:
| How you got the short string | Keeps the parent alive? |
|---|---|
slice, substring
|
yes |
split(...)[i] |
yes |
| Regex capture group | yes |
JSON.parse(big).id |
no, the parser builds new strings |
This is V8 behavior, not part of the JavaScript spec.
In practice
Copy a piece of a big string before you store it long-term:
// before: keeps the 1 MB parent alive
cache.set(key, big.slice(7, 27));
// after, in Node
cache.set(key, Buffer.from(big.slice(7, 27)).toString());
// after, anywhere
cache.set(key, JSON.parse(JSON.stringify(big.slice(7, 27))));
With the Buffer copy, heap growth for the 200 IDs went from 200 MB to 0.03 MB.
Only do this where the piece outlives the big string: caches, Maps, session state, module-level variables. If you slice something and use it right away, the bookmark is the faster choice.
To confirm a leak, take a heap snapshot in DevTools, sort by retained size, and look for huge strings held by a sliced string.
Say it in an interview
"In V8,
slice,substring,splitand regex captures can return a sliced string. That's a pointer into the original string, not a copy. If you store that small piece long-term, it keeps the whole original alive and the garbage collector can't free it. The fix is to force a copy, for example withBuffer.from(s).toString(), before you put it in a cache."
Cheat sheet
| Remember | Detail |
|---|---|
| Sliced string | A bookmark into the parent string: pointer, offset, length |
| Danger | Keeping the slice keeps the whole parent in memory |
| Cutoff | 13+ characters become a slice. 12 or fewer are copied |
| Safe |
JSON.parse results are fresh strings |
| Fix | Copy before storing long-term: Buffer.from(s).toString()
|
TL;DR
- V8 can make a slice of a string point into the original instead of copying it.
- 200 kept IDs of 20 characters held 200 MB here, because every slice kept its 1 MB parent alive.
- Pieces of 12 characters or fewer are copied, so the leak can appear when an ID format gets longer.
- Copy before storing long-term, and confirm with a heap snapshot.
The full test
function build() {
const kept = [];
for (let i = 0; i < 200; i++) {
const big = JSON.stringify({
id: `req-${i}`,
body: String(i).padEnd(1024 * 1024, "x"),
});
kept.push(big.slice(7, 27)); // 20 characters
}
return kept;
}
big is local to the loop and nothing else touches it. A heap snapshot of a smaller version, keeping 5 short strings, showed all 5 of their 1 MB parents still alive, each held by a node of type sliced string.
Every way in, measured
| How the short string was made | Keeps the parent alive? |
|---|---|
big.slice(a, b) |
yes |
big.substring(a, b) |
yes |
big.split('"')[3] |
yes |
/"id":"([^"]+)"/.exec(big)[1] |
yes |
JSON.parse(big).id |
no |
Used as an object key, then read back from Object.keys
|
no |
200 parents of 1 MB, with a 24-character piece kept from each. Node 26.13 (V8 14.6).
The trap is pulling a value out of raw text with string methods or a regex, then storing it. Parsing first and keeping a field is safe.
Why there's a cutoff at 13 characters
A sliced string is a small header: a pointer to the parent, an offset and a length. For very short pieces, copying the characters takes about as much space as that header, so V8 just copies. The exact cutoff is an engine detail and can change between versions.
Not in the spec
The JavaScript spec only says strings can't change. How an engine stores slices is up to the engine, and other engines make their own choices. Everything here is measured in V8.
Originally published on Bitlyst.
Top comments (0)