Strings inside the engine
Learn how V8 stores JavaScript strings as one-byte, two-byte, ropes, slices, external, thin, and internalized values in real code.
- 01Name the ways V8 stores a stringExplain one-byte, two-byte, cons, sliced, thin, external, and internalized strings, and tell them apart from what JavaScript itself promises.
- 02Spot when a string keeps memorySee why a tiny slice can keep a big parent string in memory in V8 12.4, and copy the slice only when that matters.
- 03Choose a clear way to build stringsUse
+,join, and template literals where each reads best, then measure busy code instead of guessing which is fastest.
One string value, many ways to store it
In JavaScript, a string is one simple kind of value. The language rules say what a string is: a list of code units that can never change. A code unit is a 16-bit number for one piece of text. Most characters, like a or ₹, are one code unit each. Code units are often written in hexadecimal (base 16), like 0x61 for a.
The rules do not say how to keep those code units in memory. That is up to the engine, the program that runs your JavaScript. This lesson uses V8, the engine inside Chrome and Node. V8 can store the same string in several ways. Your code still gets the same length, the same characters, and the same results from === and +.
A string kind is one way V8 stores a string in memory. Your code can't see which kind it is. In V8 12.4, the version inside Node 22, the kinds you will meet are one-byte, two-byte, cons, sliced, thin, external, and internalized.
This lesson is only about what happens inside the engine. How strings behave in your code is in other lessons: strings, string methods, Unicode and strings, and text encoding. To hunt for memory leaks, see Finding memory leaks.
Tagged values, Smis & heap numbers showed how small values fit inside one machine word. The lessons on booleans, undefined, and null showed values that are single read-only objects. A string is usually bigger, so V8 keeps it in the heap: the memory area where the engine keeps objects. Now we look at that object. Next come BigInts, symbols, and then ordinary objects.
Each row below is one string kind. Read across to see what it means and where you meet it.
| String kind | What it means | Where you meet it |
|---|---|---|
| One-byte | Every code unit is between 0x00 and 0xFF (0 to 255), so each one fits in 1 byte. | Names like Asha or café can be stored this way. |
| Two-byte | At least one code unit is bigger than 0xFF, so every code unit gets 2 bytes. | Text with ₹ or अ in it is stored this way. |
| Cons | A small node that points at a left piece and a right piece. | Joining strings with + can make one, so the characters are not copied right away. |
| Sliced | A small view that points at part of a bigger parent string. | A short piece cut from a long string can keep the whole long string in memory. |
| Thin | A small stand-in that points at another string. | A string you used as an object key can become one, pointing at the shared copy. |
| External | The characters are kept outside V8's own memory area. | Node's built-in module source code is stored this way. |
| Internalized | The one shared copy of some text, kept in V8's string table. | Property names and many string literals use the shared copy. |
One-byte and two-byte strings
A byte holds a number from 0 to 255. In hexadecimal, that is 0x00 to 0xFF. If every code unit in a string is 255 or less, V8 can store each code unit in one byte. This is a one-byte string.
If even one code unit is bigger than 255, V8 gives every code unit in that string two bytes. This is a two-byte string.
This is only how V8 stores the string in its own memory. It is not UTF-8, the encoding used to send text over the network. It is also not the number of characters a person sees.
length of 4. In café every code unit fits in one byte. In ₹100 one code unit is too big, so V8 gives every code unit two bytes.function describeString(text) { const oneBytePossible = /^[\u0000-\u00FF]*$/.test(text); return { length: text.length, storage: oneBytePossible ? "one-byte possible" : "two-byte needed", bytesIfOneByte: text.length, bytesIfTwoByte: text.length * 2, };} for (const text of ["Asha", "café", "₹100", "अ"]) { const report = describeString(text); console.log(text, report.length, report.storage, report.bytesIfTwoByte);}Asha paid ₹50014false14 bytes28 bytesCode units
- 0
0x0041 - 1
0x0073 - 2
0x0068 - 3
0x0061 - 4
0x0020 - 5
0x0070 - 6
0x0061 - 7
0x0069 - 8
0x0064 - 9
0x0020 - 10
0x20b9 - 11
0x0035 - 12
0x0030 - 13
0x0030
At least one code unit is bigger than 255 (0xFF), so V8 needs two bytes for every code unit in this text.
Line 2 of the playground code uses a regular expression (a text pattern) to check every code unit. It asks: is each one 255 or less? "café" passes, because é is 0x00E9 (233). "₹100" fails, because ₹ is 0x20B9 (8377). The two-byte count is simply text.length * 2. It is a simple model: it leaves out the extra bytes V8 adds to every string for its own notes.
To see the real kind, you need Node and a special flag. Running node --allow-natives-syntax turns on V8's private helper functions. Their names start with %. %DebugPrint(value) prints what V8 knows about a value, including a type: line with its kind. These helpers only work in Node with that flag, so this code can't run in the browser.
%DebugPrintJavaScript// Node-only: run with --allow-natives-syntax.function printKind(label, value) { process.stdout.write("@@" + label + "\n"); %DebugPrint(value);} printKind("ascii", "Asha");printKind("rupee", "₹");printKind("devanagari", "अ");Lines 2 to 5 make a helper. It prints a label, then the V8 details for the value. Lines 7 to 9 call it for three strings: "Asha", "₹", and "अ".
Node 22 / V8 12.4 stable excerpts@@ascii type: INTERNALIZED_ONE_BYTE_STRING_TYPE@@rupee type: INTERNALIZED_TWO_BYTE_STRING_TYPE@@devanagari type: INTERNALIZED_TWO_BYTE_STRING_TYPEIn each type: line, ONE_BYTE or TWO_BYTE is the storage width. INTERNALIZED means the string is V8's one shared copy of that text. You'll meet that near the end of the lesson.
The lesson tests run this code in Node 22, which uses V8 12.4, and check each type: line. "Asha" comes out one-byte. "₹" and "अ" come out two-byte.
Cons strings: joining without copying
Joining two strings with + could copy every character into a new string. For long strings, that copying takes time. So V8 often makes a cons string instead. A cons string is a small node with two pointers: one to the left piece and one to the right piece. No characters are copied yet.
A cons string can point at other cons strings. Many joins then make a tree of pieces. This tree is called a rope.
Later, some code may need all the characters in one row in memory. Then V8 can flatten the rope: it copies every piece, in order, into one plain string. A plain string with all its characters in one row is called a flat string. V8's own name for it is a sequential string, or SEQ for short.
You have two notes. Clipping them together is quick, because you don't rewrite anything. Later, if someone needs one clean page, you copy both notes onto it, in order. The reader sees the same message either way.
- In real life: Write a short message straight onto one page
- In JavaScript: A short join makes a flat string right away
- In real life: Clip two notes together
- In JavaScript: A cons string points at a left piece and a right piece
- In real life: Copy both notes onto one clean page
- In JavaScript: Flattening copies the pieces into one flat string
- In real life: The reader sees one message
- In JavaScript: Your code sees one string value
Step through a model of a cons string: join two pieces without copying, then flatten them into one string.
script
function makeConsString(left, right) { const length = left.length + right.length; if (length < 13) return left + right; return { kind: "cons", left, right, length };} function flattenRope(node) { return typeof node === "string" ? node : flattenRope(node.left) + flattenRope(node.right);} const right = "Pune-to-Kochi";const receipt = makeConsString(left, right);console.log(receipt.kind, receipt.length);const flat = flattenRope(receipt);console.log(flat);This step-through is a teaching model written in JavaScript. It does not look inside your browser's engine. It copies one real V8 rule: a join shorter than 13 characters is copied right away. The Node code below checks that rule in real V8.
// Node-only: run with --allow-natives-syntax.for (let n = 12; n <= 13; n += 1) { const value = "a".repeat(n - 1) + "b"; process.stdout.write("@@cons " + n + "\n"); %DebugPrint(value);}const big = "x".repeat(1000);for (let n = 12; n <= 13; n += 1) { process.stdout.write("@@slice " + n + "\n"); %DebugPrint(big.slice(0, n));}Lines 2 to 6 join strings to make lengths 12 and 13, and print each one. Line 7 makes a 1000-character string. Lines 8 to 11 cut pieces of length 12 and 13 from it and print them. You'll meet these pieces, called slices, in the next section.
Node 22 / V8 12.4 stable excerptscons length 12 type: SEQ_ONE_BYTE_STRING_TYPEcons length 13 type: CONS_ONE_BYTE_STRING_TYPEslice length 12 type: SEQ_ONE_BYTE_STRING_TYPEslice length 13 type: SLICED_ONE_BYTE_STRING_TYPEAt length 12, V8 just copies the characters, so both strings are SEQ (flat). At length 13, the join becomes a CONS string, and the cut piece becomes a SLICED string. The number 13 is a detail of this V8 version. Other versions may use a different number.
The lesson tests show that %FlattenString(cons) gives back a flat string (SEQ_ONE_BYTE_STRING_TYPE). But in this Node version, calling charCodeAt or indexOf did not change the original cons string. So this lesson says V8 may flatten a rope. It doesn't promise which everyday method does it.
Sliced strings can keep big strings in memory
A sliced string is a view into part of another string. The other string is called its parent. A sliced string stores three things:
- a pointer to the parent,
- where the piece starts in the parent,
- how long the piece is.
This makes big.slice(start, end) fast, because no characters are copied. But it has a cost. While the small slice is in use, V8 can't remove the parent from memory. If you keep a tiny slice for a long time, the huge parent stays too.
tiny holds only 13 characters, but it points back into response, so all 10 MB stays in memory. The copy holds its own 13 characters, so response can be freed.You crop a small face out of a big photo. Your phone often saves the crop as an edit that points back to the original. So while you keep the crop, the big photo stays on your phone too. If you save the crop as a new photo, you can delete the big one.
- In real life: A crop shows a small part of a photo
- In JavaScript: A slice covers a short part of the parent
- In real life: The crop is only an edit on the original
- In JavaScript: The slice points at the parent string
- In real life: Keeping the crop keeps the original
- In JavaScript: The parent stays in memory while the slice is used
- In real life: Saving the crop as its own new photo
- In JavaScript: Copying the slice makes a flat string with no parent
Step through a model of a sliced string: a tiny slice can keep a big parent in memory, and a real copy does not.
script
const response = "x".repeat(responseBytes);const tiny = response.slice(100, 113);console.log(tiny.length);console.log("slice keeps parent", true);const copied = Array.from(tiny).join("");console.log("copy length", copied.length);This step-through is a model too. The real proof is the Node code below. It runs Node with two flags:
--expose-gclets the code callglobal.gc(). That runs the garbage collector, the part of V8 that frees memory nothing uses any more.--allow-natives-syntaxturns on%FlattenString. It makes the 10 MB parent a real flat string in memory.
// Node-only: run with --expose-gc --allow-natives-syntax.function used() { global.gc(); global.gc(); return process.memoryUsage().heapUsed;}function makeSlice() { const big = %FlattenString("x".repeat(10 * 1024 * 1024)); return big.slice(100, 113);}function makeCopy() { const big = %FlattenString("x".repeat(10 * 1024 * 1024)); const tiny = big.slice(100, 113); return Array.from(tiny).join("");}const base = used();let keep = makeSlice();console.log("slice delta", used() - base);keep = null;console.log("after drop", used() - base);const copied = makeCopy();console.log("copy delta", used() - base, copied.length);In plain words, line by line:
- Lines 2 to 6:
used()runs the garbage collector twice, then returns how much memory is in use. - Lines 7 to 10:
makeSlice()makes a 10 MB string and returns a 13-character slice of it. - Lines 11 to 15:
makeCopy()does the same, but returns a copy of the slice.Array.from(tiny)splits the slice into an array of single characters, and.join("")glues them into a new flat string. - Lines 16 to 18: note the memory in use, keep the slice, and print how much memory grew. It grows by about 10 MB.
- Lines 19 and 20: drop the slice. The memory goes back down.
- Lines 21 and 22: keep the copy instead. The memory grows only a little.
Copy a slice only when both are true: you keep a tiny piece of a big string for a long time, and a memory tool shows the big string is being kept. In Node 22, these all made flat copies in tests: Array.from(slice).join(""), structuredClone(slice), and turning the slice into JSON and back. We saw this in one engine. JavaScript does not promise it, so check it in your own engine.
Internalized, thin, and external strings
V8 keeps a string table: a list where each piece of text is stored once. A string in this table is called internalized. Property names and many string literals are internalized. So when two objects both have a name key, they share one "name" string.
Sometimes V8 has a second string with the same text as one in the table. V8 can turn that second string into a thin string: a small stand-in that just points at the copy in the table.
made becomes an object key, V8 turns made into a thin string that just points at the "ticket" in the table.An external string keeps its characters outside V8's own memory area. Another program, like Node, owns those characters. Node does this for the source code of its built-in modules. That does not mean every file you read becomes external. The lesson uses the source of Node's built-in fs module only because it always shows EXTERNAL_ONE_BYTE_STRING_TYPE in this version.
// Node-only: run with --allow-natives-syntax.const source = process.binding("natives").fs;process.stdout.write("@@external\n");%DebugPrint(source); const made = ("ticket" + Math.random()).slice(0, 6);const object = {};object[made] = 1;process.stdout.write("@@original\n");%DebugPrint(made);process.stdout.write("@@key\n");%DebugPrint(Object.getOwnPropertyNames(object)[0]);Node 22 / V8 12.4 stable excerpts@@external type: EXTERNAL_ONE_BYTE_STRING_TYPE@@original type: THIN_ONE_BYTE_STRING_TYPE@@key type: INTERNALIZED_ONE_BYTE_STRING_TYPEIn plain words, line by line:
- Lines 2 to 4: read the source code of Node's built-in
fsmodule and print it. V8 reports it as external. - Line 6: make the text
"ticket"by cutting it from a longer random string. Somadeis a new string, not the copy in the table. - Lines 7 and 8: make an empty object and use
madeas a key. For the key, V8 uses the"ticket"in the string table. - Lines 9 and 10: print
made. It is now a thin string. - Lines 11 and 12: print the key stored in the object. It is the internalized copy.
Ashaprints asINTERNALIZED_ONE_BYTE_STRING_TYPEin the V8 test.₹prints asINTERNALIZED_TWO_BYTE_STRING_TYPE.'a'.repeat(12) + 'b'is 13 characters long.big.slice(100, 113)cut from a 10 MB string.- A new
ticketstring after it is used as an object key. process.binding('natives').fsin Node.
Put each thing you see into the group of string kinds it shows best.
Ways to build a string
Because V8 has cons strings, += in a loop is often fine. It is not free either: flattening and freeing memory still take time. Here are three common ways to build a string:
+and+=join strings. They are fine for small or simple joins.joinglues all the items of an array into one string. It fits when your pieces are already in an array, or when you want something between them, like" -> ".- A template literal, written with backticks, mixes text and values, like
`${name} paid`. It is often the easiest to read.
function makeLine(name, amount) { return name + " paid ₹" + amount;} let receipt = "";for (const item of ["tea", "snacks", "fruit"]) { receipt += item + ";";} const joined = ["Asha", "Pune", "Kochi"].join(" -> ");const templated = `${makeLine("Asha", 500)} for ${joined}`;console.log(receipt);console.log(joined);console.log(templated);Lines 1 to 3 make a helper that joins text with +. Line 7 adds to receipt inside a loop with +=. V8 may keep the in-between results as cons strings and flatten them when needed. Line 10 uses join because the pieces are already in an array. Line 11 uses a template literal because the sentence is easier to read than a long chain of +.
Each row below is a situation. Read across for a good first choice and the reason.
| When you have | A good first choice | Why |
|---|---|---|
| A short, simple join | first + ' ' + last or a template literal | Choose what reads best. Both give the same string. |
| A loop that adds text | result += piece | V8 can keep the in-between results as cons strings, so this is often fine. |
| Pieces already in an array | pieces.join('') | It is one clear call, and you can choose what goes between the pieces. |
| A tiny piece cut from huge text | Copy the piece only if you keep it | If a memory tool shows the big string is kept, make a new flat copy of the piece. |
| Code that must be fast | Time it with real data | Measure first. The speed depends on the engine and on your data. |
When this matters in real code
Most code should stay simple. Write clear string code. Measure the slow part before you change your style. This lesson's details help when a profile or a heap snapshot points at code that handles many strings. A profile shows where the time goes. A heap snapshot shows what is in memory.
- Measure before you rewrite string joins. See Measuring performance.
- Take heap snapshots if you think small slices keep big responses in memory.
- Copy only the small piece that you keep from a big string you no longer need.
- For how characters and encodings work, see Unicode and strings and Text encoding.
- Use V8's
%helpers only in tests and experiments, never in app code.
Common misconceptions
- “One-byte means UTF-8.” No. It means V8 stores each code unit in one byte, because every code unit is 255 or less.
- “JavaScript code can see ropes.” No. Ropes exist only inside the engine. Your code still sees a normal string.
- “Every slice wastes memory.” No. It only matters when you keep a tiny slice of a huge string for a long time.
- “
+=is always slow.” No. In V8, cons strings make many repeated joins cheap. Measure the code that runs most. - “The
%DebugPrinttype is the same everywhere.” No. It shows how this V8 version stores the value. Other versions and other engines can differ.
Each row below is one idea. Read across for what it means and the mistake to avoid.
| Idea | What it means | The mistake to avoid |
|---|---|---|
| A JavaScript string | A list of 16-bit code units that never changes. The language rules define this. | Thinking the rules say how V8 stores it. They don't: one-byte, two-byte, cons, sliced, and external are V8's own choices. |
| A one-byte string in V8 | A way to save memory when every code unit is 255 or less. | Thinking it is UTF-8. It isn't, and normal JavaScript can't see it. |
| A rope (cons string) | A tree of pieces that V8 copies into one string later. | Thinking it is a new JavaScript type. typeof still gives "string". |
| A heap snapshot that shows a kept string | Proof that kept strings matter in this one app. | Thinking every slice or += must be rewritten. |
Practice exercises
Type the two values this code prints.
function fitsOneByte(text) { return /^[\u0000-\u00FF]*$/.test(text); }
console.log(fitsOneByte("café"));
console.log(fitsOneByte("₹"));It prints true, then false. Every code unit in café is 255 or less, so V8 can store it in one byte each. ₹ is bigger, so it needs two bytes.
What is the shortest length that made cons and sliced strings in this lesson's V8 tests?
13. In Node 22 (V8 12.4), length 12 stayed flat, and length 13 made cons and sliced strings. Other versions may use a different number.
This code keeps an 80-character preview of each response in a Map for a long time. What memory bug can happen if responseText is huge?
const previews = new Map();
function rememberPreview(id, responseText) {
previews.set(id, responseText.slice(0, 80));
}Each preview can be a sliced string that points at its huge responseText. The map keeps the preview, so the huge text stays in memory too. Copy the preview only if a memory tool shows this is happening.
Name one way from the lesson to copy the slice, so it no longer points at its parent.
const token = bigResponse.slice(100, 113);
// token is kept in a cache for a long time.
// Copy it here if a memory tool shows the big string is kept.const copied = Array.from(token).join("");In Node 22's V8, this made a new flat string with no parent. Check copy tricks in the engine and app you ship.
In the V8 test, what kind did the original made string (the text "ticket") become after it was used as an object key?
A thin string: THIN_ONE_BYTE_STRING_TYPE. The key stored in the object is the internalized copy from the string table.
Your app builds receipts. It also cuts tiny IDs out of huge log files. Write one practical rule from this lesson.
Good rules: write clear code and measure first; use join when the pieces are already in an array; += is fine when it reads well; copy tiny IDs you keep only when a memory tool shows the big log is kept.
Check your understanding
For each question, first think about what JavaScript does. Then think about how V8 stores the string.
Question 1 of 7In this lesson, what does V8 mean by a one-byte string?
Choose an answer to see the explanation.
Question 2 of 7What does this storage check print?
Read the code, then predictfunction fitsOneByte(text) { return /^[\u0000-\u00FF]*$/.test(text); } console.log(fitsOneByte("café")); console.log(fitsOneByte("₹"));Choose an answer to see the explanation.
Question 3 of 7In V8 12.4, what was the shortest length that made cons and sliced strings in the lesson's tests?
Choose an answer to see the explanation.
Question 4 of 7What does this snippet print?
Read the code, then predictconsole.log("₹".length); console.log(/^[\u0000-\u00FF]*$/.test("₹"));Choose an answer to see the explanation.
Question 5 of 7Why can a tiny sliced string in V8 keep a huge string in memory?
Choose an answer to see the explanation.
Question 6 of 7What is an internalized string?
Choose an answer to see the explanation.
Question 7 of 7Which advice about building strings is safest?
Choose an answer to see the explanation.
Key takeaways
- A JavaScript string is a list of 16-bit code units that never changes. V8 picks how to store it, and your code can't see that choice.
- If every code unit is 255 (
0xFF) or less, V8 can use one byte per code unit. If not, it uses two bytes per code unit. - A cons string joins two pieces without copying them. V8 can flatten it into one plain string later.
- A sliced string is a view into a parent string. In V8 12.4, a tiny slice you keep can keep a huge parent in memory.
- Internalized strings are the shared copies in the string table. Thin strings point at them. External strings keep their characters outside V8.
- Write the clearest string code first. Then measure the busy parts for time and memory.
Remember the one-liner.
To your code, a string is one simple value. Inside V8 it can be a flat string, a rope, a slice, a shared table copy, a thin stand-in, or text kept outside the engine.
Up next: BigInt inside the engine. A BigInt is another value that V8 keeps in the heap, stored as a sign and a list of 64-bit digits. After symbols, Hidden classes & object shapes looks at ordinary objects the same way.