Math.max(...array) throws a RangeError once the array gets big
published
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 runs | Largest length that worked |
|---|---|
| Top level of a script, default stack | about 124,700 to 124,900 (varies between runs) |
| Inside a recursion 2,000 frames deep | 98,875 |
Top level, node --stack-size=400 | 50,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
- The thresholds in the table come from one machine and one Node version. Stack frame sizes differ by platform, flags and what the engine has inlined, so read them as an order of magnitude, not a constant to code against.
- Other engines have their own limits. Safari’s JavaScriptCore stops at 65,536 arguments according to MDN, which is lower than what Node accepted here.
- Spreading into an array literal or a constructor argument that takes an iterable is a different operation.
[...a, ...b]andnew Set(values)do not create an argument list, and both handled a million elements without complaint in the same Node build. Math.max()with no arguments is-InfinityandMath.min()isInfinity, soMath.max(...[])does not throw on an empty array either. If an empty input should be an error, check the length first; the loop above has the same behaviour.