JavaScript offers nine ways to convert a string to a number:
- parseInt()
- parseFloat()
- Number()
- Double tilde (~~) Operator
- Unary Operator (+)
- Math.floor()
- Multiply with number
- The Signed Right Shift Operator (>>)
- The Unsigned Right Shift Operator (>>>)
Here’s a comparison table showing how these methods differ in behavior:

The source code for this comparison table is available at https://airing.ursb.me/web/int.html.
Beyond behavioral differences, there are also significant performance differences. Here are micro-benchmark results in Node.js (V8 engine):
parseInt() x 19,140,190 ops/sec ±0.45% (92 runs sampled)
parseFloat() x 28,203,053 ops/sec ±0.25% (95 runs sampled)
Number() x 1,041,209,524 ops/sec ±0.20% (90 runs sampled)
Double tilde (~~) Operator x 1,035,220,963 ops/sec ±1.65% (97 runs sampled)
Math.floor() x 28,224,678 ops/sec ±0.23% (96 runs sampled)
Unary Operator (+) x 1,045,129,381 ops/sec ±0.17% (95 runs sampled)
Multiply with number x 1,044,176,084 ops/sec ±0.15% (93 runs sampled)
The Signed Right Shift Operator(>>) x 1,046,016,782 ops/sec ±0.11% (96 runs sampled)
The Unsigned Right Shift Operator(>>>) x 1,045,384,959 ops/sec ±0.08% (96 runs sampled)
parseInt(), parseFloat(), and Math.floor() are dramatically slower — around 2% the speed of the others, with parseInt() being the slowest at barely 1%.
Why such dramatic differences? What’s actually happening under the hood? Let’s explore the concrete implementations in V8, JavaScriptCore, and QuickJS — three mainstream JS engines.
Starting with parseInt().
1. parseInt()
ECMAScript (ECMA-262) parseInt

1.1 parseInt() in V8
In V8’s [→ src/init/bootstrapper.cc], built-in JS language objects are registered. Here’s the parseInt registration:
`Handle
// Install Number.parseInt and Global.parseInt.
Handle
JSObject::AddProperty(isolate_, global_object, “parseInt”, parse_int_fun, native_context()->set_global_parse_int_fun(*parse_int_fun); `
Both Number.parseInt and the global parseInt are registered via SimpleInstallFunction, binding the API to a Builtin. When JS calls parseInt, the engine calls Builtin::kNumberParseInt.
Builtins in V8 are code blocks executable at VM runtime. V8 currently supports five implementation types:
- Platform-dependent assembly: very efficient, but requires manual porting to each platform and is hard to maintain.
- C++: similar to runtime functions, full access to V8’s runtime capabilities, but generally unsuitable for performance-sensitive areas.
- JavaScript: slow runtime calls, unpredictable performance from type pollution, complex JS semantics. V8 no longer uses JS builtins.
- CodeStubAssembler: efficient low-level code close to assembly, but platform-agnostic and readable.
- Torque: an improved version of CodeStubAssembler with TypeScript-like syntax. Easier to write without sacrificing performance. Many modern builtins use Torque.
The function Builtin::kNumberParseInt maps to NumberParseInt, implemented in Torque at [→ src/builtins/number.tq]:
`// ES6 #sec-number.parseint transitioning javascript builtin NumberParseInt( js-implicit context: NativeContext)(value: JSAny, radix: JSAny): Number { return ParseInt(value, radix); }
transitioning builtin ParseInt(implicit context: Context)( input: JSAny, radix: JSAny): Number { try { // Check if radix should be 10 (i.e. undefined, 0 or 10). if (radix != Undefined && !TaggedEqual(radix, SmiConstant(10)) && !TaggedEqual(radix, SmiConstant(0))) { goto CallRuntime; }
typeswitch (input) {
case (s: Smi): {
return s;
}
case (h: HeapNumber): {
// Check if the input value is in Signed32 range.
const asFloat64: float64 = Convert<float64>(h);
const asInt32: int32 = Signed(TruncateFloat64ToWord32(asFloat64));
// The sense of comparison is important for the NaN case.
if (asFloat64 == ChangeInt32ToFloat64(asInt32)) goto Int32(asInt32);
// Check if the absolute value of input is in the [1,1<<31[ range.
const kMaxAbsValue: float64 = 2147483648.0;
const absInput: float64 = math::Float64Abs(asFloat64);
if (absInput < kMaxAbsValue && absInput >= 1.0) goto Int32(asInt32);
goto CallRuntime;
}
case (s: String): {
goto String(s);
}
case (HeapObject): {
goto CallRuntime;
}
}
} label Int32(i: int32) { return ChangeInt32ToTagged(i); } label String(s: String) { // Check if the string is a cached array index. const hash: NameHash = s.raw_hash_field; if (IsIntegerIndex(hash) && hash.array_index_length < kMaxCachedArrayIndexLength) { const arrayIndex: uint32 = hash.array_index_value; return SmiFromUint32(arrayIndex); } // Fall back to the runtime. goto CallRuntime; } label CallRuntime { tail runtime::StringParseInt(input, radix); } } `
A quick intro to V8’s data structures (see [→ src/objects/objects.h] for all definitions):
- Smi: inherits from Object; “small immediate integer,” only 31 bits
- HeapObject: inherits from Object; superclass for everything allocated on the heap
- PrimitiveHeapObject: inherits from HeapObject
- HeapNumber: inherits from PrimitiveHeapObject; a heap object storing a number (used for large integers)
parseInt accepts two parameters: parseInt(string, radix). The flow:
- First, check if
radixis absent, 0, or 10. If not, it’s non-decimal — fall back to the runtimeruntime::StringParseInt. - If it is decimal, check the type of the first argument.
- If it’s a Smi or an in-range (non-overflowing) HeapNumber, return the input directly — no conversion needed. If it overflows,
ChangeInt32ToTaggedis called, which force-casts to Int32 — results may be unexpected. - If it’s a String, check whether it’s a cached array index. If so, return the integer value; otherwise fall back to
runtime::StringParseInt.
- If it’s a Smi or an in-range (non-overflowing) HeapNumber, return the input directly — no conversion needed. If it overflows,
The focus shifts to runtime::StringParseInt in [→ src/runtime/runtime-numbers.cc]:
`// ES6 18.2.5 parseInt(string, radix) slow path RUNTIME_FUNCTION(Runtime_StringParseInt) { HandleScope handle_scope(isolate); DCHECK_EQ(2, args.length()); Handle