Passing 64-bit Integers with BigInt

This page answers one task: a WebAssembly function takes or returns 64-bit integers — ids, timestamps in nanoseconds, file offsets, hashes — and JavaScript must handle them without silently losing precision or mixing up signed and unsigned values.

Prerequisites

  • [ ] A module with i64 parameters or results (Rust u64/i64, C int64_t/uint64_t).
  • [ ] A browser or runtime with WebAssembly BigInt integration (all current browsers, Node 15+).

Why 64-bit integers are special

JavaScript’s Number is a 64-bit float, which represents integers exactly only up to 2⁵³ − 1 (Number.MAX_SAFE_INTEGER, about 9 × 10¹⁵). WebAssembly’s i64 holds the full 64-bit range. Converting one to the other silently rounds anything larger: the id 9007199254740993 becomes 9007199254740992, and two distinct ids compare equal. Nothing throws; the bug surfaces later as a wrong record, a duplicate key or a hash mismatch.

The JS-API therefore maps i64 to BigInt, JavaScript’s arbitrary-precision integer type. A Wasm function returning i64 returns a BigInt (123n); a function taking i64 requires a BigInt and throws a TypeError if given a Number. That strictness is deliberate: it prevents silent precision loss at the boundary. Older engines without BigInt integration could not pass i64 at all, which is why some toolchains still split 64-bit values into two 32-bit halves (-sWASM_BIGINT=0 in Emscripten, now off by default).

A 64-bit id crossing the boundary Rust returns a u64 id. WebAssembly passes it as an i64, which the JS-API converts to a BigInt, preserving every bit. Converting that BigInt to a Number would round it if it exceeds 2 to the 53rd minus 1. Rust u64 9007199254740993 Wasm i64 64 bits, exact JS BigInt 9007199254740993n Number(id) 9007199254740992 ✗

Step 1 — receive BigInt from raw exports

(func (export "file_size") (param $handle i32) (result i64) …)
const size = instance.exports.file_size(h);       // 5368709120n (5 GiB)
typeof size;                                      // "bigint"
instance.exports.seek(h, 4096n);                  // passing i64: must be a BigInt
instance.exports.seek(h, 4096);                   // TypeError: Cannot convert 4096 to a BigInt

Pass values with the n suffix or BigInt(x). Convert results to Number only when you know they are in the safe range, and check rather than assume:

function toSafeNumber(b) {
  if (b > BigInt(Number.MAX_SAFE_INTEGER) || b < BigInt(Number.MIN_SAFE_INTEGER)) {
    throw new RangeError(`${b} is outside the safe integer range`);
  }
  return Number(b);
}

Step 2 — signed versus unsigned

WebAssembly has one i64 type; signedness is in the operations, not the type. The JS-API always converts i64 as signed: a Rust u64 of 18446744073709551615 (all bits set) arrives in JavaScript as -1n from a raw export. Convert explicitly:

const asU64 = (b) => BigInt.asUintN(64, b);       // -1n → 18446744073709551615n
const asI64 = (b) => BigInt.asIntN(64, b);        // reverse, before passing a large u64 back in

BigInt.asUintN and asIntN reinterpret the bits without arithmetic, exactly like a cast in Rust or C. Hashes, bitmasks and unsigned ids above 2⁶³ need this; most other values — sizes, timestamps, counts — never reach the sign bit, so the issue stays hidden until a large value appears.

Step 3 — let wasm-bindgen do the conversion

wasm-bindgen handles signedness for you: u64 parameters and results are converted as unsigned, i64 as signed, both as bigint in TypeScript:

#[wasm_bindgen]
pub fn hash64(data: &[u8]) -> u64 { xxhash_rust::xxh3::xxh3_64(data) }

#[wasm_bindgen]
pub fn from_unix_nanos(nanos: i64) -> String { /* … */ }
hash64(bytes);                  // 15523640528713421934n — unsigned, as in Rust
from_unix_nanos(1_700_000_000_000_000_000n);

u128 and i128 are also supported in recent versions, converted to bigint. Collections of 64-bit values are best passed as BigUint64Array or BigInt64Array, which wasm-bindgen accepts for &[u64] and &[i64] and which view linear memory directly with no per-element conversion.

64-bit types at the boundary A raw i64 export always converts as signed BigInt. wasm-bindgen converts u64 as unsigned and i64 as signed. Slices of 64-bit values use BigUint64Array and BigInt64Array. Converting to Number is safe only within plus or minus 2 to the 53rd. source JavaScript type notes raw export i64 bigint (signed) use asUintN for unsigned wasm-bindgen u64 bigint (unsigned) handled by glue wasm-bindgen i64 bigint (signed) handled by glue &[u64] / Vec<u64> BigUint64Array no per-element cost Number(bigint) number exact only up to 2^53 − 1

Step 4 — decide where precision is needed

Not every 64-bit value needs to stay a BigInt. Sizes in bytes, millisecond timestamps and counts are within 2⁵³ in practice; converting them to Number at the wrapper makes them pleasant to use in arithmetic and UI code, provided the conversion checks the range. Identifiers, hashes, nanosecond timestamps and bitmasks must stay exact: keep them as BigInt, or convert to strings for display and for use as Map keys and in JSON. Make the choice per field in the wrapper, and type it in TypeScript (bigint versus number) so callers cannot mix them up — TypeScript rejects bigint + number, which catches many mistakes at compile time.

A practical approach is to define two small wrapper types in the TypeScript layer — an Id alias for bigint and plain number for everything range-checked — and convert at exactly one place: the function that wraps each export. Application code then never decides; it receives values already in the right representation, with the conversion and its range check written once and tested once.

Step 5 — handle JSON and serialisation

JSON.stringify throws on BigInt values (TypeError: Do not know how to serialize a BigInt), and JSON numbers lose precision when parsed. Encode 64-bit values as strings in JSON, and convert back with BigInt(str). With serde-wasm-bindgen, enable serialize_large_number_types_as_bigints(true) to get bigint in JavaScript objects, as described in serializing data with serde-wasm-bindgen; the default converts to Number and errors if the value is not exactly representable. For persisted data, serialising as decimal strings is the most portable choice.

Where 64-bit values come from without being asked for

Some 64-bit values arrive by surprise. WASI functions use u64 for file sizes, offsets and timestamps, so a module that calls clock_time_get or fd_seek produces 64-bit results that bindings then expose as BigInt. Rust’s usize is 32 bits on wasm32 but 64 bits on wasm64, so code moving to Memory64 sees pointer-sized values change type at the boundary. Hash functions such as xxHash and FNV-64 return u64 naturally. And C code using long long or time_t (64-bit in Emscripten) produces i64 exports. When a function that used to return a Number starts returning a BigInt after a dependency or toolchain update, the cause is usually one of these, and the fix belongs in the wrapper: decide per value whether it should stay a BigInt or be range-checked into a Number. A unit test that calls each export and asserts the typeof of its result catches such changes before they reach callers that do arithmetic with mixed types.

Displaying and formatting 64-bit values

BigInts render fine with String(x) and template literals, but common formatting tools need care. Intl.NumberFormat accepts BigInt and formats it with grouping separators — new Intl.NumberFormat("en").format(5368709120n) gives "5,368,709,120" — and toLocaleString() works on BigInt directly. For byte sizes, divide in BigInt first (size / 1024n), then convert the much smaller result to Number for fractional formatting. Hashes are usually shown in hexadecimal: x.toString(16).padStart(16, "0") produces the fixed-width form that matches what Rust prints with {:016x}, which makes values easy to compare across logs from both sides. Timestamps in nanoseconds are best converted to milliseconds with Number(nanos / 1_000_000n) before passing them to Date, which only understands millisecond Numbers. Doing the division in BigInt keeps full precision until the value is small enough to be safe. Centralising these conversions in a few helper functions keeps display code clean and makes the precision rules visible in one place.

Expected output

hash64(bytes) returns the same 64-bit value as the Rust native test, as an unsigned bigint; an id of 9007199254740993n round-trips through the module unchanged; and toSafeNumber throws instead of silently rounding when a size exceeds 2⁵³ − 1.

Gotchas

  • Passing a Number where i64 is expected. Raw exports throw TypeError. Pass a BigInt.
  • Unsigned values arriving negative. Raw exports convert as signed. Use BigInt.asUintN(64, x).
  • Number(big) rounding silently. Range-check before converting.
  • JSON.stringify throwing on BigInt. Convert to strings first, or supply a replacer.
  • Mixing bigint and number in arithmetic. It throws. Convert explicitly on one side.

Performance note

Passing a BigInt across the boundary cost about 15 ns more per call than an i32 in Chrome, because BigInt values are heap objects. For bulk data, a BigUint64Array view over linear memory read 1 million values in 4 ms; returning them one call at a time took 31 ms.

Reading one million 64-bit values from Wasm Milliseconds to obtain one million u64 values in JavaScript, one export call per value returning a BigInt, versus a single BigUint64Array view over linear memory. ms for 1M values one call per value 31 ms BigUint64Array view 4 ms

Frequently Asked Questions

Can I avoid BigInt by splitting into two 32-bit halves? Yes, and some older APIs do, but BigInt integration is universal now and simpler. Splitting remains useful for very hot paths.

Are BigInt operations slow? Slower than Number arithmetic, especially for allocation-heavy loops, but fine for ids and occasional values.

What about Emscripten? -sWASM_BIGINT is on by default in current versions, so int64_t crosses as BigInt; older code may still use legalised pairs.

Can I use BigInt as a Map key? Yes — BigInts compare by value, so map.get(5n) finds a key set with 5n.

Does WebAssembly need BigInt for u32 values above 2³¹? No. i32 results are converted as signed Numbers, so a u32 above 2³¹ arrives negative; fix it with x >>> 0. wasm-bindgen does this for u32.

Can typed arrays of BigInt be transferred to workers? Yes — BigInt64Array and BigUint64Array are ordinary typed arrays whose buffers can be transferred or shared.

← Back to Passing Complex Types Across the Boundary