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<JSFunction> number_fun = InstallFunction(isolate_, global, "Number", JS_PRIMITIVE_WRAPPER_TYPE, JSPrimitiveWrapper::kHeaderSize, 0, isolate_->initial_object_prototype(), Builtin::kNumberConstructor);
// Install Number.parseInt and Global.parseInt.
Handle<JSFunction> parse_int_fun = SimpleInstallFunction(isolate_, number_fun, "parseInt", Builtin::kNumberParseInt, 2, true);
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<Object> string = args.at(0);
Handle<Object> radix = args.at(1);
Handle<String> subject;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, subject,
Object::ToString(isolate, string));
subject = String::Flatten(isolate, subject);
if (!radix->IsNumber()) {
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, radix,
Object::ToNumber(isolate, radix));
}
int radix32 = DoubleToInt32(radix->Number());
if (radix32 != 0 && (radix32 < 2 || radix32 > 36)) {
return ReadOnlyRoots(isolate).nan_value();
}
double result = StringToInt(isolate, subject, radix32);
return *isolate->factory()->NewNumber(result);
}
Worth noting: per the standard, if radix is outside the range [2, 36], NaN is returned.
1.2 parseInt() in JavaScriptCore
JavaScriptCore registers built-in functions in [→ runtime/JSGlobalObjectFunctions.cpp]:
JSC_DEFINE_HOST_FUNCTION(globalFuncParseInt, (JSGlobalObject* globalObject, CallFrame* callFrame))
{
JSValue value = callFrame->argument(0);
JSValue radixValue = callFrame->argument(1);
// Optimized handling for numbers:
// If the argument is 0 or a number in range 10^-6 <= n < INT_MAX+1, then parseInt
// results in a truncation to integer.
static const double tenToTheMinus6 = 0.000001;
static const double intMaxPlusOne = 2147483648.0;
if (value.isNumber()) {
double n = value.asNumber();
if (((n < intMaxPlusOne && n >= tenToTheMinus6) || !n) && radixValue.isUndefinedOrNull())
return JSValue::encode(jsNumber(static_cast<int32_t>(n)));
}
return toStringView(globalObject, value, [&] (StringView view) {
return JSValue::encode(jsNumber(parseInt(view, radixValue.toInt32(globalObject))));
});
}
The comments are thorough — I’ll let them speak for themselves. Ultimately it calls parseInt, whose core implementation lives in [→ runtime/ParseInt.h]. The code follows the ECMAScript spec step by step with excellent comments — I highly recommend reading it directly.
1.3 parseInt() in QuickJS
QuickJS’s core is in [→ quickjs.c]. The registration:
static const JSCFunctionListEntry js_global_funcs[] = {
JS_CFUNC_DEF("parseInt", 2, js_parseInt ),
//...
}
The js_parseInt implementation:
static JSValue js_parseInt(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
const char *str, *p;
int radix, flags;
JSValue ret;
str = JS_ToCString(ctx, argv[0]);
if (!str)
return JS_EXCEPTION;
if (JS_ToInt32(ctx, &radix, argv[1])) {
JS_FreeCString(ctx, str);
return JS_EXCEPTION;
}
if (radix != 0 && (radix < 2 || radix > 36)) {
ret = JS_NAN;
} else {
p = str;
p += skip_spaces(p);
flags = ATOD_INT_ONLY | ATOD_ACCEPT_PREFIX_AFTER_SIGN;
ret = js_atof(ctx, p, NULL, radix, flags);
}
JS_FreeCString(ctx, str);
return ret;
}
Bellard’s code has minimal comments but is wonderfully concise.
All three engines implement parseInt based on the ECMAScript standard, but each has its own style — reading them feels like comparing three authors with distinct voices.
Looking at the standard vs. the implementations, we can see that parseInt does a lot of preliminary work before the actual string-to-number conversion: input validation, default values, string normalization, range checks, and more. That’s why it’s slower — not because the core math is expensive, but because of all the setup.
Next, let’s look at parseFloat.
2. parseFloat()
ECMAScript (ECMA-262) parseFloat

Two key differences from parseInt:
- Only one parameter — no radix support
- Returns floating-point values
2.1 parseFloat() in V8
The relevant code lives right next to parseInt. The key part [→ src/builtins/number.tq]:
// ES6 #sec-number.parsefloat
transitioning javascript builtin NumberParseFloat(
js-implicit context: NativeContext)(value: JSAny): Number {
try {
typeswitch (value) {
case (s: Smi): {
return s;
}
case (h: HeapNumber): {
// Take care of -0.
return (Convert<float64>(h) == 0) ? SmiConstant(0) : h;
}
case (s: String): {
goto String(s);
}
case (HeapObject): {
goto String(string::ToString(context, value));
}
}
} label String(s: String) {
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);
}
return runtime::StringParseFloat(s);
}
}
And [→ src/runtime/runtime-numbers.cc]:
// ES6 18.2.4 parseFloat(string)
RUNTIME_FUNCTION(Runtime_StringParseFloat) {
HandleScope shs(isolate);
DCHECK_EQ(1, args.length());
Handle<String> subject = args.at<String>(0);
double value = StringToDouble(isolate, subject, ALLOW_TRAILING_JUNK,
std::numeric_limits<double>::quiet_NaN());
return *isolate->factory()->NewNumber(value);
}
Simpler than parseInt, reflecting the simpler spec.
2.2 parseFloat() in JavaScriptCore
Even more concise:
static double parseFloat(StringView s)
{
unsigned size = s.length();
if (size == 1) {
UChar c = s[0];
if (isASCIIDigit(c))
return c - '0';
return PNaN;
}
if (s.is8Bit()) {
const LChar* data = s.characters8();
const LChar* end = data + size;
for (; data < end; ++data) {
if (!isStrWhiteSpace(*data))
break;
}
if (data == end)
return PNaN;
return jsStrDecimalLiteral(data, end);
}
const UChar* data = s.characters16();
const UChar* end = data + size;
for (; data < end; ++data) {
if (!isStrWhiteSpace(*data))
break;
}
if (data == end)
return PNaN;
return jsStrDecimalLiteral(data, end);
}
2.3 parseFloat() in QuickJS
QuickJS fits it in 12 lines:
static JSValue js_parseFloat(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
const char *str, *p;
JSValue ret;
str = JS_ToCString(ctx, argv[0]);
if (!str)
return JS_EXCEPTION;
p = str;
p += skip_spaces(p);
ret = js_atof(ctx, p, NULL, 10, 0);
JS_FreeCString(ctx, str);
return ret;
}
The brevity is intentional — the latest ECMAScript spec doesn’t require the ASCII/8-bit compatibility checks that JavaScriptCore includes, and QuickJS stays minimal.
3. Number()
ECMAScript (ECMA-262) Number ( value )

3.1 Number() in V8
Number is registered in [→ src/init/bootstrapper.cc]. The constructor Builtin::kNumberConstructor is implemented in Torque [→ src/builtins/constructor.tq]:
// ES #sec-number-constructor
transitioning javascript builtin
NumberConstructor(
js-implicit context: NativeContext, receiver: JSAny, newTarget: JSAny,
target: JSFunction)(...arguments): JSAny {
// 1. If no arguments were passed to this function invocation, let n be +0.
let n: Number = 0;
if (arguments.length > 0) {
// 2. Else,
// a. Let prim be ? ToNumeric(value).
// b. If Type(prim) is BigInt, let n be the Number value for prim.
// c. Otherwise, let n be prim.
const value = arguments[0];
n = ToNumber(value, BigIntHandling::kConvertToNumber);
}
// 3. If NewTarget is undefined, return n.
if (newTarget == Undefined) return n;
const target: JSFunction = LoadTargetFromFrame();
const result = UnsafeCast<JSPrimitiveWrapper>(
FastNewObject(context, target, UnsafeCast<JSReceiver>(newTarget)));
result.value = n;
return result;
}
Steps 1–6 in the comments map directly to the ECMAScript spec. Notably, Number explicitly supports BigInt — which is why our comparison table showed those results.
3.2 Number() in JavaScriptCore
Same logic as V8, closely following the spec [→ runtime/NumberConstructor.cpp]:
// ECMA 15.7.1
JSC_DEFINE_HOST_FUNCTION(constructNumberConstructor, (JSGlobalObject* globalObject, CallFrame* callFrame))
{
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
double n = 0;
if (callFrame->argumentCount()) {
JSValue numeric = callFrame->uncheckedArgument(0).toNumeric(globalObject);
RETURN_IF_EXCEPTION(scope, { });
if (numeric.isNumber())
n = numeric.asNumber();
else {
ASSERT(numeric.isBigInt());
numeric = JSBigInt::toNumber(numeric);
ASSERT(numeric.isNumber());
n = numeric.asNumber();
}
}
// ...
}
3.3 Number() in QuickJS
QuickJS makes BigInt support configurable via CONFIG_BIGNUM — a reflection of its philosophy of staying lean and customizable.
4. Double Tilde (~~) Operator
ECMAScript (ECMA-262) Bitwise NOT Operator

The ~ operator leverages step 2 of the spec — type conversion of the operand — to convert strings to numbers. Let’s see where this happens in the engine.
4.1 BitwiseNot in V8
V8 identifies unary operators in [→ src/parsing/token.h]. During AST construction in the parsing phase, unary operators are handled via ParseUnaryExpression and BuildUnaryExpression:
Expression* Parser::BuildUnaryExpression(Expression* expression,
Token::Value op, int pos) {
DCHECK_NOT_NULL(expression);
const Literal* literal = expression->AsLiteral();
if (literal != nullptr) {
if (op == Token::NOT) {
return factory()->NewBooleanLiteral(literal->ToBooleanIsFalse(), pos);
} else if (literal->IsNumberLiteral()) {
double value = literal->AsNumber();
switch (op) {
case Token::ADD:
return expression;
case Token::SUB:
return factory()->NewNumberLiteral(-value, pos);
case Token::BIT_NOT:
return factory()->NewNumberLiteral(~DoubleToInt32(value), pos);
default:
break;
}
}
}
return factory()->NewUnaryOperation(op, expression, pos);
}
If the literal is a number and the operator is BIT_NOT, the value is converted to Int32 and bitwise-negated.
4.2 BitwiseNot in JavaScriptCore
Similarly, during AST construction, when the ~ (TILDE) token is encountered:
ExpressionNode* ASTBuilder::makeBitwiseNotNode(const JSTokenLocation& location, ExpressionNode* expr)
{
if (expr->isNumber())
return createIntegerLikeNumber(location, ~toInt32(static_cast<NumberNode*>(expr)->value()));
return new (m_parserArena) BitwiseNotNode(location, expr);
}
4.3 BitwiseNot in QuickJS
QuickJS generates the OP_not bytecode for ~, then handles it during interpretation in JS_CallInternal:
CASE(OP_not):
{
JSValue op1;
op1 = sp[-1];
if (JS_VALUE_GET_TAG(op1) == JS_TAG_INT) {
sp[-1] = JS_NewInt32(ctx, ~JS_VALUE_GET_INT(op1));
} else {
if (js_not_slow(ctx, sp))
goto exception;
}
}
BREAK;
If it’s already an integer, it just negates it. Otherwise it calls js_not_slow, which calls JS_ToInt32Free to perform type conversion first.
5. Unary Operator (+)
ECMAScript (ECMA-262) Unary Plus Operator

The unary + is personally my favorite way to convert strings to numbers. The spec is refreshingly simple — it’s explicitly designed for numeric type conversion.
The V8 and JavaScriptCore parsing phases work the same as for ~~, so I’ll skip those. In QuickJS’s interpreter:
CASE(OP_plus):
{
JSValue op1;
uint32_t tag;
op1 = sp[-1];
tag = JS_VALUE_GET_TAG(op1);
if (tag == JS_TAG_INT || JS_TAG_IS_FLOAT64(tag)) {
} else {
if (js_unary_arith_slow(ctx, sp, opcode))
goto exception;
}
BREAK;
}
If it’s already Int or Float64, nothing happens — consistent with the spec. For other types, js_unary_arith_slow handles conversion via JS_ToFloat64Free, then returns the value as-is for OP_plus.
That covers five of the nine conversion methods:
- parseInt()
- parseFloat()
- Number()
- Double tilde (~~) Operator
- Unary Operator (+)
The remaining four won’t be covered here due to length constraints:
- Math.floor()
- Multiply with number
- The Signed Right Shift Operator (>>)
- The Unsigned Right Shift Operator (>>>)
Each method has tradeoffs. Here’s my personal guide for choosing:
If you need an integer result:
- Clean code and maximum performance, with predictable input → use Unary Operator (+)
- Defensive coding for unknown inputs → use parseInt()
- Need BigInt support → prefer Number(); if using Double tilde (~~), watch out for the 31-bit overflow issue
If you need a floating-point result:
- Clean code and maximum performance, with predictable input → use Unary Operator (+)
- Defensive coding for unknown inputs → use parseFloat()
- Need BigInt support → use parseFloat()