Strings in the Engine: Ropes and Internalisation
Here is a rule you were probably taught: never build a big string by adding to it in a loop, because each += copies the whole thing and the total cost blows up to O(n squared). It was good advice once. On a modern engine it is mostly wrong.
let out = "";
for (let i = 0; i < 100_000; i++) {
out += "row " + i + "\n";
}
That loop is fine. V8 does not copy the growing string on every pass. It quietly builds a little tree of pointers and only assembles the real characters at the end, when something finally asks for them. Same source code, completely different cost model than the folklore assumes.
A JavaScript string looks like one immutable run of UTF-16 code units. The engine does not store it as one thing. It keeps several different physical representations and slides a string between them for speed and for memory. Learn the handful that matter and a pile of “why is this string using so much memory” and “why is concatenation fine now” questions answer themselves.
One string, several shapes
Inside V8 a string is always one of a small set of internal types. The important thing to notice up front: only one of them actually stores characters. The rest are references that point at other strings, so the engine can avoid copying.
- SeqString is the plain one: a flat, contiguous buffer of characters. Every other form eventually bottoms out in one or more of these. It comes in two widths (below).
- ConsString is a concatenation that has not been copied yet: a tiny node holding a pointer to a left part and a pointer to a right part. This is the “rope”.
- SlicedString is a substring that has not been copied yet: a pointer to a parent string plus an offset and a length. A window onto someone else’s buffer.
- ThinString is a forwarding pointer to the canonical, internalised copy of a string (more on internalisation at the end).
- ExternalString is a string whose character bytes live outside the V8 heap entirely, handed in by the embedder (your page’s source text often lives here).
You never name these types in JavaScript, and you cannot ask a string which one it is. But they explain the behaviour you can see. The names here are V8’s; the shapes of the idea are shared by every major engine, which we will get to. Let us take the ones that change how your code performs, starting with the cheapest possible win: how wide each character is.
One byte or two
A JavaScript string is defined in terms of 16-bit UTF-16 code units, so the naive assumption is that every character costs two bytes. Most of your text does not. The overwhelming majority of strings in real programs are plain ASCII (identifiers, JSON keys, URLs, English prose), and forcing all of that to two bytes each would waste half the memory for nothing.
So V8 stores a string one of two ways:
- If every character fits in Latin-1 (code points up to U+00FF, which covers ASCII plus common Western European accents), the string is a one-byte
SeqOneByteString: one byte per character. - If even one character is above that range (Greek, Cyrillic, CJK, most emoji, the euro sign), the whole string becomes a two-byte
SeqTwoByteString: two bytes per character, across the board.
The key word is whole. The choice is made per string, not per character, so a single stray non-Latin-1 character doubles the storage of everything around it. Append one emoji to a paragraph of English and every letter in that paragraph now costs two bytes. This is invisible from JavaScript. .length does not change, the characters read back identically, nothing in the language tells you which width you got. But it is real memory, and at scale (a cache of a million short strings, say) it is the difference between a program that fits and one that swaps.
Concatenation does not copy: cons strings
Now the concatenation surprise. When you write a + b, the obvious implementation allocates a new buffer of length a.length + b.length and copies both halves in. Do that inside a loop that grows a string and you really do get O(n squared), because every step recopies everything so far.
V8 usually does not do that. For strings past a small size it creates a ConsString: a small fixed-size node that just holds two pointers, one to a and one to b. No characters are copied. The node says, in effect, “I am the two of these, back to back, whenever you actually need to know.” Chain a few + together and you get a tree of these nodes with the real character buffers sitting at the leaves, untouched and shared.
Each + allocates one small node and returns it. The work is constant per operation, regardless of how long the strings are, because nothing gets copied. That is the whole reason the loop at the top of this page is fine: a hundred thousand += operations build a hundred thousand cheap nodes, not a hundred thousand ever-growing copies.
There is a floor to this. Building a cons node has its own overhead (the node is an object with a header and two pointers), so for very short pieces V8 does not bother, and just copies the characters into a fresh flat string instead. The cutoff is an internal constant, currently 13 characters in V8. Do not lean on the exact number; it is a tuning parameter that has moved before and can move again. The shape of the rule is what lasts: tiny concatenations copy (it is cheaper than a node), larger ones build a rope.
Flattening: paying the copy once
The rope has to be cashed in eventually. The moment anything needs the characters laid out contiguously, V8 walks the tree left to right and copies every leaf into one flat SeqString. This is called flattening, and it is where the copy you deferred finally happens.
What forces a flatten? Anything that needs a real, linear run of characters: comparing the string, running a regular expression against it, calling something like indexOf, or handing the string out to an API (the DOM, JSON.parse, a network call). At that point the deferred work comes due and you pay to assemble the buffer.
Two things make this a good deal instead of a hidden tax. First, the flatten is O(n) in the total length, done once, not O(n squared) spread across every concatenation. Second, V8 caches the result: after flattening, it rewrites the root cons node to point straight at the new flat buffer, so the tree is never walked twice. Read the string a thousand more times and you pay for the walk zero more times.
See the rope form and flatten
Concatenate pieces and watch the cons tree grow with each +, with the “characters copied” counter stuck at zero because nothing is assembled yet. Then trigger a read and watch the whole tree flatten into a single buffer, paying the copy once. The structure here is a faithful simulation of the cons/flatten idea, not a live read of V8 memory.
Slices are views: the substring that will not let go
Concatenation defers a copy forward. Slicing defers one too, in the opposite direction. When you take a substring (str.slice(...), str.substring(...)), V8 does not necessarily copy those characters out. For a substring past that same small length threshold it creates a SlicedString: a little node holding a pointer to the parent string, plus an offset and a length. It is a window. Reading through it reads straight out of the parent’s buffer, no copy, constant time no matter how long the substring is.
Fast, and usually exactly what you want. But there is a sharp edge, and it is a real one that has bitten production systems.
This is not a bug, it is the trade the optimisation makes. A slice is cheap to create because it borrows the parent’s memory, and the price of borrowing is that the lender cannot leave. Reachability is exactly as covered in garbage collection: the parent has an incoming reference from the slice, so it is alive, full stop. The classic version of this leak is parsing: you read a multi-megabyte document, pull out a few short fields with slice, drop the document variable, and expect the big string to be collected. It is not. Each little field is a SlicedString pinning the whole thing. Keep a thousand of those fields around and you are quietly holding a thousand documents.
Internalisation: one canonical copy
The last representation is about sameness. Programs use the same strings over and over. Every object with a status field uses the property name "status". A string literal in a hot function is created on every call. Storing a fresh copy of "status" each time, and comparing those copies character by character, would be wasteful in both memory and time.
So V8 internalises certain strings: it keeps a single canonical copy of each distinct string in an internal string table, and makes every use point at that one copy. String literals in your source and property names are the big categories. Once two strings are both internalised, checking whether they are equal is a pointer comparison: same address means same string, decided in one machine instruction, no character walk at all.
This is the quiet engine behind a feature from a whole other lesson. In hidden classes, an object’s shape maps property names to slots, and a property read is a shape check plus a slot read. That shape check works because the property names are internalised: comparing the name you are looking up against the names in the shape is a set of pointer comparisons, not string scans. Fast property access leans directly on internalised strings. The inline cache that remembers “this shape, this slot” is comparing interned pointers under the covers.
The ThinString from the type list at the top is the plumbing for this. When a string that already exists as a separate object later needs to be internalised (say you use a computed string as a property key), V8 does not always move its bytes. It turns the original into a ThinString, a thin forwarding node that points at the canonical entry in the string table, so old references still resolve to the one true copy. You never see it. It is just how the engine keeps “the canonical copy” honest without rewriting every pointer.
What this means for your code
The honest answer for almost all code: nothing changes. You do not pick string representations, you cannot query them, and the engine’s defaults are good. Write the clearest code and let it store text however it likes. Micro-managing string internals is wasted effort in the same way micro-managing number boxing is (see Smis and doubles).
There are exactly three moments this knowledge earns its keep:
- Building strings in a loop is fine. The O(n squared) fear is obsolete because concatenation builds a rope and flattens once. If you prefer, pushing pieces into an array and calling
join("")at the end is equally good and arguably clearer. Either way, do not contort your code to avoid+. - Build fully, then read. Assemble the whole string before you compare it, match it, or hand it to an API, so you flatten once rather than repeatedly. Do not interleave growing and reading.
- Watch small slices of big parents. The one representation that leaks: a short substring kept for a long time out of a large string you want collected. If a heap snapshot shows a big string surviving with only something small pointing at it, force a copy to break the dependency.
Everything else (one byte versus two, when exactly a cons node forms, when a ThinString appears) is context that makes memory profiles legible, not a set of rules to code against.
Summary
- A JavaScript string is one immutable sequence to you, but V8 stores it as one of several physical forms and switches between them. Only SeqString holds actual characters; the others are references that avoid copying.
- Strings are one-byte (Latin-1) or two-byte (UTF-16). All-Latin-1 text is half the memory, but a single character past U+00FF makes the whole string two-byte. Invisible to your code, real in a heap snapshot.
- Concatenation usually makes a ConsString (a rope): a node pointing at two parts, no copy. This is why building strings in a loop is no longer O(n squared). Tiny pieces are copied instead of roped (an internal length cutoff, currently around a dozen characters, which can change).
- Flattening walks the rope and copies it into one flat buffer, triggered when contiguous characters are needed (comparison, regex, an API). V8 caches the result, so you pay the walk once. Build fully, then read.
- A substring can be a SlicedString: a view of parent plus offset and length, cheap to make but it keeps the whole parent reachable. Small slices retained from large parents are a genuine leak; force a copy to break it.
- Internalisation dedupes literals and property names into one canonical copy in a string table, so equality becomes a pointer comparison. This is what makes hidden-class property lookups fast.
- The concepts are durable; the V8 type names and thresholds are version-specific, and SpiderMonkey and JavaScriptCore reach the same ends with their own names (atoms, ropes, dependent strings).
What this means for your code: build strings freely, assemble before you read, and only worry about representation in the one leaky case, a small slice pinning a big parent. For everything else, write it plainly and let the engine store it.