How V8 stores strings
V8 represents strings as a tree of types: flat Seq strings (Latin1 or UTF-16), ConsString (O(1) concatenation node), SlicedString (O(1) substring that pins its parent), ThinString, and ExternalString. Internalised strings compare by pointer.
You parse a 50 MB log file, pull out a 12-character request ID with line.slice(0, 12), push it into an array, and let everything else go. Hours later the process is sitting on hundreds of megabytes for an array of tiny IDs. The strings you kept are 12 characters each — but each one is secretly holding its entire source line, and through it the whole buffer, alive. V8’s string representations are why, and why one + '' fixes it.
A string is not always a flat array of characters
The naive model — “a string is a contiguous block of characters” — is only one of V8’s several string representations. V8 picks a representation to make the common operations (concatenation, slicing, comparison) cheap, and only materialises a flat array of characters when it has to. A single JS string value can be any of these HeapObject kinds:
- SeqOneByteString / SeqTwoByteString — the flat case. The characters are stored contiguously right in the object. V8 uses a one-byte (Latin1) backing when every character fits in 8 bits, and a two-byte (UTF-16) backing otherwise — so an ASCII string takes half the memory of one with any non-Latin1 character.
- ConsString — a concatenation node.
a + bdoes not copy characters; it allocates a small node holding pointers toaandb(firstandsecond) and a total length. Concatenation is therefore O(1), and repeated concatenation builds a tree of ConsStrings. - SlicedString — a substring view.
str.slice(i, j)allocates a node holding a pointer to the parent string plus an offset and a length. The substring is O(1) and shares the parent’s characters — but it keeps the entire parent alive. - ThinString — a forwarding pointer. When a string is internalised (see below) but an old non-internalised copy still exists, the old one becomes a ThinString that just points at the canonical internalised string.
- ExternalString — characters owned by C++ outside the V8 heap (e.g. a source file the embedder kept in its own buffer). V8 holds a pointer to that external storage instead of copying.
The key insight across all five: V8 defers copying characters for as long as possible. Cons and Slice are O(1) precisely because they avoid materialising a flat buffer; the price is a hidden pointer to the original data — which can be enormous. When you pass a string to a native API or index into it, V8 finally pays the copy; the question is whether you want that copy to happen with the full parent attached or after you have broken the chain.
Internalised strings: equality in one cycle
Have you ever wondered how property access stays fast even though property names are strings and string comparison should be O(length)? The answer is internalisation. V8 maintains an internal string table. Identifiers, property names, and string literals are internalised: deduplicated so there is exactly one canonical copy of each distinct string. The payoff is enormous for the engine’s own work — comparing two internalised strings for equality is a single pointer comparison (1 cycle), not a character-by-character scan whose cost scales with length.
This is why property access is fast: the property name in your code and the name stored in the object’s Map are the same internalised string, so the lookup compares pointers. When you create a fresh non-internalised string that equals an existing internalised one (say by reading it from input), and it is then used as a property key, V8 may turn it into a ThinString forwarding to the canonical copy, recovering pointer-equality for future comparisons.
Flattening: the hidden cost of using a ConsString
A ConsString is cheap to build but cannot be used directly for operations that need contiguous characters — most importantly, indexing (str[i], charCodeAt), regex, and passing to C++ APIs. The first time you do something that needs the flat form, V8 flattens the string: it walks the ConsString tree and copies all the characters into a single Seq string, then rewrites the node to point at the flat result.
So the cost of a +-built string is deferred, not free. If you build a huge string with += in a loop and then index it once, you pay one O(n) flatten at that moment. Usually that is fine — but a pathological pattern is building a deep ConsString tree and repeatedly triggering re-flattening, or holding many un-flattened cons trees that bloat the heap. (Modern V8 handles += loops well; the classic advice to always use an array + join matters most for truly enormous builds.)
The memory pitfall: a slice that pins its parent
Back to the Hook. bigString.slice(0, 12) returns a SlicedString that holds a pointer to bigString. As long as that 12-character slice is reachable, bigString cannot be collected — the GC sees a live reference to it through the slice. Keep thousands of tiny slices of a giant file and you keep the giant file (and its backing buffer) entirely alive, even though logically you only wanted a few hundred bytes.
// LEAK: each id is a SlicedString pinning its whole 50 MB source line / buffer.
const ids = lines.map(line => line.slice(0, 12));
// FIX: force a fresh, flat, independent copy that does NOT reference the parent.
const ids = lines.map(line => (line.slice(0, 12) + '').slice());
// or, explicit and clear:
const ids = lines.map(line => {
const s = line.slice(0, 12);
return s.repeat(1); // forces flattening into a standalone Seq string
});The cure is to force a flatten into a standalone string that copies the characters and drops the parent pointer. Concatenating + '', or any operation that produces a fresh Seq string, breaks the retention chain so the GC can reclaim the parent. The trade is an O(slice-length) copy now in exchange for releasing an O(parent-length) retention — almost always worth it for long-lived small slices.
- ASCII backing (Latin1)
- 1 byte / char
- Non-Latin1 backing (UTF-16)
- 2 bytes / char
- ConsString concat cost
- O(1) — a node, no copy
- SlicedString substring cost
- O(1) — pins the parent
- Internalised string equality
- 1 pointer comparison
- Flatten on first index of a cons
- O(n) one-time copy
What does `a + b` actually allocate in V8 for two existing strings, and what is the cost?
You keep an array of 10,000 twelve-character slices taken from 10,000 large lines, and discard the lines. Why does memory stay high?
Order what happens when you build a string with repeated + and then call charCodeAt on it once.
- 1 Each + allocates a ConsString node pointing at its two operands
- 2 Repeated + builds a tree of ConsStrings, no characters copied
- 3 charCodeAt needs contiguous characters at a given index
- 4 V8 flattens: walks the tree and copies all chars into one Seq string
- 5 The node is rewritten to point at the flat result for future access
▸Edge cases
The one-byte vs two-byte choice is per string and decided at creation: a string of pure ASCII gets a Latin1 backing at 1 byte per character; introduce a single emoji or Cyrillic character and the whole string is stored two-byte at 2 bytes per character. This is why concatenating user text with non-Latin1 content can double the memory of an otherwise-ASCII payload, and why some servers normalise or segment text to keep large hot strings one-byte.
- 01Name V8's string representations and what each optimises.
- 02What are internalised strings and why do they make property access fast?
- 03Explain flattening and the SlicedString retention pitfall, and how to fix the leak.
V8 does not store every string as a flat character array; it chooses among several HeapObject representations to keep common operations cheap. Seq strings are the flat form, backed by Latin1 at one byte per character for pure-ASCII text and by UTF-16 at two bytes otherwise, so a single non-Latin1 character doubles a string’s memory. ConsString makes concatenation O(1) by allocating a node that points at its two operands rather than copying characters, building a tree under repeated +. SlicedString makes substrings O(1) by holding a pointer to the parent plus an offset and length — but that pointer keeps the whole parent alive, so retaining many small slices of large sources is a classic memory leak fixed by forcing a standalone flat copy such as slice + ”. ThinString forwards to a canonical internalised string, and ExternalString points at characters owned by embedder C++ to avoid a copy. Internalised strings, kept in V8’s string table, let equality and property-name comparison be a single pointer comparison instead of a character scan, which is a major reason property access is fast. The deferred cost of building with + is flattening: the first operation needing contiguous characters (indexing, regex) walks the cons tree and copies everything into one Seq string in O(n). Now when you see a process leaking memory on an array of “small” strings after parsing a large file, or a profiler showing unexpected allocations on a single charCodeAt call — you know to check whether those strings are slices pinning their parents, and one + '' is your first diagnostic step.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.