Fix RangeError: Maximum call stack size exceeded trong Node.js

intermediate💚 Node.js2026-03-24| Node.js (tất cả phiên bản), Linux / macOS / Windows

Error Message

RangeError: Maximum call stack size exceeded
#nodejs#đệ quy#stack-overflow#callstack#debug

Chuyện gì đã xảy ra

Script Node.js của bạn bị crash với thông báo kiểu như sau:

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)
    ...

Chú ý cách cùng một dòng lặp đi lặp lại hàng trăm lần. Đó chính là dấu hiệu — một hàm đang tự gọi chính nó (hoặc gọi một hàm khác rồi hàm đó gọi ngược lại) mà không bao giờ dừng.

Hiểu về call stack

Mỗi lần gọi hàm sẽ đẩy một frame vào call stack của V8. V8 giới hạn stack đó ở khoảng 10.000–15.000 frame, tùy thuộc vào platform và bộ nhớ khả dụng. Vượt quá giới hạn đó, V8 ném ra RangeError: Maximum call stack size exceeded thay vì để bộ nhớ bị tiêu tốn không kiểm soát.

Ba nguyên nhân phổ biến nhất:

  • Hàm đệ quy thiếu hoặc có điều kiện dừng (base case) bị lỗi
  • Hai hàm gọi nhau theo vòng lặp (đệ quy chéo)
  • Gọi JSON.stringify trên một object tham chiếu vòng tròn đến chính nó

Quy trình debug

Bước 1 — Đọc stack trace

Mặc định Node.js chỉ hiển thị 10 frame. Tăng giới hạn đó lên để thấy được pattern lặp lại:

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

Cùng một hàm tự gọi mình? Đệ quy vô hạn. Luân phiên A → B → A → B? Đệ quy chéo. Dù là trường hợp nào, stack trace cho bạn biết chính xác file và dòng nào cần mở trước.

Bước 2 — Thêm bộ đếm độ sâu

Trước khi đụng vào logic, hãy thêm một bộ đếm vào hàm nghi vấn:

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');
  }
  // ... phần còn lại của hàm
  return processNode(node.child, depth + 1);
}

Chạy thử. Giá trị node được log ra sẽ cho thấy chính xác dữ liệu nào kích hoạt đệ quy vô tận — nhanh hơn rất nhiều so với việc nhìn chằm chằm vào code.

Bước 3 — Kiểm tra tham chiếu vòng tròn

Lỗi xuất phát từ bên trong JSON.stringify? Đó là tham chiếu vòng tròn. Ví dụ điển hình:

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

Phát hiện trước khi serialize:

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

Giải pháp

Fix 1 — Thêm hoặc sửa base case

Đây là nguyên nhân phổ biến nhất. Mọi hàm đệ quy đều cần một điều kiện dừng rõ ràng:

// LỖI — đệ quy mãi mãi
function factorial(n) {
  return n * factorial(n - 1);
}

// ĐÃ SỬA
function factorial(n) {
  if (n <= 1) return 1; // <-- base case
  return n * factorial(n - 1);
}

Đơn giản, nhưng dễ bỏ sót khi logic base case phụ thuộc vào cấu trúc dữ liệu thay vì một bộ đếm số nguyên.

Fix 2 — Chuyển đệ quy sâu sang vòng lặp

Cây và danh sách liên kết có thể sâu tùy ý trong môi trường production. Viết lại bằng vòng lặp với một stack tường minh — xử lý được bất kỳ độ sâu nào mà không chạm đến giới hạn của V8:

// Đệ quy (bị crash với cây sâu hơn ~10.000 node)
function sumTree(node) {
  if (!node) return 0;
  return node.value + sumTree(node.left) + sumTree(node.right);
}

// Vòng lặp (an toàn với mọi độ sâu)
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;
}

Kết quả như nhau, không có rủi ro stack.

Fix 3 — Dùng trampolining cho tail recursion

Đôi khi viết lại thành vòng lặp làm giảm khả năng đọc hiểu của thuật toán. Trampolining giữ nguyên phong cách đệ quy nhưng hoàn toàn tránh được việc tăng stack:

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 thay vì gọi trực tiếp
});

console.log(factorial(50000)); // hoạt động bình thường

Mấu chốt là trả về một hàm (thunk) thay vì gọi đệ quy trực tiếp. Trampoline runner sẽ xử lý nó trong một vòng lặp thông thường.

Fix 4 — Ngắt đệ quy async bằng setImmediate

Hàm async trông có vẻ an toàn khỏi stack overflow. Nhưng không phải vậy. Mỗi lần await tiếp tục đồng bộ trên cùng một stack frame nếu promise đã được resolve. Cách sửa: nhường quyền điều khiển cho event loop một cách tường minh:

async function processItems(items, index = 0) {
  if (index >= items.length) return;
  await processOne(items[index]);
  // nhường cho event loop — xóa stack VÀ tránh blocking
  await new Promise(resolve => setImmediate(resolve));
  return processItems(items, index + 1);
}

Fix 5 — Serialize object có tham chiếu vòng tròn một cách an toàn

Có hai lựa chọn. Tự viết replacer, hoặc dùng package flatted:

// Lựa chọn A: custom replacer (không cần dependency)
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;
  });
}

// Lựa chọn B: flatted (giữ nguyên cấu trúc vòng tròn khi parse)
// npm install flatted
import { stringify, parse } from 'flatted';
const serialized = stringify(circularObj);

Dùng Lựa chọn A khi chỉ cần log an toàn. Dùng Lựa chọn B khi cần round-trip dữ liệu (serialize → deserialize với cấu trúc nguyên vẹn).

Kiểm tra kết quả

Áp dụng bản sửa, rồi xác nhận nó thực sự hoạt động:

  • Chạy script — lần này không còn RangeError.
  • Kiểm tra với các trường hợp biên: input rỗng, input một phần tử, cây 100.000 node.
  • Với các bản viết lại dạng vòng lặp, đối chiếu kết quả với phiên bản đệ quy trên input nhỏ trước.
  • Xóa bộ đếm độ sâu — hoặc giữ lại với giới hạn hợp lý và thông báo lỗi rõ ràng nếu nó kích hoạt trong production.
# Kiểm tra nhanh
node -e "const { sumTree } = require('./tree'); console.log(sumTree(buildDeepTree(100000)))"

Bài học rút ra

  • Các frame lặp lại trong stack trace chỉ có một nghĩa: đệ quy không có base case hoạt động đúng. Đọc trace trước khi sửa bất kỳ dòng code nào.
  • Đừng bao giờ tin rằng dữ liệu từ người dùng cung cấp là nông. Cây, config lồng nhau, JSON từ API — bất kỳ thứ nào cũng có thể sâu tùy ý. Hãy dùng vòng lặp.
  • JSON.stringify bị crash hầu như luôn là do tham chiếu vòng tròn. Một replacer dùng WeakSet chỉ tốn 5 dòng. Viết một lần, dùng lại mọi nơi.
  • Đệ quy async không an toàn với stack theo mặc định. Nếu bạn đang lặp qua hàng nghìn phần tử bằng đệ quy, hãy thêm yield bằng setImmediate hoặc viết lại thành vòng lặp for thông thường với await.

Related Error Notes