هذا المقال يتطلب مستوى: متوسط.
لو كتبت setTimeout(fn, 0) وانت فاكر إنها هتشتغل حالًا، الكود بتاعك فيه bug صامت مستنّي وقته. بعد المقال ده هتعرف بالظبط ترتيب تنفيذ أي كود غير متزامن في JavaScript، وهتبطّل تتفاجأ بالنتيجة.
Event Loop في JavaScript: ليه setTimeout(0) مش بيتنفّذ فورًا
المشكلة باختصار
JavaScript بتشتغل على thread واحد. يعني سطر واحد بس بيتنفّذ في اللحظة الواحدة. ومع ذلك التطبيق بيتعامل مع شبكة وتايمرات وأحداث UI في نفس الوقت من غير ما يتجمّد. الحاجة اللي بتنظّم ده اسمها Event Loop.
المشكلة إن معظم المطورين بيتعاملوا معاه كصندوق أسود. فبيتفاجئوا لما Promise.then يتنفّذ قبل setTimeout(fn, 0) رغم إن الاتنين "مؤجَّلين". وده مش تفصيلة أكاديمية: ترتيب التنفيذ ده بيحدّد هل واجهتك هتستجيب للمستخدم في 16 مللي ثانية ولا هتتجمّد ثانية كاملة.
مثال يقرّب الفكرة قبل التعريف العلمي
تخيّل مكتب فيه موظف استقبال واحد. المكتب اللي قدّامه هو المساحة اللي بيشتغل فيها دلوقتي. وعنده درجين للمهام المؤجَّلة:
- درج أصفر: المهام المستعجلة جدًا (الـ Promises).
- درج أزرق: المهام العادية المؤجَّلة (الـ
setTimeoutوأحداث الشبكة).
القاعدة عند الموظف بسيطة وصارمة: كل ما يخلّص المهمة اللي في إيده، يفضّي الدرج الأصفر بالكامل الأول. مش مهمة واحدة منه — كله. وبعد ما الأصفر يفضى تمامًا، ياخد ورقة واحدة بس من الدرج الأزرق، يشتغل فيها، وبعدين يرجع يبصّ على الأصفر تاني.
عشان كده setTimeout(fn, 0) بيتأخّر: الورقة بتاعته راحت الدرج الأزرق، وأي Promise موجود في الأصفر هياخد دوره قبلها — مهما كان عددهم.
التعريف العلمي الدقيق
دلوقتي نشيل المثال ونحط المصطلحات الحقيقية. الـ Event Loop بيشتغل على أربع مكوّنات:
- Call Stack: المكان اللي بيتنفّذ فيه الكود المتزامن دلوقتي. لو فيه أي حاجة هنا، مفيش حاجة تانية بتتحرّك.
- Web APIs / Node APIs: التايمرات وطلبات الشبكة وقراءة الملفات بتتسلّم لبيئة التشغيل (المتصفح أو Node)، مش لـ JavaScript نفسها.
- Macrotask Queue (طابور المهام): بيستقبل callbacks الـ
setTimeoutوsetIntervalوأحداث الـ I/O. - Microtask Queue (طابور المهام الدقيقة): بيستقبل callbacks الـ Promises و
queueMicrotaskوMutationObserver.
خوارزمية الـ Event Loop كالتالي: نفّذ كل الكود المتزامن في الـ Call Stack لحد ما يفضى. بعد كل مهمة (task) واحدة، صرّف الـ Microtask Queue بالكامل قبل أي حاجة تانية. بعدين اسمح للمتصفح يعمل render لو محتاج. وبعد كده بس خُد مهمة واحدة من الـ Macrotask Queue. كرّر.
الافتراض المهم هنا: الكلام ده مبني على نموذج الـ event loop الموصوف في HTML Living Standard للمتصفح. Node.js عنده تفاصيل إضافية (مراحل الـ libuv و process.nextTick) لكن قاعدة "الـ microtasks بتتصرّف قبل الـ macrotask اللي بعدها" ثابتة في الاتنين.
الكود اللي بيثبت الترتيب
شغّل ده في console المتصفح أو في Node.js مباشرة:
console.log('1: متزامن');
setTimeout(() => console.log('2: setTimeout 0'), 0);
Promise.resolve().then(() => console.log('3: promise'));
queueMicrotask(() => console.log('4: microtask'));
console.log('5: متزامن');
// الناتج الفعلي:
// 1: متزامن
// 5: متزامن
// 3: promise
// 4: microtask
// 2: setTimeout 0
السطرين المتزامنين (1 و 5) اشتغلوا الأول لأنهم في الـ Call Stack. بعدين اتصرّف الـ Microtask Queue بالكامل (3 و 4). وفي الآخر خالص جه دور الـ setTimeout من الـ Macrotask Queue. ده مش سلوك عشوائي — ده مضمون في المواصفة.
سيناريو واقعي: ليه الواجهة بتتجمّد
لو عندك dashboard بيستقبل تحديثات لحظية من WebSocket — قول 2,000 رسالة في الثانية — وكل رسالة بتطلق سلسلة Promise.then بتجدول واحدة بعد التانية، انت بنيت microtask starvation من غير ما تقصد.
السبب: الـ Event Loop مش هيوصل لمرحلة الـ render ولا للمهمة التالية طول ما الـ Microtask Queue بتولّد microtasks جديدة. النتيجة المقاسة في حالة زي دي: زمن الاستجابة للنقرة (Interaction to Next Paint) بيقفز من حوالي 16ms لأكتر من 200ms، والمستخدم بيحس إن الصفحة "ميتة" رغم إن الـ CPU شغّال 100%.
الحل إنك تكسر السلسلة عمدًا: بدّل جزء من شغلك لـ setTimeout أو scheduler.yield() عشان تدّي الـ Event Loop فرصة يعمل render ويلتقط مهمة من الـ Macrotask Queue. بتخسر دقّة توقيت بسيطة، بتكسب واجهة بترد.
الـ trade-offs اللي لازم تنتبه لها
- الـ microtasks أسرع، بس خطيرة. بتتنفّذ قبل الـ render فبتحس إنها فورية. الـ trade-off: loop من microtasks ممكن يجمّد الواجهة بالكامل ومفيش setTimeout هيلحق يشتغل.
async/awaitهو microtasks متخفّي. كلawaitبيحوّل باقي الدالة لـ microtask. الافتراض إنك فاهم إن دالة فيها 10awaitبتولّد 10 نقاط تجزئة في الـ Event Loop.setTimeout(fn, 0)مش صفر فعليًا. المتصفحات بتفرض حد أدنى ~4ms للتايمرات المتداخلة. لو محتاج "نفّذ في أقرب macrotask" من غير تأخير،MessageChannelأدق.- الترتيب بين المتصفح و Node مش متطابق 100%.
process.nextTickفي Node بيسبق حتى الـ Promises. لو بتكتب كود بيشتغل في الاتنين، متعتمدش على تفاصيل الترتيب الدقيقة.
متى لا تشغل بالك بالـ Event Loop
لو كودك كله متزامن، أو عندك سلسلة await خطّية بسيطة (واحدة ورا التانية بدون تفريعات)، الترتيب بديهي ومش محتاج تفكّر في الطوابير أصلًا. كمان لو شغّال على سكربت backend مرة واحدة (batch job) ومفيش UI ولا latency حسّاس، الفرق بين microtask و macrotask مش هيأثّر على حاجة تحسّها. التعقيد ده بيستاهل وقتك بس لما يكون عندك واجهة تفاعلية أو خدمة بتستقبل أحداث كتيرة بالتوازي.
الخطوة التالية
افتح console المتصفح دلوقتي والصق سكربت الترتيب اللي فوق وتأكّد إنك توقّعت الناتج صح. بعدين عدّل عليه: حُط await جوّه الـ .then وحاول تتوقّع فين السطر الجديد هيقع قبل ما تشغّل. لو توقّعك طلع غلط، ده بالظبط المكان اللي كان هيبقى فيه bug في الإنتاج.
المصادر
- MDN Web Docs — The event loop و In depth: Microtasks and the JavaScript runtime environment.
- HTML Living Standard (WHATWG) — Event loops: processing model، القسم الخاص بـ microtask checkpoint.
- Node.js Docs — The Node.js Event Loop, Timers, and process.nextTick().
- Jake Archibald — Tasks, microtasks, queues and schedules (مرجع عملي موثّق لترتيب التنفيذ في المتصفحات).
- قياس Interaction to Next Paint (INP) — توثيق web.dev الرسمي عن Core Web Vitals.