DEV Community

Cover image for Sliced Strings in V8: How a 20-Character ID Keeps 1 MB Alive
Mohsen Fallahnejad
Mohsen Fallahnejad

Posted on Edited on Originally published at bitlyst.blog

Sliced Strings in V8: How a 20-Character ID Keeps 1 MB Alive

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))));
Enter fullscreen mode Exit fullscreen mode

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, split and 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 with Buffer.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;
}
Enter fullscreen mode Exit fullscreen mode

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)