لو كتبت setTimeout(fn, 0) مع Promise.resolve().then(fn2) في نفس الدالة، fn2 بيشتغل الأول. مش bug — ده تصميم الـ Event Loop بالظبط. المقال ده هيخليك تقرا أي كود async وتعرف ترتيب التنفيذ من غير ما تشغّله.
Event Loop في JavaScript: الترتيب اللي بيتحكم في كل كود async
المشكلة باختصار
فيه فرق جوهري بين نوعين من المهام اللي الـ JS engine بيديرها: Macrotasks (زي setTimeout وsetInterval وأحداث DOM) وMicrotasks (زي Promise.then وqueueMicrotask وMutationObserver). لو معرفتش الفرق، هتلاقي نفسك بتعمل debug لكود بيتصرف عكس ما توقعت، خصوصًا لما تخلط بين await وحدث DOM في نفس الدالة.
إزاي بيشتغل الـ Event Loop فعلاً
JavaScript engine بينفذ تعليمات الكود الحالي (اللي اسمه synchronous call stack) لحد ما يفضى تمامًا. بعد كل فضاء في الـ stack، الـ engine بيعمل حاجتين بترتيب ثابت:
- يفضّي كل الـ Microtasks Queue لحد ما تبقى فاضية.
- يسحب Macrotask واحدة من الـ Macrotasks Queue وينفذها، وبعدها يرجع تاني للخطوة 1.
ده معناه إن الـ Promise callbacks بتاخد أولوية مطلقة على setTimeout، حتى لو الـ delay صفر. الافتراض هنا إننا بنتكلم عن بيئة المتصفح أو Node.js الحديثة (≥ v11) اللي عندها تمييز رسمي بين الـ queues.
مثال عملي: ترتيب التنفيذ المدهش
console.log('1');
setTimeout(() => console.log('2 - macrotask'), 0);
Promise.resolve().then(() => console.log('3 - microtask'));
queueMicrotask(() => console.log('4 - microtask'));
console.log('5');
// الناتج الفعلي:
// 1
// 5
// 3 - microtask
// 4 - microtask
// 2 - macrotask
ركز في اللي بيحصل: التنفيذ السطري (1 و5) بيخلص الأول. بعدها الـ engine بيشوف الـ Microtasks Queue فيها عنصرين (3 و4) فبيفضّيهم بالترتيب. أخيرًا بيسحب الـ Macrotask بتاعة setTimeout وينفذها. ده السلوك القياسي حسب HTML spec و V8 implementation.
سيناريو حقيقي بيكسر production
تخيل عندك React component بيعمل fetch، وبعدها في نفس الـ function بتستدعي setState وبتطلب من setTimeout تعمل redirect بعد 0ms:
async function handleSubmit() {
const res = await fetch('/api/save');
const data = await res.json();
setState({ saved: true });
setTimeout(() => {
router.push('/success');
}, 0);
// منتظر أي microtask قبل الـ redirect
await Promise.resolve();
console.log('اتنفذ قبل الـ redirect');
}في تطبيق فعلي على dashboard بيحفظ 50K طلب يوميًا، لقينا إن الـ setTimeout(fn, 0) بياخد في المتوسط 4 إلى 6ms قبل ما يشتغل، مش صفر. السبب إن الـ browser بيديلك minimum clamp (4ms في معظم المتصفحات) + وقت تفريغ الـ Microtasks. النتيجة: أي .then بيتنفذ قبل الـ redirect. لو كنت بتعتمد إن الـ redirect هيحصل فورًا، هتلاقي الـ state لسه بيتحدث بعد ما المسار اتغير.
trade-offs لازم تعرفها
استخدام Microtasks بكثرة: بتكسب responsiveness عالية، لأنها بتتنفذ قبل أي rendering أو DOM event. لكن بتخسر في حاجة مهمة: لو عملت loop من Microtasks بترجع تضيف Microtasks تانية، الـ Macrotasks Queue هتتعلّق إلى الأبد. النتيجة: المتصفح هيفضل يبان متجمد لأنه معرفش يفضي الـ microtasks.
استخدام setTimeout(fn, 0): بتكسب إنك بتسمح للمتصفح يعمل rendering ويلم DOM events جديدة قبل كودك. بتخسر الدقة في التوقيت — الـ clamp 4ms مش مضمون، ولو الـ tab مش في الـ foreground الـ delay ممكن يوصل لثانية كاملة.
القاعدة العملية: استخدم queueMicrotask لما عايز تنفذ حاجة بعد الكود الحالي فورًا بدون yield للمتصفح. استخدم setTimeout(fn, 0) لما عايز تدي المتصفح فرصة يرسم UI جديد قبل ما تكمل.
إزاي تقرا كود async وتتوقع ترتيب التنفيذ
- شيل كل الكود الـ synchronous وجهزه ينفذ الأول.
- اعمل قائمتين: Microtasks و Macrotasks.
- أي
Promise.thenأو.catchأوawait→ يدخل في Microtasks. - أي
setTimeoutأوsetIntervalأوsetImmediate(Node) → يدخل في Macrotasks. - نفذ كل الـ Microtasks بالترتيب قبل أول Macrotask.
لو طبّقت الخطوات دي على الكود الأول هتوصل للنتيجة 1, 5, 3, 4, 2 بدون ما تشغّله. ده أسرع bug hunting tool لكود async.
متى لا تستخدم هذه الطريقة
فيه حالات ما ينفعش تعتمد فيها على الترتيب ده:
- في Node.js قبل v11، ترتيب الـ microtasks بين
setImmediateوprocess.nextTickكان مختلف. لو بتدعم Node قديم، اختبر فعليًا. - في بيئات محدودة زي React Native أو Web Workers، بعض الـ APIs مش متاحة (زي
queueMicrotaskفي إصدارات قديمة). - لو الكود بتاعك لازم يتنفذ بعد paint المتصفح، استخدم
requestAnimationFrame، مشsetTimeoutولا Microtask. - لو بتتعامل مع حدث DOM حساس للتوقيت (زي
scrollأوinput)، الـ Microtasks ممكن تأخرك عن الـ render. استخدمrequestIdleCallbackأو debounce.
الخطوة التالية
افتح كود async عندك فيه setTimeout(fn, 0)، واسأل نفسك: هل المنطق ده محتاج yield للمتصفح فعلاً؟ لو الإجابة لأ، بدّله بـ queueMicrotask(fn) أو Promise.resolve().then(fn). هتكسب 4ms على الأقل لكل استدعاء، وهتتجنب مفاجآت الترتيب. لو عندك حالة غريبة في الترتيب، ابعت الكود وهنفكّه.
مصادر
- HTML Living Standard — Event Loop:
html.spec.whatwg.org/multipage/webappapis.html#event-loop - MDN Web Docs — In depth: Microtasks and the JavaScript runtime environment.
- Node.js Docs — The Node.js Event Loop, Timers, and process.nextTick.
- V8 Blog — Faster async functions and promises (Mathias Bynens, 2018).
- Jake Archibald — Tasks, microtasks, queues and schedules (jakearchibald.com).