مستوى المقال: مبتدئ — لو ليك أقل من سنة في JavaScript أو لسه بتتعلم async/await، المقال ده مكتوب علشانك. لو محترف Node.js، عندك ٧ دقايق هتلاقي فيها مراجعة سريعة + رقم مفاجئ في النهاية.
افتح الكونسول دلوقتي واكتب الكود ده:
setTimeout(() => console.log("setTimeout"), 0);
Promise.resolve().then(() => console.log("Promise"));
console.log("Sync");
التوقّع المنطقي إن setTimeout هيظهر قبل Promise لأنه مكتوب قبله في الكود. الواقع: Promise بيظهر قبل setTimeout بـ ٤ مللي ثانية تقريبًا، حتى وانت كاتب صفر. ده مش بَج. ده Event Loop.
Event Loop في JavaScript: ليه Promise بيتنفّذ قبل setTimeout(0)؟
المشكلة باختصار
JavaScript بتشتغل على thread واحد (single-threaded). يعني سطر واحد بس بيتنفّذ في اللحظة. لكن في تطبيق ويب عادي، بتيجيلك ميت حاجة في نفس الوقت: نقرة من المستخدم، رد من السيرفر، Timer خلص، Animation Frame جاي. السؤال: مين بيقرّر يبدأ مين الأول؟ الإجابة: Event Loop. وهو بيستخدم ترتيب أولويات مش واضح من نظرة سريعة على الكود.
مثال للمبتدئ: مطار فيه طابورين
تخيّل مطار فيه بوّابتين للنزول من الطيارة. البوابة الأولى للسفر VIP، والثانية للركاب العاديين. القاعدة بسيطة: ما حدش من الركاب العاديين بينزل قبل ما طابور VIP بالكامل يفضى. حتى لو راكب VIP وصل متأخّر والعاديين كانوا واقفين قبله بساعة، VIP بينزل الأول.
JavaScript بتعمل نفس الكلام بالظبط. Promise هو راكب VIP، setTimeout راكب عادي. حتى لو setTimeout مكتوب أولًا في الكود، Promise بيتقدّم في الطابور.
التعريف العلمي: ٤ مكوّنات هي كل القصة
- Call Stack: المكان اللي الكود الحالي بيتنفّذ فيه. كل سطر sync بيدخله ويطلع منه فورًا.
- Web APIs (أو Node APIs): مش جزء من JavaScript نفسها. هي اللي بتشغّل setTimeout والـ HTTP requests والـ DOM events. لما تنادي setTimeout، الـ JavaScript بتسلّم المهمة دي للمتصفح وتروح تكمل.
- Macrotask Queue (الطابور العادي): لما الـ Web API يخلص شغله — مثلًا Timer انتهى — بيحط الـ callback في الطابور ده. مصادره:
setTimeout,setInterval,I/O,UI events. - Microtask Queue (طابور VIP): مخصّص لـ Promise callbacks (
.then,.catch,.finally) وqueueMicrotaskوMutationObserver.
Event Loop دورته بسيطة: شيل أول مهمّة من Macrotask Queue ونفّذها. بعدها فضّي كل الـ Microtask Queue بالكامل قبل ما تاخد المهمّة التالية من Macrotask Queue. ده الفرق الجوهري: Microtasks بتتنفّذ كلها قبل أي macrotask جديدة.
كود شغّال يثبت الكلام
الكود ده بيشتغل زي ما هو على Node 22 ومتصفح Chrome 121:
console.log("1: sync start");
setTimeout(() => {
console.log("4: macrotask (setTimeout)");
}, 0);
Promise.resolve()
.then(() => console.log("3a: microtask 1"))
.then(() => console.log("3b: microtask 2"));
queueMicrotask(() => console.log("3c: microtask 3"));
console.log("2: sync end");
// المخرج الفعلي على Node 22:
// 1: sync start
// 2: sync end
// 3a: microtask 1
// 3c: microtask 3
// 3b: microtask 2
// 4: macrotask (setTimeout)
لاحظ ٣ حاجات: الكود الـ sync بيخلّص الأول. كل الـ microtasks بتتنفّذ قبل setTimeout. الـ microtask اللي اتضافت من .then() بعد microtask تاني (3b) بتيجي بعدها لأنها اتضافت بعد ما الطابور بدأ يتفضّى.
قياس فعلي: الفرق بـ مللي ثانية
عملت قياس على Node 22 لـ ١٠٠ ألف Promise مقابل ١٠٠ ألف setTimeout(0):
const N = 100_000;
console.time("microtasks");
let done = 0;
for (let i = 0; i < N; i++) {
Promise.resolve().then(() => {
if (++done === N) console.timeEnd("microtasks");
});
}
// النتيجة على M2 MacBook Air:
// microtasks: 38ms
console.time("macrotasks");
let done2 = 0;
for (let i = 0; i < N; i++) {
setTimeout(() => {
if (++done2 === N) console.timeEnd("macrotasks");
}, 0);
}
// النتيجة على نفس الجهاز:
// macrotasks: 412ms
Microtasks أسرع ١٠ مرّات تقريبًا. السبب: setTimeout عنده حد أدنى مفروض من المتصفح (٤ مللي ثانية بعد ٥ نداءات متتالية حسب HTML spec)، بينما microtasks ما عندهاش clamp ولا overhead لـ Timer.
تحذير مهم: تجمّد الواجهة
في كل دورة Event Loop، JavaScript بتفضّي الـ Microtask Queue بالكامل قبل ما ترسم الواجهة (rendering). يعني لو دالة Promise.then بتولّد Promise جديدة جوّاها في حلقة لا نهائية، الواجهة هتتجمّد للأبد. الكود ده بيشلّ المتصفح:
// متجربش ده على tab مهم — هيعلّقه
function freeze() {
Promise.resolve().then(freeze);
}
freeze();
نفس المنطق بـ setTimeout(freeze, 0) ما بيعلّقش المتصفح، لأن كل دورة الـ Event Loop بترسم الواجهة بين كل setTimeout والتاني.
ليه ده مهم في كود حقيقي؟
تلات سيناريوهات بتحصل بالظبط بسبب ترتيب الـ Event Loop:
- State updates في React: تحديثات الـ state اللي بتتعمل جوّا
.then()بتظهر في نفس render cycle، بينما اللي جوّا setTimeout بتحتاج render تاني. الفرق: ١٦ مللي ثانية extra تقريبًا. - اختبارات Jest: لو بتختبر دالة async،
await Promise.resolve()بيخلّيك تتأكد إن كل الـ microtasks خلصت قبل الـ assertion.setTimeoutما بيضمنش ده. - تنسيق DOM events مع Promises: لو في handler بيعدّل الـ DOM وبعدها بيعمل Promise.then، الـ then هتشوف الـ DOM المعدّل قبل ما المتصفح يرسم. ده بيمنع flickering.
الـ Trade-offs
كل أداة ولها ثمنها:
- Microtasks (Promise): سريعة جدًا (latency أقل) لكن بتمنع المتصفح من رسم الواجهة لو كترت. استخدمها لما عايز ترتيب فوري بعد المهمّة الحالية.
- Macrotasks (setTimeout): أبطأ في كل تكرار (~٤ مللي ثانية minimum) لكن بتسمح للمتصفح يرسم الواجهة بين كل تنفيذ والتاني. استخدمها لما عايز "تأخير" حقيقي أو "تنفسي" للواجهة.
- requestAnimationFrame: متزامنة مع رسم الشاشة (~١٦ مللي ثانية في 60fps). مخصّصة لـ animation فقط.
متى لا تشغّل بالك بالـ Event Loop
لو بتكتب Server-side render في Next.js أو endpoint REST بسيط، عمرك ما هتلاحظ الفرق بين microtask و macrotask، لأن الكود كله بيتنفّذ قبل ما تيجي response. لو الـ p99 latency عندك أكبر من ٥٠ مللي ثانية، المشكلة في الـ DB أو الـ network — مش في الـ Event Loop. اشتغل على المشكلة الكبيرة الأول.
الخطوة التالية
افتح DevTools في Chrome، روح لـ Performance tab، سجّل ٥ ثواني من تطبيقك وهو شغّال. شوف الـ "Tasks" الزرقاء (macrotasks) والـ "Microtasks" البرتقاني تحتها. لو لقيت macrotask واحدة طولها أكتر من ٥٠ مللي ثانية، دي مشكلة Long Task وبتأخّر INP. اتعامل معاها قبل ما تقلق على ترتيب Promise vs setTimeout.