Values
A number is either a tagged integer in the word itself or an object on the heap, and the engine works to keep it the first. This part is Smis and HeapNumbers and pointer compression, the representations the optimiser tracks for numbers and where boxing happens, the five shapes of a string and when a concatenation gets flattened, the cost of every string operation, symbols and BigInt and the oddballs, and the tools that show representation.
Tagged values: Smis and pointers
"Is a number an object? Where does the 42 in x = 42 actually live?"
In the variable itself. V8 represents every value as a machine word with a tag bit. If the low bit is 0, the word is a small integer (Smi) and the value is the upper bits: no heap object, no allocation, no indirection. If the low bit is 1, the word is a pointer to a heap object: a string, an object, a function, or a HeapNumber holding a double. This one decision shapes the performance of all numeric code.
- Smi: a 31-bit signed integer (with pointer compression; 32-bit without) encoded as
value << 1. Range about ±1.07 billion. Array indices, counters, small ids, character codes, most loop variables. Free to create, copy, compare. - HeapNumber: an object with a map and an 8-byte IEEE 754 double. Allocated in the young generation like any object. Holds every number that is not a Smi: fractions, integers beyond the Smi range,
-0,NaN,Infinity. - Oddballs:
undefined,null,true,false, and the hole are singleton heap objects in read-only space; comparisons are pointer compares. - Everything else: a pointer to a heap object with a map that says what it is.
- No allocation for small integers. A loop to a million allocates nothing for its counter.
- Fast type checks. "Is this a Smi" is
test al, 1. Every IC and every optimised arithmetic sequence starts with it. - Fast arithmetic. Two Smis add as integers (after one shift, or using the fact that
(a<<1) + (b<<1) = (a+b)<<1directly) with the CPU's overflow flag as the guard. - Uniform storage. Arrays, fields, registers and the stack all hold tagged words; the GC can tell pointers from integers by the bit, so it never mistakes a number for an address.
- The heap is a 4 GB cage; pointers are stored as 32-bit offsets from its base. Tagged slots are 4 bytes. Objects are roughly 40% smaller than with 8-byte pointers; memory bandwidth and cache use drop with them.
- The cost: a base-register add on every pointer load, and Smis shrink to 31 bits. The trade was measured as a clear win on real workloads and is on in Chrome and Node.
- External memory (ArrayBuffer backing stores, some string data) lives outside the cage; the heap limit does not count it;
process.memoryUsage().externaldoes.
node --allow-natives-syntax -e "%DebugPrint(42); %DebugPrint(2**30); %DebugPrint(4.5); %DebugPrint(-0)". The first is a Smi; the other three are HeapNumbers, and the second one is an integer.Numbers in optimised code: unboxed, boxed, converted
The engine has one Number type and at least four representations for it. Which one a value gets at each point in a function is chosen by the optimiser, and the choice decides whether a numeric loop allocates. Knowing the conversion points is knowing where the HeapNumbers come from.
- TaggedSigned: a Smi in a tagged slot or register.
- Word32: a raw 32-bit integer in a machine register. From untagging a Smi, from
| 0, from integer feedback. - Float64: a raw double in an xmm register or a double array slot or a Double field.
- Tagged / TaggedPointer: a pointer to a HeapNumber (or anything). The representation of "escaped" numbers.
- Conversions between them are explicit nodes: ChangeInt32ToFloat64 (free), ChangeFloat64ToTagged (an allocation unless the value is Smi-representable), ChangeTaggedToFloat64 (a load from the HeapNumber), CheckedTaggedToInt32 (a Smi check or a HeapNumber load plus a truncation check).
- At function boundaries that are not inlined: a double returned or passed becomes a HeapNumber (unless it is an integer in Smi range, which is re-tagged for free).
- At stores into Tagged slots: generic arrays (PACKED_ELEMENTS), object fields with Tagged representation, Map and Set values, closure context slots (contexts are tagged),
arguments. - Not at stores into Double slots: PACKED_DOUBLE_ELEMENTS arrays, Double-representation fields, typed arrays.
- In the interpreter and Sparkplug: every non-Smi result of arithmetic is boxed, because those tiers have no unboxed representation. A hot numeric function that fails to reach Maglev or TurboFan allocates a HeapNumber per operation.
// integer-ness is a representation, not a type const a = 1; // Smi const b = 1.0; // also Smi: 1.0 === 1 and V8 stores it as the integer const c = 1.5; // HeapNumber (or unboxed in a register/double field) const d = 2 ** 31; // HeapNumber: integer, but outside the 31-bit Smi range const e = -0; // HeapNumber: -0 cannot be a Smi (Smi 0 is +0) // so: a field initialised to 0 then assigned 1.5 generalises Smi → Double (one-time deopt, then fine) // a field initialised to 0 then assigned 2 ** 31 does the same // a field initialised to 0 then assigned null generalises Smi → Tagged: boxing forever. avoid. // integer intent, stated (x / 2) | 0 // truncate: int32 in optimised code, one instruction Math.trunc(x / 2) // same meaning; optimised to the same thing in modern V8 Math.imul(a, b) // 32-bit wraparound multiply: never a double, never overflow deopt x >>> 0 // reinterpret as uint32 // BigInt: arbitrary precision, heap-allocated digits, never a Smi, every operation allocates const big = 2n ** 64n; // fine for ids and crypto; wrong for a hot loop counter
--trace-gc: no scavenges once optimised. The same loop with the sum stored into this.total where total was initialised to null: scavenges every few hundred ms. Change the initialiser to 0 and they stop.Strings: five shapes behind one interface
A string is immutable and indexable, and V8 exploits the immutability to avoid copying: concatenation makes a pair node, slicing makes a view, and only an operation that needs the characters in a row pays for the copy. The result is that string-building code has a cost profile that depends on when the characters are read, not only on how many there are.
- Sequential (SeqOneByteString, SeqTwoByteString): header plus characters. One byte per character if every character fits in Latin-1, two bytes otherwise. Decided at creation. A one-byte string is half the memory and faster to hash, compare, and scan.
- Cons (ConsString): two pointers and a length. Created by
+, template literals,concat, when the result is over a small threshold (13 characters). Concatenation is O(1); the characters stay in the leaves. - Sliced (SlicedString): parent, offset, length. Created by
slice,substring,substrfor results of 13+ characters. A view; the parent is retained. - Thin (ThinString): a forwarding pointer to an internalised copy. Created when a non-internalised string is internalised (used as a property key) so existing references find the canonical copy.
- External: characters owned by the embedder (e.g. a large file loaded by Node), referenced by V8 without copying.
- Internalised: not a shape but a flag: the string is in the string table, unique for its contents. All literals, identifiers and property names. Comparison by pointer; hashing precomputed.
- When: any operation that needs random access or a contiguous buffer:
charCodeAt, indexing, comparison beyond length, regex matching, most methods, use as a property key, passing to a builtin that calls into C++. - What: allocate a sequential string of the full length; traverse the cons tree copying leaves; patch the cons node to point at the flat string (so other holders of the same node also benefit). O(n) once per tree.
- The consequence: building with
+=and reading once is fine (one flatten). Building with+=and reading after every append is quadratic (each read flattens the growing tree).Array.joincomputes the total length first and copies once: always flat, always linear.
// build a string: three ways, 100k pieces
let s = ''; for (const p of parts) s += p; // 100k ConsString nodes; one flatten on first read (O(n))
const t = parts.join(''); // one allocation of the final length; one copy. flat.
const u = parts.reduce((a, b) => a + b, ''); // same as the first; the reduce adds call overhead
// all three produce the same characters. the difference is when the copy happens and how many nodes exist meanwhile.
// for a few pieces: += is fine and clearer. for thousands in a hot path: join.
// keys: these are not the same cost
obj.name // internalised at parse time: pointer compare in the IC
obj['na' + 'me'] // constant-folded by the parser: same as above
obj['na' + suffix] // built at runtime: hash the string, probe the table (internalise), then the lookup. every time.
map.get(key) // Map hashes the string once per get; for dynamic keys this is the right structurenode --allow-natives-syntax -e "let s='';for(let i=0;i<5;i++)s+='abcdefgh'; %DebugPrint(s); s.charCodeAt(0); %DebugPrint(s)". The first print shows a ConsString tree; the second shows it flattened.String operations and their costs
| Operation | What happens | Cost |
|---|---|---|
a + b, template literal | ConsString if result ≥ 13 chars; else a copy | O(1) or O(small) |
s.length | Stored in the header for every shape | O(1) |
s[i], s.charCodeAt(i) | Flatten if cons; then index | O(n) once, then O(1) |
s.slice(a, b) | SlicedString if ≥ 13 chars (view); else copy | O(1), retains parent |
a === b | Pointer compare; if both internalised and different pointers, false immediately; else length compare, then flatten and compare bytes | O(1) for internalised; O(n) worst |
s.indexOf(t), includes | Flatten both; Boyer-Moore-Horspool or similar for longer needles | O(n) with a small constant |
s.split(sep) | Flatten; scan; create substrings (sliced or copied); the result array | O(n) plus allocations per piece |
s.replace(re, fn) | Regex engine (Irregexp, compiled to bytecode or native code); the callback per match | O(n) plus calls |
s.toLowerCase(), normalize() | Copy with ICU for non-ASCII; fast path for one-byte ASCII | O(n) |
JSON.stringify(obj) | A fast path for plain objects with simple values; builds one flat string via an incremental buffer | O(size); fastest serialiser you have |
JSON.parse(s) | Flatten; a dedicated parser that builds objects with precomputed maps for repeated shapes | O(n); often faster than an equivalent object literal in source for large data |
obj[dynamicString] | Internalise the key (hash, string table probe), then the property lookup | O(len) per access; use Map for dynamic keys |
new TextEncoder().encode(s) | Flatten; UTF-8 transcode into a Uint8Array | O(n); unavoidable at I/O boundaries |
Buffer.from(s) (Node) | Same transcode, into Node's pooled buffers | O(n) |
- Build with an array and join when the piece count is large or the result is read incrementally.
- Keep hot strings one-byte where you can: a single emoji or non-Latin character in a template makes the whole result two-byte.
- Use Map for dynamic keys, objects for static ones.
- Be careful with slices of huge strings you keep around; copy if the parent should die.
- Serialise once:
JSON.stringifyis faster than hand-built string concatenation for structured data, and it produces a flat string. - Regex: compiled once per literal (cached); a regex built from a string each call is recompiled each call. Hoist them.
Symbols, BigInt, and the oddballs
- A unique heap object with an optional description. Two symbols are equal only if they are the same object.
Symbol.for(key)uses a global registry so the same key gives the same symbol across realms. - As property keys they live in the same descriptor array as string keys and are just as fast; they are skipped by
for...in,Object.keysandJSON.stringify, which is their main use: metadata and protocol hooks that do not collide with user data. - Well-known symbols (
Symbol.iterator,Symbol.asyncIterator,Symbol.toPrimitive,Symbol.hasInstance,Symbol.toStringTag,Symbol.dispose) are how built-in operations dispatch to user code. The engine looks them up like any property, through the IC, so defining them on a prototype is cheap and defining them per-instance makes instances polymorphic. - Private fields (
#x) are implemented as private symbols: hidden from all reflection, stored like normal properties, with a brand check on access. As fast as a normal field once the IC is warm.
- Arbitrary precision integers stored as a heap object with a sign and an array of 64-bit digits. Never a Smi; every arithmetic result is a new allocation.
- Cost: a small BigInt add is tens of nanoseconds and an allocation; a Number add is one instruction. Fine for 64-bit ids, timestamps in nanoseconds, cryptography, exact money if you must. Wrong for loop counters and hot arithmetic.
- No mixing:
1n + 1throws. Conversions (Number(big),BigInt(n)) are explicit.BigInt64Arraystores them unboxed. - Feedback: the binary operation lattice has a BigInt kind; a site that sees both Numbers and BigInts becomes Any.
undefined,null,true,false: singletons in read-only space. Comparing to them is a pointer compare.typeofon them is a map check.- The hole: an internal oddball marking an uninitialised array element or a
letin its temporal dead zone. Reading a holey array element checks for it; reading a TDZ variable throws on it. Never observable from JavaScript directly, but the reason holey arrays and TDZ checks cost something. - NumberOrOddball feedback: an arithmetic site that saw numbers and
undefined(a missing property added to a sum) compiles a path that handles the oddball conversion. Slower than Number; common in code that sums optional fields. Default the field.
Reading values: tools
| Tool | Shows | Use it for |
|---|---|---|
%DebugPrint(v) | Smi vs HeapNumber; string shape (Seq, Cons, Sliced, Thin, one-byte or two-byte, internalised); BigInt digits | What representation a value has right now |
%IsSmi(v), %HasFastProperties, %StringIsFlat(s) (name varies by version) | Booleans | Assertions in experiments |
--trace-turbo + Turbolizer, look for Change* nodes | Representation changes in the graph; ChangeFloat64ToTagged is a boxing point | Finding where a numeric loop allocates |
--trace-deopt reasons "not a Smi", "not a heap number", "lost precision", "minus zero" | Numeric representation guards failing | Numeric type instability |
DevTools Memory → snapshot → (string) and (concatenated string) and (sliced string) entries | Strings by shape, with sizes and retainers | A cons tree never flattened; a slice retaining a huge parent; two-byte strings that could be one-byte |
DevTools Memory → snapshot → HeapNumber count (under (number) or system entries) | How many boxed doubles exist | A Tagged field or generic array full of boxed doubles |
--trace-gc while running a numeric loop | Scavenges that should not be happening | Boxing in a hot loop, found without a profiler |
Buffer.byteLength(s) vs s.length (Node) | UTF-8 bytes vs UTF-16 units | Sizing I/O; remembering that length is not bytes |
JSON.parse it; snapshot; sort the (string) entries by shallow size. Many will be sliced strings into the original response text, which is still alive because of them. That is normal, and it is also why "parsed and discarded the raw text" does not free the memory you expected.