関数の呼び出しが深くなりすぎたときに出ます。 ほとんどは、終わらない再帰です。
function f(n) {
return f(n + 1);
}
console.log(f(0));関数を呼ぶと、戻り先を覚えておく場所(コールスタック)を1段使います。 戻らないまま呼び続けると、その置き場所が尽きます。
何段まで積めるのか
決まっていません。 処理系・環境・関数の中身で変わります。
function countdown(n) {
countdown(n - 1);
}
countdown(3);何段だったかは書きません。 実行するたびに変わり、環境でも変わるからです。 「数千から数万段」程度とだけ思ってください。
深さに頼った設計をしないでください。 手元で通っても、別の環境で落ちます。
原因1: 止まる条件が無い
再帰には基底条件(これ以上呼ばないで返す条件)が要ります。
function countdown(n) {
if (n === 0) return;
countdown(n - 1);
}
countdown(3.5);n が減り続けるだけで、止まる場所がありません。
0 を通り過ぎ、負の数へ落ちていきます。
function walk(list) {
if (list.length === 0) return 0;
return 1 + walk(list);
}
console.log(walk([1, 2, 3]));再帰を書いたら、まず止まる条件を書いてください。
原因2: 条件はあるが、そこへ辿り着かない
書いてあるのに効いていない形です。いちばん見つけにくい。
function isEven(n) {
return isOdd(n - 1);
}
function isOdd(n) {
return isEven(n - 1);
}
console.log(isEven(4));3.5 から1ずつ引いても、0 にはなりません。-0.5 -1.5 … と通り過ぎます。
=== ではなく範囲で書いてください。
const obj = {
get name() {
return this.name;
},
};
console.log(obj.name);同じことは、値が減らないときにも起きます。
const big = new Array(200000).fill(1);
console.log(Math.max(...big));walk(list) に同じ配列を渡しています。減らないので終わりません。
walk(list.slice(1)) が正しい形です。
function sumTo(n) {
return n <= 0 ? 0 : n + sumTo(n - 1);
}
console.log(sumTo(100000));原因3: 互いに呼び合っている
1つの関数を見ても再帰に見えないので、見落としやすい形です。
isEven → isOdd → isEven … と往復し続けます。
どちらにも止まる条件がありません。
同じ形は、getter や toString の中で自分自身を読むときにも起きます。
this.name が同じ getter を呼びます。別の名前に持たせてください。
原因4: 大きな配列を引数に展開した
再帰でなくても出ます。... や apply は、配列の要素を全部引数にします。
引数の数にも上限があります。
要素が20万個なので、引数を20万個渡したことになります。
展開せずに回してください。
上限も環境によって変わります。数万個までなら通ることが多いですが、当てにしないでください。
深い再帰を書き換える
再帰の形が自然でも、深くなるなら繰り返しに直します。
再帰のままだと、この規模で落ちます。
末尾呼び出し最適化には頼らないでください。 仕様にはありますが、V8(Node / Chrome)は実装していません。
木構造をたどるように、深さが知れている場合だけ再帰を使います。 深さが入力に比例するなら、自分で置き場所(スタック)を持ちます。
pop() で取り出すので、後ろから先に見ます。順序を揃えたいなら shift() を使うか、push する順を逆にします。
似ているエラーとの違い
| 文言 | 何が起きたか |
|---|---|
Maximum call stack size exceeded |
呼び出しが深すぎる(この記事) |
JavaScript heap out of memory |
記憶が足りない(配列を無限に伸ばしたなど) |
| (何も出ずに固まる) | 終わらない while / for。スタックは使わないので落ちない |
終わらないループはこのエラーになりません。 固まったまま帰ってきません。 落ちてくれるだけ、再帰のほうが気づきやすいとも言えます。
処理系によって文言が違う
| 処理系 | 文言 |
|---|---|
| Node / Chrome(V8) | Maximum call stack size exceeded |
| Firefox | too much recursion |
| Safari | Maximum call stack size exceeded |
このページの実行結果は Node 22.22.3 のものです。
探しかたの順序
- その関数が自分を呼んでいないか見る
- 呼んでいるなら、止まる条件があるか。
===なら範囲に直す - 呼び出しのたびに値が確実に近づいているか。同じ値を渡していないか
- 自分を呼んでいないなら、互いに呼び合う相手を探す
- 再帰が無いなら、
...やapplyに大きな配列を渡していないか
深さを引数で持って出力すると、どこまで潜っているかが見えます。 値が減っていなければ、そこが原因です。
配列を1つの値にまとめる処理は、再帰で書かなくても reduce() で足りることがあります。
→ Array.prototype.reduce()
入れ子の配列を平らにする処理も、自分で再帰を書かずに flat() で済みます。
→ 入れ子の配列を平らにする