BigInt inside the engine
Learn how V8 stores BigInts as heap digit arrays, chooses arithmetic algorithms, converts strings, handles 64-bit paths, and enforces size limits.
- 01Read a V8 BigInt layoutExplain sign, length, little-endian 64-bit digits, zero length, and why V8 has no Smi-like BigInt immediate.
- 02Connect operations to costsTrace digit addition, one Karatsuba split, V8 multiplication thresholds, string conversion strategies, and division choices.
- 03Use BigInt deliberatelyChoose Number, BigInt, BigInt64Array, and fixed-width wrapping without making timing claims or mixing numeric types accidentally.
BigInts are heap digit arrays
A JavaScript BigInt is the language's exact integer value. Inside V8 12.4, the value is represented by a heap object whose payload is a sign plus an array of machine-word digits. The V8 blog's original BigInt article describes the same idea as storing a large integer in register-sized chunks called digits, with the sign stored separately.
In V8, a BigInt is a BIG_INT_BASE_TYPE heap object with a sign bit, a digit length, and little-endian digits. Small Numbers can be Smis, but V8 has no Smi-like immediate form for BigInt: even 1n is a BigInt heap object.
This lesson builds on the beginner BigInt lesson and Tagged values, Smis & heap numbers. We focus on storage and costs, not syntax: why arithmetic creates new immutable results, why multiplication changes algorithms, why decimal strings are work, and how fixed-width 64-bit APIs fit into the picture.
Write a long number across several boxes on a form. The sign, the number of boxes, and the contents of each box are separate. Changing the number means making a fresh result instead of changing the old one.
- In real life: A plus or minus box
- In JavaScript: V8's sign bit
- In real life: The count of number boxes used
- In JavaScript: The BigInt digit length
- In real life: Each box holds one fixed-size part
- In JavaScript: One 64-bit digit on this Node/V8 build
- In real life: A changed number uses a fresh form
- In JavaScript: BigInt arithmetic returns a new immutable value
Where the analogy stops: A paper form uses decimal digits. V8 stores binary machine words and can switch algorithms for very large values.
Sign, length, and 64-bit digits
The primary source is V8's src/objects/bigint.h. BigIntBase stores sign and length in one atomic bitfield, defines digit_t = uintptr_t, and exposes a flexible digit array. On the Node 22 build used by the tests, uintptr_t is 64 bits, so 2n ** 64n prints two digits: 0x0 then 0x1.
| Piece | Verified fact | What it means |
|---|---|---|
| Map | BIG_INT_BASE_TYPE; %DebugPrint shows the map itself in ReadOnlySpace. | All ordinary BigInt values point at this heap-object shape in V8. |
| Bitfield | One atomic bitfield stores the sign bit and a 30-bit length. | sign: 1 means negative; length: 0 represents 0n. |
| Digits | A flexible array of uintptr_t digits, so Node's non-pointer-compressed 64-bit build uses 64-bit digits. | Digits are little-endian: 2n ** 64n prints 0x0, then 0x1. |
%DebugPrintJavaScript// Node-only: run with --allow-natives-syntax.for (const [label, value] of [ ["ten", 10n], ["two64", 2n ** 64n], ["negative", -5n], ["zero", 0n],]) { process.stdout.write("@@" + label + "\n"); %DebugPrint(value);}The test asserts only stable text: [BigInt], BIG_INT_BASE_TYPE, instance size: variable, length, sign, and digit lines. It does not assert addresses or exact object sizes. Node 22 here uses V8 12.4 without pointer compression; Chrome uses pointer compression, the sandbox, and a newer V8, so address-looking details differ.
const value = 2n ** 64n;const mask = (1n << 64n) - 1n;const sign = value < 0n ? "negative" : "positive";const lowDigit = value & mask;const nextDigit = (value >> 64n) & mask; console.log(sign);console.log("0x" + lowDigit.toString(16));console.log("0x" + nextDigit.toString(16));0240 bytes64-bit digits, least significant first
- digit 0
0x0 - digit 1
0x1
18446744073709551616n has sign 0, length 2, and digits 0x0, 0x1. The byte count is a labelled teaching model, not a real object-size probe.
& and >> operations. %DebugPrint in the tests proves the same little-endian digits for Node/V8 12.4.The playground computes the same little-endian digits with real BigInt operations: mask with 0xffffffffffffffffn, then shift by 64n. Its byte count is labelled as a teaching model so it does not pretend to be a heap inspector.
const cache = new Map([[1n, "a"]]);const set = new Set([1n, BigInt("1")]); console.log(1n === 1n);console.log(cache.get(1n));console.log(set.size);1n === 1n compares the BigInt value. The Map lookup and the Set size show that BigInt keys use value hashing, not heap-object identity.
Addition is digit-by-digit
Addition and subtraction walk the digit arrays with carries or borrows. A one-digit add is cheap; a thousand-digit add touches roughly a thousand digits. V8 creates a fresh BigInt result because BigInt values are immutable. The public value is primitive, but the internal result storage still has to be allocated unless the compiler can avoid it in a narrower optimized path.
Step through schoolbook BigInt addition: add each 64-bit digit, keep low bits, carry high bits, then compare with native BigInt.
script
const BIGINT_BASE = 1n << 64n;const DIGIT_MASK = BIGINT_BASE - 1n; function fromDigits(digits) { let value = 0n; for (let index = digits.length - 1; index >= 0; index -= 1) { value = value * BIGINT_BASE + digits[index]; } return value;} function addDigits(left, right) { const out = []; let carry = 0n; const length = Math.max(left.length, right.length); for (let index = 0; index < length; index += 1) { const sum = (left[index] ?? 0n) + (right[index] ?? 0n) + carry; out[index] = sum & DIGIT_MASK; carry = sum >> 64n; } if (carry) out.push(carry); return out;} const right = [0x2n];const digits = addDigits(left, right);console.log(digits.map((digit) => "0x" + digit.toString(16)).join(", "));console.log(fromDigits(digits) === fromDigits(left) + fromDigits(right));Line 17 in the walkthrough is the whole trick: add one 64-bit lane from the left, one from the right, and the carry. Line 18 stores the low 64 bits, and line 19 carries the overflow into the next digit. The final line checks the teaching implementation against native BigInt addition.
Multiplication algorithms
V8's bigint-internal.h sets the thresholds. The operation dispatcher in bigint-internal.cc chooses based on the shorter operand's digit count. The 2018 V8 BigInt blog explains the original schoolbook implementation; V8 12.4 also ships Karatsuba, Toom-Cook-3, and FFT files.
V8 12.4.254.21 src/bigint/bigint-internal.h:kKaratsubaThreshold = 34 digitskToomThreshold = 193 digitskFftThreshold = 1500 digitskFftInnerThreshold = 200 digitskBurnikelThreshold = 57 digitskBarrettThreshold = 13310 digitskToStringFastThreshold = 43 digitskFromStringLargeThreshold = 300 digits| Algorithm | Range | Cost model | Primary source |
|---|---|---|---|
| single digit | shorter operand length is 1 | O(n) | MultiplySingle in src/bigint/bigint-internal.cc |
| schoolbook | 2 through 33 digits | O(n²) | kKaratsubaThreshold = 34; mul-schoolbook.cc |
| Karatsuba | 34 through 192 digits | about O(n^1.585) | kToomThreshold = 193; mul-karatsuba.cc |
| Toom-Cook-3 | 193 through 1499 digits | about O(n^1.465) | kFftThreshold = 1500; mul-toom.cc |
| FFT | 1500 digits and above | Schönhage-Strassen-style sub-quadratic | mul-fft.cc; advanced algorithms are enabled by default except Android |
The FFT file says the algorithm is due to Schönhage and Strassen. The build flag v8_advanced_bigint_algorithms is enabled by default except on Android; without it, the source falls back to Karatsuba for larger products.
Step through one Karatsuba split: split, compute z0, z2, one cross term, then recombine and check the real product.
script
const SPLIT_BASE = 1000n; function karatsubaOneLevel(x, y) { const xLow = x % SPLIT_BASE; const xHigh = x / SPLIT_BASE; const yLow = y % SPLIT_BASE; const yHigh = y / SPLIT_BASE; const z0 = xLow * yLow; const z2 = xHigh * yHigh; const z1 = (xLow + xHigh) * (yLow + yHigh) - z2 - z0; return z2 * SPLIT_BASE * SPLIT_BASE + z1 * SPLIT_BASE + z0;} console.log(product === 123456n * 789012n);console.log(product.toString());Karatsuba saves work by replacing two cross multiplications with one product of sums and two subtractions. The walkthrough uses base 1000n so the split is readable; V8 splits in machine-word digits.
| Path | When it appears | What to remember |
|---|---|---|
| single digit | divisor length is 1 | Divide by one machine-word digit. |
| schoolbook / Knuth | divisor length below 57 | kBurnikelThreshold = 57; V8's source says the schoolbook path is loosely based on Go/Knuth. |
| Burnikel-Ziegler | large divisor, or Barrett not selected | Recursive division from div-burnikel.cc. |
| Barrett | advanced algorithms, divisor length at least 13310, and dividend longer than divisor | kBarrettThreshold = 13310; implemented in div-barrett.cc. |
- 10-digit operands
- 33-digit operands
- 34-digit operands
- 192-digit operands
- 193-digit operands
- 1499-digit operands
- 1500-digit operands
Sort each shorter-operand digit count by the V8 12.4 thresholds.
Converting to and from strings
BigInt values store binary digits, not decimal text. tostring.cc uses a special base-power-of-two path for radices like 16, and a divide-and-conquer path for large non-power radices. fromstring.cc parses large inputs by combining neighboring chunks so fast multiplication can help.
| Case | Algorithm | Why it matters |
|---|---|---|
| Power-of-two radix | Use bit grouping for bases such as 2, 8, 16, and 32. | No repeated decimal division is needed. |
| Small non-power radix | Classic multiply/divide by chunks. | Good for shorter values. |
Large toString | V8 switches at kToStringFastThreshold = 43 digits. | The fast path recursively divides by half-size powers and uses Barrett division. |
| Large parse | V8 switches at kFromStringLargeThreshold = 300 result digits. | It combines neighboring chunks so fast multiplication algorithms can help. |
const decimal = "900719925474099312345";const id = BigInt(decimal);const hex = id.toString(16); console.log(hex);console.log(BigInt("0x" + hex) === id);Printing a huge decimal BigInt is expensive because the engine must convert from base 2**64 digits to base 10 characters. That is useful work, not a cached property read.
BigInt64 fast paths and typed arrays
Fixed-width APIs are different from arbitrary-precision BigInt arithmetic. BigInt.asIntN(64, value) and BigInt.asUintN(64, value) keep the low 64 bits. BigInt64Array and BigUint64Array store raw 64-bit lanes in an ArrayBuffer; reading an element gives JavaScript a BigInt value.
const buffer = new ArrayBuffer(16);const signed = new BigInt64Array(buffer);const bytes = new Uint8Array(buffer); signed[0] = -1n;signed[1] = (1n << 63n) - 1n; console.log(signed[0]);console.log(bytes.slice(0, 8).every((byte) => byte === 255));console.log(BigInt.asUintN(64, -1n));V8 12.4's type-hints.h includes kBigInt and kBigInt64. Compiler sources such as simplified-lowering.cc lower checked BigInt64 operations to word-64 machine representations in the right contexts. This is a representation fact, not a promise that every BigInt expression is faster.
The byte view proves the buffer holds raw bytes: writing -1n to a signed lane fills the first eight bytes with 255. The read still returns a BigInt primitive, not a Number.
Maximum BigInt size
BigInt means arbitrary precision, not infinite precision. V8's BigInt::kMaxLengthBits is 1 << 30, about one billion bits. That value is in src/objects/bigint.h, beside the note that V8 chooses a platform-independent limit below the implementation maximum.
// Node-only guard: V8 rejects the result length before allocating the value.try { 2n ** (2n ** 30n);} catch (error) { console.log(error.name + ": " + error.message);}The lesson test runs the probe in a child process and asserts RangeError: Maximum BigInt size exceeded. The exponent is rejected by V8's size guard quickly; the test does not try to allocate a billion-bit result.
Other engine layouts
The language rules are shared by the ECMAScript BigInt specification, but engines can choose different private storage. These source snapshots are useful comparisons, not portability hooks.
| Engine | Storage choice | Verified source fact |
|---|---|---|
| V8 12.4 | Heap object for every BigInt value | Sign/length bitfield plus machine-word digits; no Smi-like BigInt immediate. |
| JavaScriptCore | Optional BigInt32 immediate exists in source | USE(BIGINT32) adds a BigInt32Tag, but WebKit currently defines USE_BIGINT32 0. |
| SpiderMonkey | Heap cell with inline digits | JS::BigInt stores small digit arrays inline before switching to heap digit storage. |
JavaScriptCore's JSCJSValue.h has EncodeAsBigInt32 and a BigInt32Tag under USE(BIGINT32), but WebKit's platform header currently defines USE_BIGINT32 0. SpiderMonkey's BigIntType.h defines InlineDigitsLength and uses inline digits until the value needs heap digit storage.
Practical use
Use Number when values fit inside Number.MAX_SAFE_INTEGER, when decimals matter, or when you need Math APIs. Use BigInt for exact large integers: database IDs, cryptographic math, bit masks, counters that cross 53 bits, and 64-bit protocol fields.
const fromProtocol = BigInt.asUintN(64, 18446744073709551616n + 5n);const smallCount = 42n; console.log(fromProtocol);console.log(Number(smallCount));- Keep Number and BigInt arithmetic separate; convert deliberately at boundaries.
- Use
BigInt.asUintN(64, value)orBigInt.asIntN(64, value)when the domain is fixed-width. - Do not convert a large decimal ID through Number first; parse the string with
BigInt(text). - Measure hot paths. Algorithm thresholds and BigInt64 lowering are engine facts, not universal timing claims.
Common misconceptions
- “Small BigInts are immediate values.” Not in V8 12.4. The tests prove
10nis a[BigInt]heap object. - “BigInt has no maximum.” V8 sets a maximum bit length and throws a RangeError when the result would exceed it.
- “BigInt64Array stores BigInt objects in the buffer.” The buffer stores raw 64-bit lanes; reads produce BigInt values.
- “Decimal printing is free.” Converting binary digits to decimal characters is an algorithm with real cost.
| Idea | Accurate statement | Common trap |
|---|---|---|
| BigInt value equality | 1n === 1n compares numeric BigInt values. | It is not object identity; Map and Set use value hashing for BigInt keys. |
| Heap allocation | V8 BigInt values are heap objects and arithmetic creates immutable results. | That does not mean every operation is slow enough to matter in real programs. |
| BigInt64Array | The array stores raw 64-bit lanes in an ArrayBuffer. | Reading an element produces a BigInt value; the element is not a heap BigInt sitting inside the buffer. |
| Maximum size | V8 rejects values above 1 << 30 bits. | BigInt is arbitrary precision within implementation and memory limits, not mathematical infinity. |
Two forms can both show the number 1. When you need the value, the number matters, not which sheet it is on. BigInt keys work that way in Map and Set.
- In real life: Two forms both show 1
- In JavaScript: Two expressions can produce the same
1nvalue - In real life: You compare the written number
- In JavaScript: Map and Set use BigInt value equality
- In real life: A changed total goes on a new form
- In JavaScript: Arithmetic creates a new immutable result
Where the analogy stops: Paper forms have separate physical identities. BigInt primitives compare by numeric value, so two ways of producing 1n are the same Map or Set key.
Practice exercises
2n ** 64nRun the program and type the two digit strings in order.
const value = 2n ** 64n;
const mask = (1n << 64n) - 1n;
console.log("0x" + (value & mask).toString(16));
console.log("0x" + ((value >> 64n) & mask).toString(16));const value = 2n ** 64n;
const mask = (1n << 64n) - 1n;
console.log("0x" + (value & mask).toString(16));
console.log("0x" + ((value >> 64n) & mask).toString(16));The low 64 bits are zero, and the next 64-bit digit is one.
Predict the value returned by the Map lookup.
const cache = new Map([[1n, "a"]]);
console.log(cache.get(BigInt("1")));const cache = new Map([[1n, "a"]]);
console.log(cache.get(BigInt("1")));BigInt("1") and 1n are the same BigInt value for Map lookup.
Write the low digit and the carry produced by the addition.
const mask = (1n << 64n) - 1n;
const sum = 0xffffffffffffffffn + 2n;
console.log("0x" + (sum & mask).toString(16));
console.log(sum >> 64n);const mask = (1n << 64n) - 1n;
const sum = 0xffffffffffffffffn + 2n;
console.log("0x" + (sum & mask).toString(16));
console.log(sum >> 64n);The low digit is 0x1; the overflow is one 64-bit digit of carry.
Run the multiplication and type the decimal product.
console.log((123456n * 789012n).toString());console.log((123456n * 789012n).toString());Native multiplication confirms the same product as the one-level split.
Predict the unsigned wrapped value.
console.log(BigInt.asUintN(64, -1n));console.log(BigInt.asUintN(64, -1n));The low 64 bits of -1n are all ones, so the unsigned value is 2**64 - 1.
Use the 64-bit wrapping rule to predict the remaining value.
console.log(BigInt.asUintN(64, 18446744073709551616n + 5n));console.log(BigInt.asUintN(64, 18446744073709551616n + 5n));Modulo 2**64, the huge value leaves only 5n.
Quiz
Question 1 of 8What is a V8 BigInt in this lesson's verified Node/V8 build?
Choose an answer to see the explanation.
Question 2 of 8What does V8 print for
0n's digit length?Choose an answer to see the explanation.
Question 3 of 8What does this BigInt key code print?
Read the code, then predictconst cache = new Map([[1n, "a"]]); const set = new Set([1n, BigInt("1")]); console.log(1n === 1n); console.log(cache.get(1n)); console.log(set.size);Choose an answer to see the explanation.
Question 4 of 8At what shorter-operand digit length does V8 12.4 switch from schoolbook to Karatsuba multiplication?
Choose an answer to see the explanation.
Question 5 of 8What does this fixed-width BigInt code print?
Read the code, then predictconst buffer = new ArrayBuffer(8); const signed = new BigInt64Array(buffer); signed[0] = -1n; console.log(signed[0]); console.log(BigInt.asUintN(64, signed[0]));Choose an answer to see the explanation.
Question 6 of 8Why can
big.toString(10)be expensive for a huge BigInt?Choose an answer to see the explanation.
Question 7 of 8What error does V8 throw for
2n ** (2n ** 30n)?Choose an answer to see the explanation.
Question 8 of 8What is the practical rule for 64-bit protocol fields?
Choose an answer to see the explanation.
Key takeaways
- V8 BigInts are heap objects with a sign, a length, and little-endian machine-word digits.
- Zero has length zero;
2n ** 64nhas digits0x0and0x1on the verified 64-bit build. - Addition is linear in digit count; multiplication switches from schoolbook to Karatsuba, Toom-Cook, and FFT at verified thresholds.
- String conversion and parsing are real algorithms, especially for large decimal inputs.
- Use BigInt for exact large integers, and use
asUintN(64)/asIntN(64)for fixed-width lanes.
One-line definition: A BigInt is an exact integer value that V8 stores as an immutable heap object containing sign, length, and machine-word digits.
Up next: Symbols inside the engine.