Node.jsのRangeError: Maximum call stack size exceededを修正する

intermediate💚 Node.js2026-03-24| Node.js(全バージョン)、Linux / macOS / Windows

Error Message

RangeError: Maximum call stack size exceeded
#nodejs#recursion#stack-overflow#callstack#debug

何が起きたのか

Node.js スクリプトが次のようなエラーでクラッシュしました:

RangeError: Maximum call stack size exceeded
    at Object.<anonymous> (/app/utils.js:12:5)
    at Object.<anonymous> (/app/utils.js:12:5)
    at Object.<anonymous> (/app/utils.js:12:5)
    ...

同じ行が何百回も繰り返されているのに気づくでしょう。これがヒントです — ある関数が自分自身を(あるいは自分を呼び返す別の関数を)止まることなく呼び続けています。

コールスタックを理解する

関数を呼び出すたびに、V8 のコールスタックにフレームが積まれます。V8 はプラットフォームや利用可能なメモリに応じて、スタックをおおよそ 10,000〜15,000 フレームに制限しています。その上限を超えると、メモリが際限なく消費される前に V8 が RangeError: Maximum call stack size exceeded をスローします。

主な原因は次の 3 つです:

  • 基底ケース(base case)が存在しないか壊れている再帰関数
  • 2 つの関数が循環的に呼び合っている(相互再帰)
  • 自分自身を参照するオブジェクトに対して JSON.stringify を呼び出している

デバッグの手順

ステップ 1 — スタックトレースを読む

デフォルトでは Node.js は 10 フレームしか表示しません。繰り返しのパターンが見えるよう上限を増やしましょう:

node --stack-trace-limit=50 index.js

同じ関数が自分を呼んでいる場合は無限再帰です。A → B → A → B と交互に現れる場合は相互再帰です。いずれにせよ、トレースを見れば最初に開くべきファイルと行番号が正確にわかります。

ステップ 2 — 深さカウンターを追加する

ロジックに手を加える前に、怪しい関数にカウンターを仕込みましょう:

function processNode(node, depth = 0) {
  if (depth > 100) {
    console.log('Depth exceeded, node:', JSON.stringify(node, null, 2));
    throw new Error('Recursion depth guard triggered');
  }
  // ... rest of function
  return processNode(node.child, depth + 1);
}

実行してみてください。ログに出力された node の値から、暴走する再帰を引き起こしている正確なデータがわかります — コードを眺め続けるよりもはるかに速く原因を特定できます。

ステップ 3 — 循環参照を確認する

エラーが JSON.stringify の内部で発生していましたか?それは循環参照です。典型的な例:

const a = { name: 'a' };
const b = { name: 'b', ref: a };
a.ref = b; // circular!
JSON.stringify(a); // => RangeError: Maximum call stack size exceeded

シリアライズ前に検出しましょう:

function hasCircular(obj) {
  const seen = new WeakSet();
  function detect(o) {
    if (typeof o !== 'object' || o === null) return false;
    if (seen.has(o)) return true;
    seen.add(o);
    return Object.values(o).some(detect);
  }
  return detect(obj);
}

console.log(hasCircular(a)); // true

解決策

修正 1 — 基底ケースを追加または修正する

これが圧倒的に多い根本原因です。すべての再帰関数には、処理を完全に止める条件が必要です:

// BROKEN — recurses forever
function factorial(n) {
  return n * factorial(n - 1);
}

// FIXED
function factorial(n) {
  if (n <= 1) return 1; // <-- base case
  return n * factorial(n - 1);
}

シンプルですが、基底ケースのロジックが数値カウンターではなくデータの形状に依存している場合は見落としやすいです。

修正 2 — 深い再帰をループに書き換える

ツリーや連結リストは本番環境では際限なく深くなる可能性があります。ループと明示的なスタックを使って書き直せば、V8 の制限に触れることなくどんな深さでも処理できます:

// Recursive (blows up on trees deeper than ~10,000 nodes)
function sumTree(node) {
  if (!node) return 0;
  return node.value + sumTree(node.left) + sumTree(node.right);
}

// Iterative (safe for any depth)
function sumTree(root) {
  const stack = [root];
  let total = 0;
  while (stack.length) {
    const node = stack.pop();
    if (!node) continue;
    total += node.value;
    stack.push(node.left, node.right);
  }
  return total;
}

結果は同じで、スタックのリスクはゼロです。

修正 3 — 末尾再帰にトランポリンを使う

ループへのリファクタリングでアルゴリズムの可読性が損なわれることがあります。トランポリンを使えば再帰スタイルを保ちつつ、スタックの増大を完全に回避できます:

function trampoline(fn) {
  return function(...args) {
    let result = fn(...args);
    while (typeof result === 'function') {
      result = result();
    }
    return result;
  };
}

const factorial = trampoline(function fact(n, acc = 1) {
  if (n <= 1) return acc;
  return () => fact(n - 1, n * acc); // thunk instead of direct call
});

console.log(factorial(50000)); // works fine

ポイントは、再帰的に呼び出す代わりに関数(サンク)を返すことです。トランポリンのランナーがそれを通常のループで展開します。

修正 4 — setImmediate で非同期再帰を分断する

非同期関数はスタックオーバーフローから安全に見えます。しかしそうではありません。Promise がすでに解決済みの場合、各 await は同じスタックフレーム上で同期的に再開します。修正方法:明示的にイベントループに制御を譲ります:

async function processItems(items, index = 0) {
  if (index >= items.length) return;
  await processOne(items[index]);
  // yield to the event loop — clears the stack AND prevents blocking
  await new Promise(resolve => setImmediate(resolve));
  return processItems(items, index + 1);
}

修正 5 — 循環オブジェクトを安全にシリアライズする

2 つの選択肢があります。独自のリプレイサーを作るか、flatted パッケージを使うかです:

// Option A: custom replacer (no dependencies)
function safeStringify(obj) {
  const seen = new WeakSet();
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) return '[Circular]';
      seen.add(value);
    }
    return value;
  });
}

// Option B: flatted (preserves circular structure on parse)
// npm install flatted
import { stringify, parse } from 'flatted';
const serialized = stringify(circularObj);

安全なログ出力だけが目的なら Option A を使いましょう。データのラウンドトリップ(シリアライズ → 構造を保ったままデシリアライズ)が必要な場合は Option B を使いましょう。

確認

修正を適用したら、実際に問題が解決されたことを確認しましょう:

  • スクリプトを実行する — 今度は RangeError が発生しないことを確認。
  • エッジケースでテストする:空の入力、要素が 1 つの入力、深さ 100,000 ノードのツリー。
  • ループへの書き換えを行った場合は、まず小さな入力で再帰版と出力を照合して確認する。
  • 深さガードを削除するか — あるいは適切な上限と本番環境で発火した際の明確なエラーメッセージを付けて残す。
# Quick smoke test
node -e "const { sumTree } = require('./tree'); console.log(sumTree(buildDeepTree(100000)))"

得られた教訓

  • スタックトレースでフレームが繰り返されているなら、意味は一つ:基底ケースが機能していない再帰です。コードに触れる前にトレースを読みましょう。
  • ユーザーが提供するデータが浅いと信じてはいけません。ツリー、ネストされた設定、API からの JSON — いずれも際限なく深くなる可能性があります。ループを使いましょう。
  • JSON.stringify がクラッシュするのは、ほぼ必ず循環参照が原因です。WeakSet を使ったリプレイサーは 5 行で書けます。一度書いたら至る所で再利用しましょう。
  • 非同期再帰はデフォルトでスタックセーフではありません。数千件のアイテムを再帰的にループ処理する場合は、setImmediate で制御を譲るか、await 付きの通常の for ループに書き直しましょう。

Related Error Notes