~/blog

Math.max(...array) throws a RangeError once the array gets big

published

#javascript#node#arrays

TL;DR

Math.max(...values), target.push(...items) and String.fromCharCode(...bytes) pass every element of the array as its own argument. Arguments need stack space, so once the array is big enough the call throws RangeError: Maximum call stack size exceeded, even though nothing is recursing. On Node 26.2.0 that happened at about 125,000 elements at the top level of a script, and sooner when the call already sits deep in the stack. Use a loop for min and max, Uint8Array.prototype.toBase64() or a chunked conversion for base64, and a loop or concat for appending.

The problem

The code passes every test, because test fixtures are small, and then fails on real data:

const readings = Array.from({ length: 300_000 }, (_, i) => i % 1000);
const peak = Math.max(...readings);
// RangeError: Maximum call stack size exceeded

The base64 version of the same bug shows up in upload and export code. Converting bytes to a binary string with a spread is a common one-liner, and it breaks on files of a few hundred kilobytes:

const bytes = new Uint8Array(300_000); // about 293 KB
const encoded = btoa(String.fromCharCode(...bytes));
// RangeError: Maximum call stack size exceeded

The error message sends you looking for runaway recursion. There is none. The stack overflowed on a single call that was handed 300,000 arguments.

Why it happens

Spread syntax in a call expands the array into an argument list: Math.max(...[3, 1, 2]) is exactly Math.max(3, 1, 2). Function.prototype.apply does the same thing. MDN’s page on apply puts the risk plainly: with an arbitrarily long argument list you can exceed the engine’s argument length limit, and the consequences of calling a function with more than tens of thousands of arguments are unspecified and vary across engines. It also notes that JavaScriptCore, the engine in Safari, has a hard-coded limit of 65,536 arguments.

In V8 the arguments go on the stack, so the limit is not a fixed count. It is whatever stack is left at the moment of the call. Here is the largest array Math.max(...arr) accepted on Node 26.2.0 (Windows, default flags), found by bisecting:

Where the call runsLargest length that worked
Top level of a script, default stackabout 124,700 to 124,900 (varies between runs)
Inside a recursion 2,000 frames deep98,875
Top level, node --stack-size=40050,098

String.fromCharCode(...bytes), target.push(...items) and Math.max.apply(null, arr) failed at the same point, give or take a few elements. That middle row is the dangerous one: a line that works in a small script can fail when the same function is called from deep inside a framework, a router or a recursive parser, on the same input.

MDN adds one more wrinkle. Most engines throw, but nothing in the specification stops an engine from silently passing only some of the arguments instead. In that case Math.max would return a wrong answer rather than an error.

You can reproduce the numbers on your own machine with this script:

// spread-limit.mjs
function works(n) {
  try {
    Math.max(...new Array(n).fill(1));
    return true;
  } catch {
    return false;
  }
}

let lo = 1;
let hi = 1;
while (works(hi)) { lo = hi; hi *= 2; }
while (hi - lo > 1) {
  const mid = Math.floor((lo + hi) / 2);
  if (works(mid)) lo = mid; else hi = mid;
}
console.log(process.version, 'largest length that worked:', lo);
node spread-limit.mjs
# v26.2.0 largest length that worked: 124781

What to do

Spreading into a call is fine when the array has a known, small size: a few coordinates, a fixed list of options. When the length comes from user data, a file, an API response or a database, do not spread it into a function call.

Min and max: a loop

function maxOf(values) {
  let max = -Infinity;
  for (const v of values) {
    if (v > max) max = v;
  }
  return max;
}

maxOf(Array.from({ length: 1_000_000 }, (_, i) => (i * 7919) % 1_000_003)); // 1000002

values.reduce((m, v) => (v > m ? v : m), -Infinity) gives the same result in one line. MDN’s Math.max page recommends the reduce form for exactly this reason. Both work on typed arrays too.

One behaviour changes when you switch, so decide which one you want. Math.max returns NaN if any argument is NaN, while the > comparison skips NaN:

Math.max(1, NaN, 3);  // NaN
maxOf([1, NaN, 3]);   // 3

Base64: toBase64, or convert in chunks

Uint8Array.prototype.toBase64() encodes the bytes directly, with no intermediate string and no argument list. MDN marks it Baseline 2025, newly available across current browsers since September 2025, and it exists in Node 26. Its counterpart for decoding is Uint8Array.fromBase64().

const encoded = bytes.toBase64();
const decoded = Uint8Array.fromBase64(encoded);

If you still support older browsers, keep the String.fromCharCode approach but feed it bounded slices:

function bytesToBase64(bytes) {
  const CHUNK = 0x8000; // 32,768 arguments per call
  let binary = '';
  for (let i = 0; i < bytes.length; i += CHUNK) {
    binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
  }
  return btoa(binary);
}

On 300,000 bytes this produced the same 400,000-character string as bytes.toBase64() and as Node’s Buffer.from(bytes).toString('base64'). A chunk of 32,768 stays well under the JavaScriptCore limit as well as V8’s. In Node-only code, Buffer is the simplest choice. Keep in mind that base64 is an encoding, not protection; that is covered in base64 is not encryption.

Appending: a loop, concat, or chunked push

// mutate in place
for (const item of items) target.push(item);

// or build a new array
const merged = target.concat(items);

// or keep push(...), but bound each call
for (let i = 0; i < items.length; i += 10_000) {
  target.push(...items.slice(i, i + 10_000));
}

Caveats

References