Number to String: How V8 Prints a Double

Turning a double into a string is a shortest-representation problem, and V8 solves it with Dragonbox, a hand-rolled digit formatter, and a per-isolate cache of the last number-to-string conversions.

TL;DR: String(x) for a double is a shortest round-trip problem — find the fewest decimal digits that read back as the same double. V8 solves it with jkj’s Dragonbox (src/numbers/conversions.cc:1161), formats the resulting digits with a hand-written two-digits-at-a-time routine, and then memoises the whole thing in a per-isolate SmiStringCache / DoubleStringCache. The threshold where 1e21 becomes 1e+21 is not a special case: it falls out of the ECMA-262 formatting rules, which V8 implements literally.

Number.prototype.toString looks like the simplest thing in the language: print the digits. It is not. The specification demands the shortest decimal representation that round-trips, and that is a genuinely hard search problem — you have to know exactly where the boundaries of the floating-point interval around your number are, and that requires exact arithmetic on 53-bit significands scaled by arbitrary powers of ten.

V8’s answer has three layers: an algorithm, a formatter, and a cache.

What “shortest” means

The rule, from ECMA-262 section 6.1.6.1.20 (Number::toString): given a positive finite number x, find the integer k and the decimal digits s such that reading s × 10^(n−k) back gives x exactly, and k is as small as possible. If there are two such s of the same length, pick the one closer to x; if they are equally close, pick the larger.

That last clause is the reason this is hard. “Closest to x” means comparing against the exact midpoints between adjacent doubles, and those midpoints are not representable in binary floating point at all.

The three modes V8’s dtoa layer supports make the distinction explicit:

cpp
// src/base/numbers/dtoa.h:15
enum DtoaMode {
  // Return the shortest correct representation.
  // For example the output of 0.299999999999999988897 is (the less accurate but
  // correct) 0.3.
  DTOA_SHORTEST,
  // Return a fixed number of digits after the decimal point.
  // For instance fixed(0.1, 4) becomes 0.1000
  // If the input number is big, the output will be big.
  DTOA_FIXED,
  // Return a fixed number of digits, no matter what the exponent is.
  DTOA_PRECISION
};

DTOA_SHORTEST is Number.prototype.toString. DTOA_FIXED is toFixed. DTOA_PRECISION is toPrecision and toExponential. The buffer bound follows from the first mode:

cpp
// src/base/numbers/dtoa.h:28
// The maximal length of digits a double can have in base 10 as returned by
// 'DoubleToAscii'. This does neither include sign, decimal point nor exponent.
// For example DoubleToAscii(-3.5844466002796428e+298, ..., buffer, ...) will
// fill buffer with the string "35844466002796428", while sign and decimal point
// position will be provided through additional output arguments.
// kBase10MaximalLength refers to the maximal length of this string. Note that
// DoubleToAscii null-terminates its input. So the given buffer should be at
// least kBase10MaximalLength + 1 characters long.
const int kBase10MaximalLength = 17;

Seventeen. A double has 53 bits of significand, and 2^53 ≈ 9.007 × 10^15, so any double needs at most 17 decimal digits to round-trip. Everything downstream is sized around that number.

The primary path: Dragonbox

Here is the interesting bit, and it is a change most write-ups of V8 still get wrong. The shortest-representation path in this checkout is not Grisu3 and not Dragon4 — it is Dragonbox, jkj’s table-free implementation of the Ryu-style shortest-representation algorithm:

cpp
// src/numbers/conversions.cc:1160
      SimpleStringBuilder builder(buffer.begin(), buffer.size());
      auto d = jkj::dragonbox::to_decimal(v);

      if (d.is_negative) builder.AddCharacter('-');

      // Only in debug-builds the buffer is null-terminated.
      constexpr int kDecimalRepLength =
          base::kBase10MaximalLength + (DEBUG_BOOL ? 1 : 0);
      char decimal_rep[kDecimalRepLength];
      int length = SignificandToChars(d.significand, decimal_rep);

to_decimal returns a significand (a uint64_t holding the decimal digits) and an exponent (a power of ten). The number is

text
significand × 10^exponent

and that is all the algorithm gives you. The formatting decisions — where the decimal point goes, whether to use exponential notation — are made afterwards, by the ECMAScript rules, and have nothing to do with the representation algorithm. That separation is what makes the code easy to read.

(The third_party/dragonbox directory in this checkout is a stub: the actual sources are fetched by DEPS. The interface V8 uses is the one above.)

The digit-formatting helper is V8’s own, and the comment says where it came from:

cpp
// src/numbers/conversions.cc:42
// SignificandToChars and its helpers are heavily inspired by
// dragonbox::to_chars.
// See //third_party/dragonbox/src/source/dragonbox_to_chars.cc

The trick in SignificandToChars is to split the number into blocks and convert each block with a multiplication trick rather than with division:

cpp
// src/numbers/conversions.cc:204
uint8_t SignificandToChars(uint64_t n, char* buffer) {
  DCHECK_LT(n, 99999999999999999);  // Only supports up to 17 digits

  if (n >= 100'000'000) {
    // If we have at least 9 digits, split into 2 blocks. The second one always
    // has exactly 8 digits.
    uint32_t first_block = static_cast<uint32_t>(n / 100'000'000);
    uint32_t second_block = static_cast<uint32_t>(n % 100'000'000);

    uint8_t first_block_digits = ConvertUpTo9Digits(first_block, buffer);
    Convert8Digits(second_block, buffer + first_block_digits);
    return first_block_digits + 8;
  } else {
    return ConvertUpTo9Digits(static_cast<uint32_t>(n), buffer);
  }
}

And Convert8Digits converts eight digits at once with a single multiply-shift:

cpp
// src/numbers/conversions.cc:106
static void Convert8Digits(uint32_t n, char* buffer) {
  static constexpr uint32_t kUint32Mask = kMaxUInt32;
  // 281474978 = ceil(2^48 / 1'000'000) + 1
  uint64_t prod = n * 281474978LL;
  prod >>= 16;
  prod += 1;
  Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer);
  prod = (prod & kUint32Mask) * 100;
  Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer + 2);
  prod = (prod & kUint32Mask) * 100;
  Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer + 4);
  prod = (prod & kUint32Mask) * 100;
  Convert2Digits(static_cast<uint8_t>(prod >> 32), buffer + 6);
}

The constant 281474978 is ceil(2^48 / 10^6) + 1. Multiplying by it and shifting right by 16 gives you the first two digits, and the +1 is the rounding correction. Then it peels off two digits at a time with a multiply-by-100 and a mask. A 200-entry lookup table of digit pairs (kRadix100Table) does the final conversion to characters.

This is the standard “itoa by multiplication” technique, and it is noticeably faster than the obvious n % 10 loop because the divisions disappear and the compiler can keep the whole thing in registers.

The formatting rules, and where 1e21 comes from

Once you have the digits and the exponent, the rest is a direct transcription of the specification. V8 implements it as four cases, annotated with the spec step numbers:

cpp
// src/numbers/conversions.cc:1175
      int decimal_point = length + d.exponent;

      if (length <= decimal_point && decimal_point <= 21) {
        // ECMA-262 section 9.8.1 step 6.
        builder.AddString(decimal_rep, length);
        builder.AddPadding('0', decimal_point - length);

      } else if (0 < decimal_point && decimal_point <= 21) {
        // ECMA-262 section 9.8.1 step 7.
        builder.AddSubstring(decimal_rep, decimal_point);
        builder.AddCharacter('.');
        builder.AddString(decimal_rep + decimal_point, length - decimal_point);

      } else if (decimal_point <= 0 && decimal_point > -6) {
        // ECMA-262 section 9.8.1 step 8.
        builder.AddStringLiteral("0.");
        builder.AddPadding('0', -decimal_point);
        builder.AddString(decimal_rep, length);

      } else {
        // ECMA-262 section 9.8.1 step 9 and 10 combined.
        builder.AddCharacter(decimal_rep[0]);
        if (length != 1) {
          builder.AddCharacter('.');
          builder.AddString(decimal_rep + 1, length - 1);
        }
        builder.AddCharacter('e');
        builder.AddCharacter((decimal_point >= 0) ? '+' : '-');
        int exponent = decimal_point - 1;
        if (exponent < 0) exponent = -exponent;
        builder.AddExponent(exponent);
      }

decimal_point is the number of digits before the decimal point. The magic number 21 is right there in the comparison, and it is the answer to “where does exponential notation kick in”:

text
 1e21  →  "1e+21"      (decimal_point == 22, so the last branch)
 1e20  →  "100000000000000000000"
 1e-6  →  "0.000001"   (decimal_point == -5, third branch)
 1e-7  →  "1e-7"       (decimal_point == -6, last branch)

The > -6 in the third branch is the other side of the same rule: below 1e-6, JavaScript switches to exponential notation. The asymmetry between the two thresholds (21 above, 6 below) is the spec’s, and it is one of the odd corners of the language that everyone learns the hard way.

decimal_point is computed as length + d.exponent, and length is the number of significant digits. So the four cases are really about where the decimal point falls relative to the digit string, not about the magnitude of the number.

Two special cases are handled before the algorithm even runs: NaN, Infinity and -Infinity are constants, and an integral double that fits in an int32 takes a completely different path:

cpp
// src/numbers/conversions.cc:1153
    default: {
      if (IsInt32Double(v)) {
        // This will trigger if v is -0 and -0.0 is stringified to "0".
        // (see ES section 7.1.12.1
        // https://tc39.es/ecma262/#sec-tostring-applied-to-the-number-type)
        return IntToStringView(FastD2I(v), buffer);
      }

IsInt32Double is a range check plus an integrality test, and it is worth it: String(42) should not go through a shortest-representation search at all. It is also how -0 becomes "0".

The number-to-string cache

The expensive part of String(x) is not the formatting — it is that programs convert the same numbers over and over. arr.map(String), id columns, log formatting: the same handful of doubles, thousands of times.

V8 keeps two per-isolate caches for this. The Smi cache is a FixedArray of key/value pairs:

cpp
// src/objects/number-string-cache.h:19
// Used for mapping non-zero Smi to Strings.
V8_OBJECT class SmiStringCache : public FixedArray {
  V8_IT_NO_AUTO_CHECKER;

 public:
  using Super = FixedArray;

  // Empty entries are initialized with this sentinel (both key and value).
  static constexpr Tagged<Smi> kEmptySentinel = Smi::zero();

  static constexpr int kEntryKeyIndex = 0;
  static constexpr int kEntryValueIndex = 1;
  static constexpr uint32_t kEntrySize = 2;

  static constexpr uint32_t kInitialSize = 128;

and the double cache is a separate object with an unaligned double key:

cpp
// src/objects/number-string-cache.h:76
// Used for mapping raw doubles to Strings.
V8_OBJECT class DoubleStringCache : public HeapObject {
  V8_IT_NO_AUTO_DISPATCH;

 public:
  V8_OBJECT struct Entry {
    UnalignedDoubleMember key_;
    TaggedMember<UnionOf<Smi, String>> value_;
  } V8_OBJECT_END;

The double cache is keyed on the bit pattern of the double, and the hash is the xor of its two 32-bit halves:

cpp
// src/objects/number-string-cache-inl.h:115
// static
InternalIndex DoubleStringCache::GetEntryFor(uint64_t number_bits,
                                              uint32_t capacity) {
  uint32_t hash = static_cast<uint32_t>(number_bits) ^
                  static_cast<uint32_t>(number_bits >> 32);
  uint32_t mask = capacity - 1;
  return InternalIndex{hash & mask};
}

Note the consequence: the cache is not numerically aware. +0.0 and -0.0 have different bit patterns and therefore different cache entries, even though they stringify identically. That is fine — the cache is a memo, not a canonicaliser — and it is why the double cache stores the raw bits rather than a normalised value.

Both caches start small and grow when they first collide. This is a nice piece of laziness: most programs never convert more than a hundred distinct numbers, so the cache starts at 128 entries and only allocates the full table when an entry is actually overwritten:

cpp
// src/objects/number-string-cache-inl.h:77
  // Switch to full-size mode if the entry is already occupied.
  if (cache->capacity() == kInitialSize &&
      cache->get(entry_index + kEntryKeyIndex) != kEmptySentinel &&
      !isolate->MemorySaverModeEnabled()) {
    // Allocate full-size cache, recompute the entry and proceed with adding
    // the entry.
    uint32_t full_size = std::bit_ceil(v8_flags.smi_string_cache_size.value());
    CHECK_LT(kInitialSize, full_size);
    CHECK_LE(full_size, kMaxCapacity);
    // TODO(ishell): consider copying entries from previous table.
    cache = SmiStringCache::New(isolate, full_size);
    isolate->heap()->SetSmiStringCache(*cache);

The full sizes are flags:

cpp
// src/flags/flag-definitions.h:894
DEFINE_UINT(smi_string_cache_size, 8192, "Full size of SmiStringCache")
DEFINE_UINT(double_string_cache_size, 4096, "Full size of DoubleStringCache")

8192 Smi entries and 4096 double entries, in a direct-mapped table (no probe sequence: one hash, one slot, and a collision evicts). And note the TODO(ishell): when the cache grows, the old entries are thrown away. The comment says so, and it is a real (if minor) regression for a program that fills 128 entries and then converts the same 128 again.

The C++ entry point is Factory::DoubleToString, and it shows the whole policy in one function — try the cache, compute, store:

cpp
// src/heap/factory-base.cc:1011
  InternalIndex entry = InternalIndex::NotFound();
  uint64_t value_bits = 0;
  if constexpr (kCanUseCache) {
    if (mode != NumberCacheMode::kIgnore) {
      value_bits = base::bit_cast<uint64_t>(value);
      entry = DoubleStringCache::GetEntryFor(isolate(), value_bits);
    }
    if (mode == NumberCacheMode::kBoth) {
      Handle<Object> cached =
          DoubleStringCache::Get(isolate(), entry, value_bits);
      if (!IsUndefined(*cached)) return Cast<String>(cached);
    }
  }

The NumberCacheMode has three values — kIgnore (don’t touch the cache), kBoth (read and write) and presumably kUpdate (write only) — and the comment above explains why the cache is main-thread only:

cpp
// src/heap/factory-base.cc:1007
  // LocalFactory does not have access to number string cache, only
  // main thread Factory does (since it's a mutable root).
  constexpr bool kCanUseCache = std::is_same_v<Impl, Factory>;

The preallocated strings

Before the cache, there is an even cheaper layer: a table of 100 pre-built strings for the integers 0 to 99.

cpp
// src/common/globals.h:3383
constexpr uint32_t kPreallocatedNumberStringTableSize = 100;

The table is built at heap setup, and the digits 0 to 9 are taken straight from the single-character string roots:

cpp
// src/heap/setup-heap-internal.cc:1333
    static_assert(kPreallocatedNumberStringTableSize >= 10);
    for (int i = 0; i < 10; ++i) {
      RootIndex root_index = RootsTable::SingleCharacterStringIndex('0' + i);
      Tagged<String> str =
          Cast<String>(factory->read_only_roots().object_at(root_index));
      DCHECK(ReadOnlyHeap::Contains(str));
      preallocated_number_string_table->set(i, str);
    }

    // This code duplicates FactoryBase::SmiToNumber.
    for (uint32_t i = 10; i < kPreallocatedNumberStringTableSize; ++i) {
      std::string_view string = IntToStringView(i, buffer);
      Handle<String> str = factory->InternalizeString(
          base::OneByteVector(string.data(), string.length()));

The “this code duplicates FactoryBase::SmiToNumber” comment is the kind of thing that shows up in every code base with a bootstrapping problem: the table has to exist before the factory can make strings, so one of the two has to be written twice, and a LINT check keeps them in sync.

The lookup in generated code is a bounds check and a load:

cpp
// src/codegen/code-stub-assembler.cc:10061
TNode<String> CodeStubAssembler::TryMatchPreallocatedNumberString(
    TNode<Int32T> value, Label* bailout) {
  GotoIf(Uint32GreaterThanOrEqual(
             value, Uint32Constant(kPreallocatedNumberStringTableSize)),
         bailout);

  TNode<FixedArray> table =
      CAST(LoadRoot(RootIndex::kPreallocatedNumberStringTable));

  TNode<Object> result =
      UnsafeLoadFixedArrayElement(table, ChangeInt32ToIntPtr(value));

  return CAST(result);
}

The fast path in generated code

Number.prototype.toString is a Torque builtin, and for radix 10 it calls straight into the CSA NumberToString macro:

cpp
// src/builtins/number.tq:197
  // 6. If radixNumber = 10, return ! ToString(x).
  if (radixNumber == 10) {
    return NumberToString(x);
  }

The CSA macro has two tiers: a Smi path and a heap-number path, and the heap-number path first tries to normalise the double into a Smi:

cpp
// src/codegen/code-stub-assembler.cc:10128
TNode<String> CodeStubAssembler::NumberToString(TNode<Number> input,
                                                Label* bailout) {
  ...
  BIND(&if_heap_number);
  TNode<HeapNumber> heap_number_input = CAST(input);
  TNode<Float64T> float64_input = LoadHeapNumberValue(heap_number_input);
  {
    Comment("NumberToString - HeapNumber");
    // Try normalizing the HeapNumber.
    smi_input = TryFloat64ToSmi(float64_input, &not_smi);
    Goto(&if_smi);
  }

3.0 is a HeapNumber on the heap, but it is integral and fits in a Smi, so it is converted to a Smi and printed through the Smi path. That is a very common case — any number that came out of integer arithmetic and was boxed — and it turns a shortest-representation search into an integer-to-string conversion.

The Smi path in generated code probes the cache directly, in assembly:

cpp
// src/codegen/code-stub-assembler.cc:10178
  // Load the number string cache.
  TNode<SmiStringCache> cache = CAST(LoadRoot(RootIndex::kSmiStringCache));

  // Make the hash mask from the length of the number string cache. It
  // contains two elements (number and string) for each cache entry.
  TNode<Uint32T> cache_length =
      LoadFixedArrayBaseLengthAsUint32(ReinterpretCast<FixedArray>(cache));
  TNode<Int32T> one = Int32Constant(1);
  TNode<Word32T> mask = Int32Sub(Word32Shr(cache_length, one), one);

  // Load the smi key, make sure it matches the smi we're looking for.
  TNode<Word32T> hash = Word32And(int32_value, mask);
  TNode<IntPtrT> entry_index = Signed(ChangeUint32ToWord(Int32Add(hash, hash)));

and the double path is a bit more elaborate, because the key is a 64-bit value stored in two halves:

cpp
// src/codegen/code-stub-assembler.cc:10264
  // Make a hash from the two 32-bit values of the double.
  TNode<Int32T> low = Signed(Float64ExtractLowWord32(input));
  TNode<Int32T> high = Signed(Float64ExtractHighWord32(input));
  TNode<Word32T> entry = Word32And(Word32Xor(low, high), mask);
  TNode<IntPtrT> entry_offset = DoubleStringCacheEntryToOffset(entry);

When the cache misses, the generated code bails out to the runtime rather than trying to compute the string itself — the double-to-string computation is far too big to want in the middle of a builtin:

cpp
// src/codegen/code-stub-assembler.cc:10299
  BIND(&runtime);
  {
    // No cache entry, go to the runtime.
    result = CAST(
        CallRuntime(Runtime::kNumberToStringSlow, NoContextConstant(), input));
    Goto(&done);
  }

So the division of labour is: generated code handles the cache hit, the runtime handles the computation. The cache hit path is a handful of instructions; the miss path is a runtime call.

The other dtoa: Grisu3 and Dragon4

The Grisu3/Dragon4 machinery in src/base/numbers/ is still in the tree, and it is still used — just not for Number.prototype.toString’s shortest mode any more. base::DoubleToAscii is the older entry point and it does have a fast path with a fallback:

cpp
// src/base/numbers/dtoa.cc:58
  bool fast_worked;
  switch (mode) {
    case DTOA_SHORTEST:
      fast_worked = FastDtoa(v, FAST_DTOA_SHORTEST, 0, buffer, length, point);
      break;
    case DTOA_FIXED:
      fast_worked = FastFixedDtoa(v, requested_digits, buffer, length, point);
      break;
    case DTOA_PRECISION:
      fast_worked = FastDtoa(v, FAST_DTOA_PRECISION, requested_digits, buffer,
                             length, point);
      break;
    default:
      UNREACHABLE();
  }
  if (fast_worked) return;

  // If the fast dtoa didn't succeed use the slower bignum version.
  BignumDtoaMode bignum_mode = DtoaToBignumDtoaMode(mode);
  BignumDtoa(v, bignum_mode, requested_digits, buffer, length, point);
  buffer[*length] = '\0';

FastDtoa is Grisu3, and the interesting part is that it is not always correct — it computes a candidate using a 64-bit approximation of a power of ten, and then has to prove that the candidate is unambiguous. The function that does the proving is called RoundWeed, and its comment includes an ASCII diagram of the interval arithmetic:

cpp
// src/base/numbers/fast-dtoa.cc:43
static bool RoundWeed(char* last_digit, uint64_t distance_too_high_w,
                      uint64_t unsafe_interval, uint64_t rest,
                      uint64_t ten_kappa, uint64_t unit) {

The logic: the buffer currently holds a decimal in the unsafe interval between the two neighbouring doubles. If it is in the safe interval, it definitely round-trips. If it is in the unsafe interval but not the safe one, Grisu3 cannot prove anything and returns false, and the caller falls back to the exact algorithm.

The exact algorithm is BignumDtoa — Dragon4 with arbitrary-precision integers, the “slow dtoa” of the brief:

cpp
// src/base/numbers/bignum-dtoa.cc:96
  bool need_boundary_deltas = (mode == BIGNUM_DTOA_SHORTEST);
  InitialScaledStartValues(v, estimated_power, need_boundary_deltas, &numerator,
                           &denominator, &delta_minus, &delta_plus);
  // We now have v = (numerator / denominator) * 10^estimated_power.
  FixupMultiply10(estimated_power, is_even, decimal_point, &numerator,
                  &denominator, &delta_minus, &delta_plus);

It computes the exact boundaries m- and m+ of the interval of doubles that round to v, as rationals with bignum numerators and denominators, and then generates digits until the candidate is strictly between them. That is the “shortest and closest” guarantee, obtained by brute force.

toFixed and toPrecision: a different algorithm

toFixed and toPrecision do not want the shortest representation. They want a specific number of digits, correctly rounded, and the rounding rule is different: halfway cases round away from zero, not to even. (1.005).toFixed(2) is "1.00" in JavaScript, and (0.15).toFixed(2) is "0.15", and these are exact-arithmetic questions.

The DTOA_FIXED mode has its own fast path and its own exact fallback:

cpp
// src/base/numbers/fixed-dtoa.h:15
// Produces digits necessary to print a given number with
// 'fractional_count' digits after the decimal point.
// ...
// Halfway cases are rounded towards +/-Infinity (away from 0). The call
// FastFixedDtoa(0.15, 2, ...) thus returns buffer = "2", decimal_point = 0.

Note the difference in the halfway rule between the two modes. The shortest mode rounds halfway cases up (to the larger magnitude), per the spec:

cpp
// src/base/numbers/dtoa.h:42
//  - SHORTEST: produce the least amount of digits for which the internal
//   identity requirement is still satisfied. If the digits are printed
//   (together with the correct exponent) then reading this number will give
//   'v' again. The buffer will choose the representation that is closest to
//   'v'. If there are two at the same distance, than the one farther away
//   from 0 is chosen (halfway cases - ending with 5 - are rounded up).

The exact fallback uses the same rule, with the tie broken by the significand rather than by the digit parity:

cpp
// src/base/numbers/bignum-dtoa.h:39
//   'v'. If there are two at the same distance, than the number is round up.
cpp
// src/base/numbers/bignum-dtoa.cc:67
  bool is_even = (significand & 1) == 0;

That single bit of the double’s significand is what decides which of the two neighbouring decimals is generated when the candidate sits exactly on a boundary: is_even tells GenerateShortestDigits whether the lower boundary delta_minus is inclusive, so the same double always prints as the same string. It is the reason String(0.1) is stable across platforms — a tie is not broken by the rounding mode of the host FPU but by the bits of the value itself.

toFixed also has a magnitude cutoff, and it is the same 1e21:

cpp
// src/numbers/conversions.cc:1229
std::string_view DoubleToFixedStringView(double value, int f,
                                         base::Vector<char> buffer) {
  const double kFirstNonFixed = 1e21;

Above that, toFixed falls back to the plain toString formatting, because there is no fixed-point representation of 1e21 that fits in the buffer anyway (the integer part alone has 22 digits, and the spec’s limit is 21 digits before the point).

And the digit limit is 100:

cpp
// src/numbers/conversions.h:69
// The limit for the fractionDigits/precision for toFixed, toPrecision
// and toExponential.
constexpr int kMaxFractionDigits = 100;
constexpr int kDoubleToFixedMaxDigitsBeforePoint = 21;

which is why (1).toFixed(101) throws a RangeError.

The other direction

For completeness, the parsing direction is not V8’s own code any more either. StringToDouble uses fast_float:

cpp
// src/numbers/conversions.cc:892
  auto ret =
      fast_float::from_chars(current_uc, end_uc, value,
                             static_cast<fast_float::chars_format>(
                                 fast_float::chars_format::general |
                                 fast_float::chars_format::no_infnan |
                                 fast_float::chars_format::allow_leading_plus));

fast_float is a std::from_chars implementation built on the Eisel- Lemire algorithm, and it is exact — it returns the correctly rounded double for any input, with no fallback needed. So the round trip String(Number(x)) → parse → double is exact by construction, and Number(String(x)) === x holds for every finite double.

Trade-offs

What it buys. Number-to-string is fast enough that nobody notices it. A double prints in a few tens of nanoseconds, the common case (a Smi, or a repeated value) is a cache hit that costs a load and a compare, and the algorithm is correct to the last bit.

What it costs.

  • The shortest-representation problem is genuinely hard. Dragonbox uses a large amount of precomputed table data (the “cache” of 128-bit multiples of powers of ten) and a non-obvious implementation. It is not code you can read and modify.
  • Two dtoa implementations coexist. src/base/numbers/ has Grisu3, Dragon4 and fixed-dtoa; src/numbers/conversions.cc has Dragonbox. The shortest mode of base::DoubleToAscii is essentially dead code for the toString path but is still reachable from other callers, and the two have different halfway-rounding behaviour.
  • The cache grows by throwing itself away. The first collision at 128 entries discards the entire table and replaces it with an 8192 entry one, and the old contents are not copied over. A program that converts 129 distinct numbers in a loop pays for the conversion of all 129 again after the resize.
  • The cache is direct-mapped. One hash, one slot, no probing. Two hot numbers that hash to the same slot will evict each other forever, and the cache becomes a treadmill.
  • The double cache is not numerically aware. 0 and -0 are different keys, and NaN — of which there are many bit patterns — gets whatever slot its bits hash to.

The bottom line

Converting a double to a string in V8 is three separable problems, and V8 solves each one in the right place.

The representation problem (find the shortest round-tripping digits) is solved by Dragonbox, which returns a significand and an exponent and nothing else. The formatting problem (where does the decimal point go, do we use exponential notation) is solved by a direct transcription of the ECMA-262 rules, and the 21 and -6 in that code are the whole reason String(1e21) is "1e+21". The performance problem (the same numbers keep coming back) is solved by a per-isolate cache that memoises the result, backed by a table of 100 pre-built strings for the small integers.

The mental model to keep: the algorithm produces digits, the spec produces format, and the cache produces speed — and the boundaries between the three are exactly where the code is.

The thing people get wrong

“V8 uses Grisu3 with a Dragon4 fallback” is the standard story, and it is now out of date. In this checkout the shortest-representation path for Number.prototype.toString is jkj’s Dragonbox, and it has no fallback at all — it is exact by construction. The Grisu3 code is still in the tree, but it now serves the fixed and precision modes (and the legacy base::DoubleToAscii entry point), not toString.

The other thing people get wrong is the threshold. It is not “V8 uses exponential notation for large numbers” — it is that the spec says so, with a specific and slightly asymmetric rule: fixed notation for 1e-6 through 1e20, exponential outside that. A language that switches notation at 1e21 and at 1e-7 is not making a random choice; it is following a rule that was written down in 1999 and has been frozen ever since.

V8InternalsNumbersStrings