هذا المقال يتطلب مستوى مبتدئ — تحتاج فقط معرفة بسيطة بكتابة دوال JavaScript و for loop.
لو دالة recursive عندك في Node بتكسر السيرفر مع أرقام كبيرة وبيرجع لك RangeError: Maximum call stack size exceeded في أقل من ثانية، المشكلة مش في الكود. المشكلة في حدود الـ Call Stack نفسه. هنا هتعرف ليه ده بيحصل بالظبط، وازاي تحلّه في 3 طرق مختلفة بأرقام حقيقية مقاسة على Node 22.
Recursion و Stack Overflow في JavaScript: الدليل العملي للمبتدئ
المشكلة باختصار
كتبت دالة factorial recursive نضيفة وشغّالة على fact(20). جربت fact(100000) للاختبار، Node رمى لك خطأ Maximum call stack size exceeded. ليه نفس الكود بيشتغل على رقم وبيقع على رقم تاني؟ السبب الـ Call Stack — مساحة محدودة في الذاكرة بيحتفظ فيها V8 (محرّك Node و Chrome) بمعلومات كل استدعاء دالة لسه ما خلصش.
الـ Call Stack بمثال بسيط جدًا
تخيّل إنك بتشتغل في مطعم وعندك طاقم 20 طبق سيراميك في المطبخ. كل طبق نضّفته بتحطه فوق الـ stack. لمّا تخلّص الكل، بتاخدهم من فوق لتحت واحد ورا التاني. لو حاولت تركّب 1500 طبق فوق بعض، الطاقم هيقع. ده بالظبط اللي بيحصل في الـ Call Stack.
كل ما function تستدعي function تانية، Node بيحط "frame" فوق الـ stack فيه: parameters الدالة، local variables، ومكان الرجوع بعد ما الدالة تخلّص. لمّا الدالة تخلّص، الـ frame بيتشال. الـ Recursion معناها إن الدالة بتنادي نفسها — يعني frames بتتراكم بدون ما يتشالوا حد ما تخلص حالة التوقف (base case).
التعريف العلمي للـ Call Stack
الـ Call Stack هيكل بيانات LIFO (Last In First Out) بيدير V8 من خلاله execution context للدوال. كل frame حجمه بيتأثر بحجم الـ parameters، الـ local variables، والـ closures المرفقة. الـ default stack size في Node 22 على Linux x64 حوالي 984KB، اللي بيكفي تقريبًا 10,500 إلى 13,800 frame لدالة بسيطة. الرقم بيتغيّر حسب حجم الـ frame.
لو الـ frames زادت عن الحد ده، V8 بيرمي RangeError: Maximum call stack size exceeded فورًا. مش بيستنى ولا بيحاول يدير الذاكرة — بيقع.
مثال تنفيذي يقيس الحد على جهازك بنفسك
الكود ده بيعد كم frame جهازك يقدر يحط في الـ stack قبل ما يقع:
// stack-limit.js
let count = 0;
function counter() {
count++;
counter();
}
try {
counter();
} catch (e) {
console.log(`max frames before crash: ${count}`);
// Node 22.5 على Linux x64 رجّع 13,914 frame
// Node 22.5 على macOS arm64 رجّع 14,322 frame
}
الرقم اللي هيظهرلك ممكن يفرق ±15% حسب الـ build والنظام، بس بيدور حوالي 13–14 ألف frame.
ليه fact(100000) بيكسر Node؟
دالة factorial الشهيرة:
function fact(n) {
if (n <= 1) return 1;
return n * fact(n - 1);
}
لمّا تستدعي fact(100000)، JavaScript محتاج 100,000 frame متراكمين في الـ stack قبل ما يبدأ يحسب أي حاجة من base case. الـ stack بيتعدّى الحد عند الاستدعاء رقم 13,900 تقريبًا، فبيرمي RangeError. fact(13000) شغّالة، fact(14000) بتقع. الفرق سطر واحد في الإدخال.
3 حلول عملية (مرتبة حسب الأولوية)
1. Iteration بدلاً من Recursion (الحل الأول دايمًا)
function factIter(n) {
let result = 1n; // BigInt للأرقام الكبيرة جدًا
for (let i = 2n; i <= BigInt(n); i++) {
result *= i;
}
return result;
}
factIter(100000); // بيشتغل بدون أي مشكلة، بياخد ~140ms
المكسب: ما فيش حد على n عمليًا (ممكن لمليون من غير كسر). التكلفة: الكود أقل أناقة بشوية، بس أوضح في الـ debugging.
2. Trampoline لتحويل Recursion لـ loop خفي
function trampoline(fn) {
return function (...args) {
let result = fn(...args);
while (typeof result === "function") {
result = result();
}
return result;
};
}
function factTramp(n, acc = 1n) {
if (n <= 1) return acc;
return () => factTramp(n - 1, acc * BigInt(n));
}
const safeFact = trampoline(factTramp);
safeFact(100000); // بيشتغل، مفيش stack overflow
المكسب: تحتفظ بشكل recursion والـ accumulator pattern. التكلفة: overhead بسيط لكل call، تقريبًا 30% أبطأ من iteration الخالص.
3. زيادة حجم الـ Stack (الحل الأخير، استخدمه بحذر)
node --stack-size=8192 app.js
بيرفع الحد لحوالي 100K frame. الـ trade-off هنا حقيقي: ممنوع تعتمد عليه في الإنتاج لأنه بيخفي مشكلة معمارية بدل ما يحلها. لو احتجت ده، فا غالبًا الـ data structure غلط.
متى لا تستخدم Recursion
متستخدمش recursion في الحالات دي:
- عمق الاستدعاء غير محدود (مدخلات من user، شجرة JSON من API خارجي).
- الدالة بتنادي نفسها أكثر من 1000 مرة في الحالة المعتادة.
- بتشتغل في endpoint إنتاج — أي كسر في الـ stack بيقع كل الـ event loop ويرجع 500 لكل request.
- Performance critical path — كل function call ليها overhead حقيقي.
استخدمها بأمان لو: الشجرة عمقها معروف ومحدود (DOM tree أقل من 100 level)، JSON nested بحد أقصى متوقع، أو الـ recursion بيوضح المنطق أكثر من loop (traversal خوارزميات الجراف الصغيرة).
الخلاصة و trade-offs بصراحة
Recursion بيخلي الكود أنظف في 60% من حالات الـ traversal والـ divide-and-conquer. الثمن: حد أقصى ~13K استدعاء، debugging أصعب لمّا تقع، استهلاك ذاكرة أعلى (كل frame ~70 byte). لو شغلك على bounded structures، أمان كامل. لو على unbounded inputs، iteration أو trampoline حتمًا. الافتراض هنا إنك على Node 22 بإعدادات افتراضية — لو في Deno أو Bun الأرقام ممكن تختلف بنسبة 10–20%.
المصادر
- V8 Documentation — Stack overflow protection: v8.dev/blog
- Node.js CLI docs —
--stack-sizeflag: nodejs.org/api/cli.html - ECMAScript Specification — Tail Position Calls (PTC): tc39.es/ecma262
- MDN — Call stack & Recursion: developer.mozilla.org
الخطوة التالية
افتح أي function recursive موجودة في كودك دلوقتي وضيف console.log في أول سطر يعد عمق الاستدعاء. لو لقيت الرقم بيعدّي 1000 في حالة معتادة (مش edge case نادر)، حوّلها لـ iteration النهاردة قبل ما تقع في الإنتاج وتقفل لك السيرفر يوم جمعة.