> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# حلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟
- URL: https://arabroot.io/articles/حلقة-الاحداث-event-loop-في-javascript/
- Published: 2026-10-30T12:58:11.000Z
- Updated: 2026-10-30T23:17:52.000Z
- Description: كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, تطوير الواجهات والجوال Frontend & Mobile

إذا كتبت JavaScript لفترة فلا بد أنك مررت بإحدى هاتين اللحظتين. الأولى أن تضع `console.log` بعد `setTimeout(fn, 0)` وبعد `Promise.resolve().then(...)` فتظهر الأسطر بترتيب لم تتوقعه، فتقول لنفسك إن صفراً من الملي ثانية يعني «الآن»، فلماذا جاء المؤقت بعد الوعد؟ والثانية أشد إزعاجاً، فلنفرض أنك أضفت إلى صفحة المنتجات في موقعك كوداً يبني فهرساً للبحث داخل المتصفح حتى تظهر نتائج البحث فوراً دون الرجوع إلى السيرفر، وكل شيء بدا ممتازاً على جهازك، ثم بدأت تصلك رسائل من المستخدمين بأن زر «أضف إلى السلة» لا يستجيب أحياناً، وأن بعضهم ضغطه مرتين فوجد منتجين في السلة. وعندما تفتح الصفحة لا تجد أي خطأ في الـ Console، ولا يوجد طلب شبكة بطيء، والكود الذي يضيف المنتج إلى السلة سطر واحد.

والسبب في الحالتين واحد، وهو الطريقة التي ينفذ بها المتصفح كودك، فالصفحة كلها تعمل على خيط رئيسي واحد Main Thread، وعليه يعمل كود JavaScript ومعالجات النقرات ورسم الشاشة، وما يقرر من يعمل الآن ومن ينتظر هو حلقة الأحداث Event Loop. وعندما تفهم هذه الحلقة سوف تعرف لماذا جاء المؤقت بعد الوعد، ولماذا انتظرت نقرة المستخدم ثلث ثانية خلف كود الفهرسة، وكيف تقسم هذا العمل حتى تبقى الصفحة سريعة الاستجابة. وهذا المقال من مرحلة [هندسة الواجهات وتطبيقات الويب Frontend](https://arabroot.io/start/#stage-web-frontend)، وقد شرحنا في [مقال الخيوط وasync](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/#event-loop) ترتيب الطوابير في Node.js باختصار، وهنا سوف ننزل إلى التفاصيل في المتصفح أولاً ثم نعود إلى Node.js.

وسوف نناقش في هذا المقال ما يلي:

- لماذا يعمل كود الصفحة على خيط واحد، وما هو مكدس الاستدعاءات Call Stack وقاعدة «التنفيذ حتى النهاية» Run to Completion.
- المهام Tasks والمهام الصغيرة Microtasks: من أين تأتي كل واحدة، ومتى تنفذ، ولماذا يسبق الوعد المؤقت دائماً.
- خطوة الرسم Rendering في الحلقة، وأين يقع `requestAnimationFrame` بين المهام، مع تجربة حقيقية في Chrome.
- المؤقتات: لماذا `setTimeout(fn, 0)` ليس صفراً، والحد الأدنى 4 ms بعد التداخل، وتبطيء المؤقتات في الألواح المخفية.
- المهام الطويلة Long Tasks ومقياس INP بأجزائه الثلاثة، وتجربة تقيس كم تنتظر النقرة خلف مهمة فهرسة طويلة.
- تقسيم العمل بـ `setTimeout` وبـ `scheduler.yield()` وبالـ Web Workers، مع أرقام من 15 تشغيلاً لكل طريقة.
- حلقة الأحداث في Node.js ومراحلها، و`setImmediate` و`process.nextTick`، وتجويع الحلقة بالوعود.
- أخطاء شائعة، وجدول قرار، وتدريب عملي.

## لماذا يعمل كود الصفحة على خيط Thread واحد؟

عندما تفتح صفحة في المتصفح فإن الصفحة تحصل على خيط رئيسي واحد، وعلى هذا الخيط يحدث كل ما له علاقة بالصفحة نفسها: تحليل HTML، وتنفيذ JavaScript، واستدعاء معالجات الأحداث Event Handlers، وحساب الأنماط Styles والتخطيط Layout والرسم Paint. ويوجد في المتصفح طبعاً خيوط أخرى كثيرة للشبكة وفك الصور وتركيب الطبقات، ولكن كودك الذي يلمس الـ DOM لا يعمل إلا على الخيط الرئيسي، والسبب أن الـ DOM نفسه لم يصمم ليتحمل خيطين يعدلان نفس العنصر في نفس اللحظة. وتخيل لو كان معالج نقرة يحذف عنصراً من القائمة بينما معالج آخر على خيط ثان يقرأ طول نفس القائمة، فسوف تحتاج أقفالاً Locks في كل سطر من كود الواجهة، وسوف تظهر مشكلات حالة السباق Race Condition التي شرحناها في [مقال الخيوط](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/#race-condition) في كل صفحة على الإنترنت. لذلك اختار الويب نموذجاً أبسط: خيط واحد للصفحة، وطوابير ينتظر فيها العمل دوره، وحلقة تأخذ من هذه الطوابير بترتيب محدد.

وهذا لا يعني أنك لا تستطيع تشغيل كود على خيط آخر، فهناك [الـ Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web%5FWorkers%5FAPI/Using%5Fweb%5Fworkers?ref=arabroot.io)، وكل Worker له خيطه وحلقة أحداثه الخاصة، ولكنه لا يرى الـ DOM إطلاقاً، ويتواصل مع الصفحة بالرسائل فقط. وسوف نعود إلى الـ Workers في قسم تقسيم العمل، لأنها الحل الصحيح لبعض أنواع العمل وليست الحل لكل شيء.

والنموذج الذي يحكم هذا كله مكتوب في [معيار HTML](https://html.spec.whatwg.org/multipage/webappapis.html?ref=arabroot.io#event-loops)، في قسم حلقات الأحداث Event Loops، وهو نفس النموذج الذي تتبعه Chrome وFirefox وSafari، ولذلك فإن ما سوف تتعلمه هنا ليس تفصيلاً من تفاصيل متصفح واحد، وإنما قواعد مكتوبة في المعيار، مع بعض الحرية التي يتركها المعيار لكل متصفح كما سوف نرى.

## مكدس الاستدعاءات Call Stack: التنفيذ حتى النهاية

مكدس الاستدعاءات هو المكان الذي يتتبع فيه محرك JavaScript الدالة التي تعمل الآن ومن استدعاها، فعندما تستدعي دالة يضعها المحرك فوق المكدس، وعندما تنتهي يزيلها، وعندما تستدعي هي دالة أخرى توضع الأخرى فوقها، وهكذا. وهذا هو نفس المكدس Stack الذي يظهر لك في رسالة الخطأ عندما يطبع المتصفح سلسلة الدوال التي أوصلت إلى السطر الذي فشل.

والقاعدة الأهم هنا هي التنفيذ حتى النهاية Run to Completion، أي أن المتصفح عندما يبدأ تنفيذ مهمة فإنه لا يقاطعها في منتصفها ليعالج نقرة أو يرسم الشاشة، وإنما ينتظر حتى يفرغ المكدس تماماً. وهذا يعطيك ضماناً جميلاً، وهو أن المتغيرات التي تقرؤها في دالتك لن يغيرها كود آخر في منتصف التنفيذ، ولذلك لا تحتاج أقفالاً في JavaScript. ولكن نفس القاعدة لها وجه آخر، وهو أن الدالة التي تستغرق 400 ms تحجز الخيط 400 ms كاملة، ولا يستطيع المتصفح خلالها أن يستجيب لأي شيء، ولا حتى أن يحرك مؤشر التحميل المرسوم بـ JavaScript.

وقد يتساءل البعض: إذا كان المكدس لا يقاطع، فكيف تعمل `fetch` مثلاً وهي تنتظر رد السيرفر ثانية كاملة؟ والإجابة أن الانتظار نفسه لا يحدث على الخيط الرئيسي، فعندما تستدعي `fetch` يرسل المتصفح الطلب على خيوط الشبكة الخاصة به كما شرحنا في [مقال HTTP](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، وتنتهي دالتك فوراً وتعود إليك بوعد Promise، ويفرغ المكدس. وعندما يصل الرد يضع المتصفح مهمة في الطابور، ثم تأتي الحلقة إلى هذه المهمة في دورها، فيحل الوعد ويعمل الكود الذي بعد `await`. أي أن JavaScript لا تنتظر أبداً على الخيط الرئيسي، وإنما تسجل ما تريد تنفيذه لاحقاً ثم تخرج، وهذا كل سر البرمجة غير المتزامنة Asynchronous في المتصفح.

## المهام Tasks: من أين تأتي؟

المهمة Task، وتسميها بعض المراجع المهمة الكبيرة Macrotask، هي وحدة عمل كاملة يضعها المتصفح في طابور وينفذها لاحقاً من أولها إلى آخرها. وأشهر مصادر المهام كما يلي:

- **تحليل الصفحة:** تنفيذ كل وسم `<script>` عند تحليل HTML هو مهمة.
- **أحداث المستخدم:** النقرة والضغط على المفاتيح والكتابة في حقل، فكل حدث من هذه يصل كمهمة تنفذ معالجاته.
- **المؤقتات Timers:** عندما يحين وقت `setTimeout` أو `setInterval` يضع المتصفح ردها في طابور المهام، ولا ينفذها فوراً.
- **الشبكة:** وصول رد `fetch` أو بيانات WebSocket.
- **الرسائل:** `postMessage` بين النوافذ أو مع Worker، و[MessageChannel](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel?ref=arabroot.io).

ولاحظ أن المعيار لا يتحدث عن طابور مهام واحد، وإنما عن عدة طوابير، ويترك للمتصفح أن يختار في كل دورة من أي طابور يأخذ، بشرط أن تبقى المهام من نفس المصدر بترتيب دخولها. وهذه الحرية هي التي تسمح للمتصفح أن يعطي أحداث المستخدم أولوية أعلى من المؤقتات، فالنقرة التي تنتظر في الطابور قد تسبق مؤقتاً جاهزاً، ولكنها لا تستطيع أن تسبق المهمة التي تعمل الآن مهما كانت أولويتها، لأن التنفيذ حتى النهاية لا استثناء فيه.

والقاعدة الثانية المهمة أن الحلقة تأخذ مهمة واحدة فقط في كل دورة، ثم تعطي الفرصة للمهام الصغيرة وللرسم قبل أن تأخذ المهمة التالية. وهذا بالضبط ما يجعل تقسيم العمل الطويل إلى مهام صغيرة مفيداً، كما سوف نرى في التجربة، فبين كل مهمتين توجد فرصة للنقرة أن تعالج وللشاشة أن ترسم.

## المهام الصغيرة Microtasks: لماذا يسبق الوعد Promise المؤقت؟

المهمة الصغيرة Microtask هي عمل قصير تريد تنفيذه بعد أن ينتهي الكود الحالي مباشرة، وقبل أن يعود المتصفح إلى أي مهمة أخرى أو إلى الرسم. ومصادرها في المتصفح كما يلي:

- ردود الوعود: `.then` و`.catch` و`.finally`، والجزء الذي بعد `await` داخل الدالة `async`.
- الدالة [queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/Window/queueMicrotask?ref=arabroot.io) التي تضع دالة في هذا الطابور مباشرة.
- ردود MutationObserver التي تراقب تغييرات الـ DOM.

والفرق الجوهري بين الطابورين هو متى يفرغ كل منهما، فبعد كل مهمة ينفذ المتصفح ما يسميه المعيار نقطة فحص المهام الصغيرة Microtask Checkpoint، أي أنه ينفذ كل ما في طابور المهام الصغيرة، وإذا أضافت مهمة صغيرة مهمة صغيرة أخرى أثناء التنفيذ فإنها تنفذ في نفس النقطة أيضاً، ولا يخرج المتصفح منها حتى يفرغ الطابور تماماً. أما طابور المهام فيأخذ منه مهمة واحدة فقط ثم يعود إلى المهام الصغيرة والرسم. ولهذا السبب يأتي `.then` قبل `setTimeout(fn, 0)` دائماً، فالوعد ينفذ في نقطة الفحص التي بعد المهمة الحالية مباشرة، والمؤقت يحتاج أن ينتظر دوره كمهمة جديدة في دورة قادمة.

وقد يتساءل البعض: لماذا وجد طابوران أصلاً؟ لماذا لا يوضع رد الوعد في طابور المهام مثل المؤقت؟ والإجابة أن ردود الوعود عادة تكمل عملاً بدأ للتو، فإذا كتبت `const data = await res.json()` ثم حدثت الواجهة، فأنت تريد أن يكتمل هذا التحديث قبل أن يرسم المتصفح الشاشة، وإلا ظهرت للمستخدم حالة نصف محدثة. فالمهام الصغيرة تعطيك ضماناً بأن سلسلة الوعود كلها تكتمل قبل الرسم وقبل أن يعالج أي حدث جديد، كما يشرح [دليل المهام الصغيرة في MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTML%5FDOM%5FAPI/Microtask%5Fguide?ref=arabroot.io).

ولهذا الضمان ثمن، فبما أن المتصفح لا يخرج من نقطة الفحص حتى يفرغ الطابور، فإن سلسلة وعود تضيف نفسها إلى الطابور بلا نهاية تجمد الصفحة تماماً كما تجمدها حلقة `while(true)`، لأن المتصفح لن يصل إلى الرسم ولا إلى النقرات أبداً. وهذا ما يسمى تجويع الحلقة Starvation، وسوف نقيسه بالأرقام في تجربة Node.js لاحقاً.

والصورة التالية تجمع الخطوات في دورة واحدة من الحلقة: مهمة واحدة، ثم كل المهام الصغيرة، ثم الرسم إذا حان وقته، ثم العودة إلى البداية:

![أربع خطوات مرقمة لدورة حلقة الأحداث: خذ مهمة واحدة من طابور المهام، نفذها على مكدس الاستدعاءات حتى يفرغ، نفذ كل المهام الصغيرة، ثم إذا حان وقت إطار جديد نفذ requestAnimationFrame ثم Style وLayout وPaint وComposite، وبجانبها ثلاثة صناديق للطوابير بأمثلة: click handler وsetTimeout وMessageChannel وfetch في طابور المهام، وpromise.then وafter await وqueueMicrotask في طابور المهام الصغيرة، وrequestAnimationFrame قبل الرسم](https://arabroot.io/content/images/2026/10/js-event-loop-01-loop.webp)

مهمة واحدة في كل دورة، ثم طابور المهام الصغيرة كله، ثم الرسم إذا حان وقت إطار جديد

## أين يقع الرسم Rendering في الحلقة؟

الخطوة الرابعة في الصورة هي التي ينساها كثير من المطورين، وهي تحديث الشاشة Update the Rendering. فالمتصفح لا يرسم بعد كل مهمة، وإنما عندما تأتي فرصة للرسم Rendering Opportunity، وهذه تأتي عادة مع معدل تحديث الشاشة، أي قرابة 60 مرة في الثانية في أغلب الشاشات، أو مرة كل 16.7 ms تقريباً، وقد تكون أسرع في شاشات 120Hz. وإذا لم يتغير شيء في الصفحة فقد يتخطى المتصفح الرسم كله.

وعندما يقرر المتصفح أن يرسم فإنه يمر بعدة خطوات بالترتيب: أولاً ينفذ ردود [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame?ref=arabroot.io) التي سجلتها الصفحة، ثم يحسب الأنماط Style، ثم التخطيط Layout، ثم الرسم Paint، ثم تركيب الطبقات Composite. وكيف تعمل كل خطوة من هذه من الداخل، ولماذا يكلف تغيير خاصية أكثر من أخرى، هو موضوع مقال مستقل في نفس المرحلة عن عمل المتصفح من الداخل، والذي يهمنا هنا هو موقع الرسم في الحلقة. فالرسم يحتاج نفس الخيط الرئيسي الذي يعمل عليه كودك، وبالتالي إذا كانت مهمة طويلة تعمل الآن فلن يرسم المتصفح أي إطار Frame حتى تنتهي، ولن يتحرك شيء على الشاشة مهما كان معدل الشاشة.

وهذا يفسر سلوكاً قد يحيرك، فلو كتبت كوداً يضيف عنصر «جار التحميل» إلى الصفحة ثم يبدأ مباشرة حساباً طويلاً في نفس الدالة، فلن يرى المستخدم عنصر التحميل أبداً، لأن المتصفح لم يحصل على فرصة للرسم بين السطرين، وعندما انتهى الحساب كان العنصر قد حذف أو تغير. والحل أن تعطي المتصفح فرصة للرسم قبل الحساب، وهذا ما سوف نتعلمه في قسم تقسيم العمل.

أما `requestAnimationFrame` فهي المكان الصحيح لأي تعديل بصري يتكرر مع كل إطار، مثل حركة عنصر أو تحديث عداد، لأنها تعمل مرة واحدة قبل كل رسم بالضبط، فلا تضيع حسابات على إطارات لن تظهر كما يحدث مع `setInterval(fn, 16)` الذي لا يعرف شيئاً عن موعد الإطار. ولاحظ أن المهام الصغيرة التي تضيفها داخل رد `requestAnimationFrame` تنفذ هي أيضاً قبل أن يكمل المتصفح إلى حساب الأنماط، كما سوف تظهر التجربة التالية.

## التجربة الأولى: ترتيب التنفيذ في Chrome

الآن سوف نختبر كل ما سبق في متصفح حقيقي، فالصفحة التالية تسجل في مصفوفة كل خطوة مع الوقت منذ بداية السكربت، وتجمع كل الأنواع التي ذكرناها: كود متزامن، ومؤقت بصفر، ورسالة MessageChannel، ورد `requestAnimationFrame`، ووعود و`queueMicrotask` ودالة `async`. احفظها باسم `order.html`:

```html
<pre id="out"></pre>
<script>
// order.html: in which order does the browser run tasks, microtasks and rendering callbacks?
const log = [];
const t0 = performance.now();
const note = (msg) => log.push(`${(performance.now() - t0).toFixed(2).padStart(7)} ms  ${msg}`);

note('1 sync: start of the script');
setTimeout(() => note('task: setTimeout 0'), 0);
const ch = new MessageChannel();
ch.port1.onmessage = () => note('task: MessageChannel message');
ch.port2.postMessage(null);
requestAnimationFrame(() => {
  note('render step: requestAnimationFrame (before style, layout, paint)');
  Promise.resolve().then(() => note('microtask queued inside rAF'));
});
Promise.resolve().then(() => {
  note('microtask: promise .then');
  queueMicrotask(() => note('microtask: queued by another microtask'));
});
queueMicrotask(() => note('microtask: queueMicrotask'));
(async () => {
  note('2 sync: async function before await');
  await null;
  note('microtask: async function after await');
})();
note('3 sync: end of the script');

// print once everything above has had its turn
setTimeout(() => { document.getElementById('out').textContent = log.join('\n'); window.__order = log; }, 200);
</script>
```

ولكي تكون النتيجة قابلة للتكرار لم نفتح الصفحة باليد، وإنما شغلنا Chrome بدون واجهة Headless وتحكمنا فيه عبر بروتوكول DevTools Protocol، وهو نفس البروتوكول الذي تستخدمه أدوات الاختبار مثل Playwright التي شرحناها في [دليل اختبارات E2E](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-e2e-%D9%85%D8%B9-playwright-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/). والسكربت `run.mjs` يشغل سيرفراً صغيراً على المنفذ 19345 يقدم ملفات التجربة، ثم يفتح الصفحة خمس مرات ويقرأ المصفوفة `window.__order` في كل مرة. والأمر كما يلي:

```bash
node content/articles/js-event-loop/lab/run.mjs
```

والمخرج للتحميل الأول سوف يكون كما يلي:

```text
Browser: Chrome/156.0.8078.49

== order.html, first load
   0.10 ms  1 sync: start of the script
   0.40 ms  2 sync: async function before await
   0.40 ms  3 sync: end of the script
   0.50 ms  microtask: promise .then
   0.50 ms  microtask: queueMicrotask
   0.50 ms  microtask: async function after await
   0.50 ms  microtask: queued by another microtask
   2.30 ms  task: setTimeout 0
   2.50 ms  task: MessageChannel message
  11.60 ms  render step: requestAnimationFrame (before style, layout, paint)
  11.60 ms  microtask queued inside rAF
```

في المخرج أعلاه لاحظ التالي:

- الأسطر الثلاثة الأولى كود متزامن، بما فيه السطر الذي داخل الدالة `async` قبل `await`، فالدالة `async` تعمل متزامنة حتى تصل إلى أول `await`، وعندها فقط يتحول باقيها إلى مهمة صغيرة.
- بعد أن انتهى السكربت، وهو نفسه مهمة، فرغ المكدس فبدأت نقطة فحص المهام الصغيرة، فنفذت الأربعة كلها عند 0.50 ms، بالترتيب الذي دخلت به الطابور.
- السطر `queued by another microtask` أضافه رد `.then` أثناء تنفيذه، ومع ذلك نفذ في نفس نقطة الفحص قبل المؤقت، وهذا هو المعنى العملي لعبارة «حتى يفرغ الطابور».
- المؤقت جاء عند 2.30 ms مع أن مدته صفر، ثم رسالة MessageChannel بعده، فكلاهما مهمة جديدة تنتظر دورها في دورة قادمة.
- رد `requestAnimationFrame` جاء عند 11.60 ms، أي بعد قرابة تسعة ملي ثانية لم يحدث فيها شيء، لأن المتصفح كان ينتظر موعد الإطار التالي، والمهمة الصغيرة التي أضيفت داخله نفذت مباشرة بعده في نفس الوقت.

والآن السؤال الأهم: هل هذا الترتيب ثابت؟ والمخرج للتحميلات الخمسة يجيب، وهو كما طبعه السكربت:

```text
== position of rAF vs setTimeout in 5 loads
   0.50 ms  microtask: promise .then ->    0.50 ms  microtask: queueMicrotask ->    0.50 ms  microtask: async function after await ->    0.50 ms  microtask: queued by another microtask -> setTimeout (2.30 ms) -> MessageChannel (2.50 ms) -> requestAnimationFrame (11.60 ms)
   0.20 ms  microtask: promise .then ->    0.30 ms  microtask: queueMicrotask ->    0.30 ms  microtask: async function after await ->    0.30 ms  microtask: queued by another microtask -> requestAnimationFrame (0.40 ms) -> setTimeout (1.20 ms) -> MessageChannel (1.40 ms)
   0.40 ms  microtask: promise .then ->    0.40 ms  microtask: queueMicrotask ->    0.40 ms  microtask: async function after await ->    0.40 ms  microtask: queued by another microtask -> requestAnimationFrame (0.80 ms) -> setTimeout (2.70 ms) -> MessageChannel (2.90 ms)
   0.20 ms  microtask: promise .then ->    0.20 ms  microtask: queueMicrotask ->    0.20 ms  microtask: async function after await ->    0.20 ms  microtask: queued by another microtask -> setTimeout (0.30 ms) -> MessageChannel (0.30 ms) -> requestAnimationFrame (4.40 ms)
   0.20 ms  microtask: promise .then ->    0.30 ms  microtask: queueMicrotask ->    0.30 ms  microtask: async function after await ->    0.30 ms  microtask: queued by another microtask -> setTimeout (1.30 ms) -> MessageChannel (1.40 ms) -> requestAnimationFrame (11.60 ms)
```

وهنا مشكلة صغيرة واجهناها في السكربت نفسه، فقد أردنا أن نطبع في كل سطر المهام وخطوة الرسم فقط، فكتبنا فلتراً يبحث عن النص `task: `، ولكن الكلمة `microtask: `تحتوي هي أيضاً على `task: `، فظهرت المهام الصغيرة في السطر مع المهام. وقد تركنا المخرج كما هو لأنه لا يغير النتيجة، بل يؤكدها، والدرس هنا أن المطابقة بالنص الجزئي في السجلات تخدعك بسهولة، فطابق بالكلمة الكاملة أو ببداية السطر.

وأهم ما في هذه الأسطر الخمسة ما يلي:

- الجزء الثابت في كل التحميلات: الكود المتزامن ثم المهام الصغيرة الأربع ثم المهام، و`setTimeout` قبل MessageChannel في كل مرة لأن كلاهما سجل بهذا الترتيب.
- الجزء المتغير: `requestAnimationFrame` جاء بعد المؤقت في ثلاثة تحميلات، وقبله في تحميلين عند 0.40 ms و0.80 ms، والسبب أن موعد الإطار التالي يعتمد على اللحظة التي بدأ فيها السكربت بالنسبة لساعة الشاشة، فإذا صادف أن الإطار كان مستحقاً مباشرة بعد السكربت رسم المتصفح قبل أن يأخذ مهمة المؤقت.

والقاعدة العملية من هذه التجربة: ترتيب المهام الصغيرة مضمون، وترتيب المهام من نفس المصدر مضمون، أما ترتيب المؤقت بالنسبة للرسم فغير مضمون، فلا تكتب كوداً يفترض أن `setTimeout(fn, 0)` يعمل قبل الإطار التالي أو بعده. والصورة التالية تبين ترتيب التحميل الأول والتحميلات الخمسة:

![قائمة بأسطر المخرج الحقيقي ملونة حسب النوع: ثلاثة أسطر كود متزامن عند 0.10 و0.40 ms، ثم أربع مهام صغيرة عند 0.50 ms، ثم مهمتان setTimeout عند 2.30 ms وMessageChannel عند 2.50 ms، ثم requestAnimationFrame ومهمة صغيرة داخله عند 11.60 ms، وبجانبها خمسة صفوف تبين أن rAF جاء بعد setTimeout ثلاث مرات وقبله مرتين](https://arabroot.io/content/images/2026/10/js-event-loop-02-order.webp)

المهام الصغيرة تسبق المهام دائماً، أما موقع requestAnimationFrame فيتبع موعد الإطار

وهذا السؤال عن ترتيب الأسطر من أشهر أسئلة مقابلات الواجهات، وقد جمعنا أسئلة أخرى من نفس النوع في [أسئلة مقابلة مطور Frontend](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/#event-loop)، ولكن الهدف هنا أبعد من الإجابة الصحيحة في المقابلة، فالهدف أن تعرف أين يقع كل سطر من كودك في الحلقة عندما تبطئ الصفحة.

## المؤقتات Timers: لماذا setTimeout(fn, 0) ليس صفراً؟

الرقم الذي تمرره إلى `setTimeout` هو أقل مدة قبل أن يضع المتصفح الرد في طابور المهام، وليس الموعد الذي ينفذ فيه الرد، فبعد أن تمر المدة يجب أن ينتظر الرد دوره خلف المهام التي في الطابور وخلف المهمة التي تعمل الآن. فإذا طلبت 10 ms وكانت هناك مهمة طويلة مدتها 300 ms فسوف ينفذ ردك بعد 300 ms على الأقل. وفوق ذلك هناك حالتان يرفع فيهما المتصفح المدة بنفسه.

### الحد الأدنى بعد التداخل Nesting

يقول [قسم المؤقتات في معيار HTML](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html?ref=arabroot.io#timers) إن المؤقت الذي ينشأ من داخل مؤقت آخر يحمل مستوى تداخل Nesting Level، وإذا زاد هذا المستوى على 5 وكانت المدة أقل من 4 ms فإن المتصفح يجعلها 4 ms. والسبب تاريخي وعملي في نفس الوقت، فبعض الصفحات القديمة كانت تكتب حلقات من `setTimeout(fn, 0)` المتداخلة تستهلك المعالج بلا فائدة، فوضع المعيار هذا الحد ليحمي الجهاز من هذه الحلقات. والصفحة التالية تقيس ذلك، فكل مؤقت ينشئ التالي من داخله، ونسجل الزمن بين كل مؤقت والذي يليه:

```html
<pre id="out"></pre>
<script>
// clamp.html: setTimeout(fn, 0) nested inside itself; how long is each hop really?
const gaps = [];
let last = performance.now();
function hop(level) {
  const now = performance.now();
  gaps.push({ level, gap: +(now - last).toFixed(2) });
  last = now;
  if (level < 12) setTimeout(() => hop(level + 1), 0);
  else { window.__clamp = gaps; document.getElementById('out').textContent = gaps.map(g => `level ${String(g.level).padStart(2)}: ${g.gap} ms`).join('\n'); }
}
setTimeout(() => hop(1), 0);
</script>
```

والمخرج من نفس التشغيل سوف يكون كما يلي:

```text
== clamp.html
level  1: 2.6 ms
level  2: 0.4 ms
level  3: 0 ms
level  4: 0.1 ms
level  5: 0.1 ms
level  6: 0 ms
level  7: 4.4 ms
level  8: 4.4 ms
level  9: 4.4 ms
level 10: 4.5 ms
level 11: 4.1 ms
level 12: 4.9 ms
```

في المخرج أعلاه لاحظ التالي:

- المؤقتات من الأول إلى السادس عملت بلا تأخير يذكر، وأرقام مثل 0 و0.1 ms تعني أن المؤقت نفذ في الدورة التالية مباشرة، والأول أخذ 2.6 ms لأنه بدأ من سكربت الصفحة أثناء تحميلها.
- من المؤقت السابع أصبح كل انتظار 4 ms أو أكثر قليلاً، فالمؤقت الأول أنشأه السكربت ومستوى تداخله صفر، والسابع أنشأه مؤقت مستواه 6، أي أكبر من 5، فطبق المتصفح الحد. وهذا يطابق ما يذكره [توثيق setTimeout في MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout?ref=arabroot.io) من أن الحد يبدأ بعد خمسة مؤقتات متداخلة.
- الأرقام فوق 4 ms مثل 4.9 هي الحد نفسه مع زمن انتظار الدور في الطابور.

![رسم أعمدة لاثني عشر مؤقتاً متداخلاً: الأعمدة من 1 إلى 6 زرقاء وقصيرة بقيم 2.6 و0.4 و0 و0.1 و0.1 و0، والأعمدة من 7 إلى 12 برتقالية بقيم بين 4.1 و4.9 ms، مع خط أحمر متقطع عند 4 ms يمثل الحد الأدنى في معيار HTML](https://arabroot.io/content/images/2026/10/js-event-loop-06-clamp.webp)

بعد خمسة مستويات من التداخل يضع المتصفح حداً أدنى 4 ms لكل مؤقت

وهذا الحد سوف يظهر لنا مرة أخرى بشكل غير متوقع في تجربة تقسيم العمل، فمن يقسم عمله بـ `setTimeout(fn, 0)` في سلسلة يدفع هذه الملي ثانيات الأربع مع كل جزء بعد الجزء الخامس.

### المؤقتات في الألواح Tabs المخفية

الحالة الثانية أشد أثراً، فعندما ينتقل المستخدم إلى لوح Tab آخر يبطئ المتصفح مؤقتات اللوح المخفي ليوفر البطارية. وحسب [نفس صفحة MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout?ref=arabroot.io) فإن Chrome يفحص المؤقتات في اللوح المخفي مرة كل ثانية، وإذا بقي اللوح مخفياً أكثر من خمس دقائق، وكانت المؤقتات متداخلة خمس مرات أو أكثر، والصفحة صامتة، فإنه يفحصها مرة كل دقيقة فقط، وهذا ما يسميه التبطيء المكثف Intensive Throttling. ولهذا أثر مباشر على كود تعتمد عليه كثير من التطبيقات، مثل مؤقت يجدد التوكن قبل انتهائه، كما شرحنا في [مقال الجلسات وJWT](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، فإذا كان المؤقت مضبوطاً قبل الانتهاء بثلاثين ثانية واللوح مخفي منذ عشر دقائق فقد يعمل بعد انتهاء التوكن فعلاً. والحل ألا تعتمد على دقة المؤقت في هذه الحالات، وإنما تفحص صلاحية التوكن قبل كل طلب، أو تستمع إلى الحدث `visibilitychange` وتجدد عندما يعود المستخدم إلى اللوح.

## المهام الطويلة Long Tasks ومقياس INP

نعود الآن إلى صفحة المنتجات التي بدأنا بها. المهمة الطويلة Long Task حسب [واجهة Long Tasks في MDN](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming?ref=arabroot.io) هي أي مهمة تحجز الخيط الرئيسي 50 ms أو أكثر، والرقم 50 ليس عشوائياً، فحسب [نموذج RAIL](https://web.dev/articles/rail?ref=arabroot.io) من فريق Chrome يجب أن يرى المستخدم استجابة لنقرته خلال 100 ms، وإذا أراد المتصفح أن يستجيب لنقرة المستخدم في حدود 100 ms فإن أي مهمة أطول من 50 ms تأكل نصف هذه الميزانية قبل أن تبدأ معالجة النقرة أصلاً.

ولقياس أثر هذه المهام على المستخدم يوجد اليوم مقياس اسمه زمن التفاعل حتى الرسم التالي Interaction to Next Paint، واختصاراً INP، وقد أصبح أحد مقاييس Core Web Vitals بدلاً من FID في 12 مارس 2024 كما أعلن [فريق Chrome في web.dev](https://web.dev/blog/inp-cwv-march-12?ref=arabroot.io). ويقيس INP الزمن من لحظة تفاعل المستخدم مع الصفحة بالنقر أو اللمس أو الكتابة حتى يرسم المتصفح الإطار التالي الذي يظهر نتيجة هذا التفاعل، ويأخذ تقريباً أسوأ تفاعل في زيارة الصفحة. وحسب [صفحة INP في web.dev](https://web.dev/articles/inp?ref=arabroot.io) فإن القيمة 200 ms أو أقل جيدة، وما فوق 500 ms ضعيف، وما بينهما يحتاج تحسيناً.

وأهم ما في INP أنه يقسم زمن التفاعل إلى ثلاثة أجزاء، وكل جزء له سبب مختلف وعلاج مختلف:

1. **تأخير الإدخال Input Delay:** من لحظة النقرة حتى يبدأ أول معالج للحدث، وسببه غالباً مهمة أخرى كانت تعمل عندما نقر المستخدم، مثل الفهرسة في مثالنا، فالنقرة تنتظر في الطابور.
2. **زمن المعالجة Processing:** زمن تنفيذ معالجات الحدث نفسها، وسببه كود ثقيل داخل المعالج.
3. **تأخير العرض Presentation Delay:** من انتهاء المعالجات حتى يظهر الإطار، ويشمل حساب الأنماط والتخطيط والرسم لما غيرته المعالجات.

ويقيس المتصفح هذه الأجزاء بنفسه عبر واجهة Event Timing، فكل حدث تزيد مدته على حد معين يظهر كمدخل من النوع [PerformanceEventTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEventTiming?ref=arabroot.io) فيه `startTime` وهو لحظة الحدث، و`processingStart` و`processingEnd` حول المعالجات، و`duration` حتى الرسم التالي. ويذكر MDN أن الحد الافتراضي 104 ms وأن أقل حد يمكنك طلبه هو 16 ms، ويذكر [معيار Event Timing](https://w3c.github.io/event-timing/?ref=arabroot.io) أن `duration` تقرب إلى مضاعفات 8 ms لأسباب أمنية، ولذلك سوف ترى في التجربة أرقاماً مثل 16 و432 بالضبط.

والقاعدة التي تجمع هذا القسم: كل مهمة تزيد على 50 ms في صفحتك هي تأخير محتمل لنقرة مستخدم، سواء كانت مهمتك أنت أو سكربت تحليلات أو إعلانات أضيف إلى الصفحة، فالخيط واحد والطابور واحد للجميع.

💡

في حالة موثقة نشرها فريق Chrome، وجدت شركة redBus لحجز الحافلات أن INP في صفحة البحث عندها ضعيف مع أنها كانت تتوقع العكس، فقد كان تغيير حقول البحث يستدعي دالة Reducer ثقيلة تعيد رسم الواجهة مع كل حرف. فنقلت حالة الحقل إلى المكون نفسه ولم تحدث الـ Store إلا عند مغادرة الحقل، فتحسن INP في هذا التفاعل بنسبة 72%، وتقول الشركة إن مبيعاتها زادت 7% بعد مجموعة تحسينات INP، والتفاصيل في [دراسة الحالة على web.dev](https://web.dev/case-studies/redbus-inp?ref=arabroot.io).

## التجربة الثانية: نقرة تنتظر خلف مهمة فهرسة

الآن سوف نبني صفحة المنتجات نفسها بشكل مصغر، ونقيس ما يحدث للنقرة. في الصفحة `longtask.html` 120,000 منتج بأسماء تجريبية، وزر «أضف إلى السلة»، وكود يبني فهرساً للبحث، والفهرسة عمل حقيقي وليس انتظاراً مصطنعاً: لكل منتج نوحد شكل الاسم بـ `normalize` ونحوله إلى أحرف صغيرة ونقسمه إلى كلمات، ثم نضيف رقم المنتج إلى قائمة كل كلمة في `Map`. والجزء المهم من الصفحة كما يلي:

```javascript
// longtask.html?mode=block|chunk|handler   (the full file is in lab/longtask.html)
const index = new Map();
function indexOne(name, id) {           // the real work: normalise the name, split it, add each token to the index
  for (const tok of name.normalize('NFKD').toLowerCase().split(/[\s-]+/)) {
    let ids = index.get(tok); if (!ids) index.set(tok, ids = []); ids.push(id);
  }
}
const useTimeout = new URLSearchParams(location.search).get('yield') === 'timeout';
const yieldToMain = () => (globalThis.scheduler?.yield && !useTimeout ? scheduler.yield() : new Promise(r => setTimeout(r, 0)));

async function runJob() {
  jobStart = performance.now(); document.getElementById('status').textContent = 'تعمل';
  if (mode === 'chunk') {
    let deadline = performance.now() + 10;
    for (let i = 0; i < N; i++) {
      indexOne(products[i], i);
      if (performance.now() >= deadline) { slices++; await yieldToMain(); deadline = performance.now() + 10; }
    }
    slices++;
  } else {
    for (let i = 0; i < N; i++) indexOne(products[i], i);
    slices = 1;
  }
  jobEnd = performance.now(); document.getElementById('status').textContent = 'انتهت';
}

document.getElementById('add').addEventListener('click', () => {
  const p0 = performance.now();
  if (mode === 'handler') { for (let i = 0; i < N; i++) indexOne(products[i], i); }
  document.getElementById('cart').textContent = `السلة: ${++cart}`;
  clickAt = { handlerStart: p0, handlerEnd: performance.now() };
});

// what the browser itself measured
new PerformanceObserver(l => { for (const e of l.getEntries()) if (['pointerdown', 'pointerup', 'click', 'mousedown', 'mouseup'].includes(e.name))
  result.events.push({ name: e.name, startTime: +e.startTime.toFixed(1), inputDelay: +(e.processingStart - e.startTime).toFixed(1),
    processing: +(e.processingEnd - e.processingStart).toFixed(1), duration: e.duration, interactionId: e.interactionId }); })
  .observe({ type: 'event', durationThreshold: 16, buffered: true });
new PerformanceObserver(l => { for (const e of l.getEntries()) result.longtasks.push({ startTime: +e.startTime.toFixed(1), duration: +e.duration.toFixed(1) }); })
  .observe({ type: 'longtask', buffered: true });
let lastFrame = performance.now();
(function frame(t) { result.frames.push(+(t - lastFrame).toFixed(1)); lastFrame = t; requestAnimationFrame(frame); })(performance.now());
```

في الكود أعلاه لاحظ التالي:

- للصفحة ثلاثة أوضاع: `block` تبني الفهرس في مهمة واحدة، و`chunk` تبنيه في أجزاء مدة كل منها قرابة 10 ms ثم تعطي الخيط للمتصفح، و`handler` لا تبني الفهرس في الخلفية، وإنما يبنيه معالج النقرة نفسه قبل تحديث السلة.
- الدالة `yieldToMain` تستخدم `scheduler.yield()` إذا كانت موجودة في المتصفح، وإلا تعود إلى وعد يحل في `setTimeout`، والمعامل `yield=timeout` في الرابط يجبرها على `setTimeout` لنقارن الطريقتين.
- التقسيم بالوقت وليس بعدد المنتجات، أي «اعمل حتى تمر 10 ms ثم توقف»، والسبب أن نفس عدد المنتجات يستغرق وقتاً مختلفاً على هاتف ضعيف وعلى حاسوب سريع، والوقت هو ما يشعر به المستخدم.
- ثلاثة مراقبين: Event Timing للنقرة، وLong Tasks للمهام فوق 50 ms، وحلقة `requestAnimationFrame` تسجل الزمن بين كل إطارين، فإذا تجمدت الشاشة ظهرت فجوة كبيرة.

والسكربت `run.mjs` يفتح الصفحة في كل وضع، ويبدأ الفهرسة، وبعد 60 ms يرسل نقرة حقيقية على الزر عبر الأمر [Input.dispatchMouseEvent](https://chromedevtools.github.io/devtools-protocol/tot/Input/?ref=arabroot.io#method-dispatchMouseEvent)، أي نفس مسار الإدخال الذي تمر به نقرة الفأرة، ويسجل في نفس الوقت تتبع أداء Performance Trace عبر [نطاق Tracing](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/?ref=arabroot.io) في البروتوكول، وهو نفس التتبع الذي تراه في لوح Performance في DevTools. والجزء الخاص بالنقرة كما يلي:

```javascript
    // read the button position BEFORE the job: Runtime.evaluate also runs on the page's main thread and would wait for the long task
    const box = await b.evaluate(`(() => { const r = document.getElementById('add').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
    await b.evaluate('startJob()');
    await sleep(60);                                                  // the user clicks 60 ms after the job started
    const sent = Date.now();
    for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) await b.send('Input.dispatchMouseEvent', { type, x: box.x, y: box.y, button: 'left', clickCount: 1, timestamp: Date.now() / 1000 });
```

والمخرج للوضع `block` سوف يكون كما يلي:

```text
== longtask.html?mode=block  (n=120000, yield via scheduler.yield)
job: 362.9 ms in 1 slice(s); longest task 363 ms; long tasks seen: 2; max frame gap 350 ms
pointerdown inputDelay 2.5 ms, processing 0.1 ms, duration 16 ms, interactionId 7329
click       inputDelay 2.3 ms, processing 1.6 ms, duration 16 ms, interactionId 7329
page clock: job 1295.7 -> 1658.6 ms; click handler ran at 1665.8 ms; frame gaps over 50 ms: 350
CDP mouse events acknowledged after 299 ms; from sending the click to the handler starting: 298.7 ms
trace: 1 main-thread tasks >= 50 ms: 363.2 ms; tasks 1-50 ms: 3; EventDispatch: mousedown 0 ms, click 1.5 ms
```

وهذا المخرج يحتاج قراءة سطراً سطراً:

- **السطر `job`:** بناء الفهرس استغرق 362.9 ms في مهمة واحدة، وواجهة Long Tasks رأتها مهمة طويلة بطول 363 ms، وأطول فجوة بين إطارين 350 ms، أي أن الشاشة تجمدت ثلث ثانية لم يرسم فيها المتصفح أي إطار. أما `long tasks seen: 2` فالمهمة الثانية من تحميل الصفحة نفسها قبل أن نبدأ.
- **سطرا `pointerdown` و`click`:** هذا ما سجلته Event Timing، وفيه تأخير إدخال 2.5 ms فقط. وهذا الرقم غير صحيح لما حدث فعلاً، وسوف نشرح السبب بعد قليل.
- **السطر `page clock`:** بدأت المهمة عند 1295.7 ms من ساعة الصفحة وانتهت عند 1658.6 ms، وبدأ معالج النقرة عند 1665.8 ms، أي بعد انتهاء المهمة بسبعة ملي ثانية، مع أن النقرة أرسلت بعد بدايتها بقرابة 71 ms.
- **السطر `CDP`:** قسنا الزمن من خارج الصفحة، من لحظة إرسال النقرة في Node.js حتى بدأ المعالج، بنفس ساعة الجهاز، فكان 298.7 ms. وهذا هو تأخير الإدخال الحقيقي الذي شعر به «المستخدم».
- **السطر `trace`:** التتبع من DevTools رأى مهمة واحدة فوق 50 ms على الخيط الرئيسي بطول 363.2 ms، وهي الفهرسة، ورأى أن إرسال حدث `click` نفسه استغرق 1.5 ms فقط، أي أن المعالج سريع، والمشكلة كلها في الانتظار قبله.

![لقطة شاشة من Chrome لصفحة المنتجات في الوضع block فيها 120,000 منتج وحالة الفهرسة انتهت وزر أضف إلى السلة والسلة 1، وتحتها جدول القياسات: المهمة 362.9 ms في مهمة واحدة، وأطول مهمة 363 ms، وأطول فجوة بين إطارين 350 ms، ومن إرسال النقرة حتى بدء المعالج 298.7 ms، وقيم Event Timing بالأخضر: inputDelay 2.5 ms وprocessing 0.1 ms وduration 16 ms](https://arabroot.io/content/images/2026/10/js-event-loop-07-lab-block.webp)

لقطة حقيقية من Chrome 156 بعد التجربة: النقرة انتظرت 298.7 ms خلف مهمة الفهرسة

### مشكلتان واجهناهما في القياس

المشكلة الأولى ظهرت في أول تشغيل للتجربة، فقد كان السكربت يبدأ الفهرسة، ثم ينتظر 60 ms، ثم يقرأ موقع الزر بالأمر `Runtime.evaluate`، ثم يرسل النقرة. والنتيجة أن معالج النقرة بدأ بعد نهاية الفهرسة في كل مرة تقريباً، وأن الصفحة بدت سريعة. والسبب أن `Runtime.evaluate` ينفذ كوداً داخل الصفحة، والكود داخل الصفحة يحتاج الخيط الرئيسي، فانتظر أمر القراءة نفسه حتى انتهت الفهرسة، وبالتالي أرسلت النقرة بعد أن فرغ الخيط. والحل كان أن نقرأ موقع الزر قبل أن نبدأ الفهرسة، كما ترى في التعليق في الكود أعلاه. والدرس هنا أوسع من التجربة، فأي أداة تقيس صفحة عبر تنفيذ كود داخلها، سواء كانت أداة اختبار أو سكربت مراقبة، تعيش هي أيضاً في نفس الحلقة، وقد تقيس الصفحة بعد أن تنتهي المشكلة وليس أثناءها.

والمشكلة الثانية أن Event Timing سجلت تأخير إدخال بين 2 و20 ms تقريباً في التشغيلات المختلفة للوضع `block`، بينما القياس من الخارج كان قرابة 300 ms، بل إن أحد التشغيلات لم يسجل أي مدخل للنقرة أصلاً لأن المدة المحسوبة كانت أقل من حد 16 ms. وعندما مررنا وقت الإرسال في المعامل `timestamp` مع الأمر لم يتغير شيء. والذي نستنتجه من الأرقام أن الحدث المرسل عبر البروتوكول يأخذ وقته عندما يصل إلى الصفحة وليس عندما أرسلناه، فلا ترى Event Timing الانتظار الذي حدث قبل ذلك. أما النقرة الحقيقية من فأرة المستخدم فيأخذ وقتها من نظام التشغيل، ولذلك تظهر في INP عند المستخدمين الحقيقيين. والقاعدة العملية: في اختبارات المختبر الآلية قس الزمن من خارج الصفحة كما فعلنا، وفي الإنتاج اعتمد على قياس المستخدمين الحقيقيين عبر مكتبة [web-vitals](https://github.com/GoogleChrome/web-vitals?ref=arabroot.io).

### ماذا يحدث إذا وضعنا العمل داخل معالج النقرة Event Handler نفسه؟

في الوضع `handler` لا توجد مهمة في الخلفية، فعندما ينقر المستخدم يبدأ المعالج فوراً، ولكنه يبني الفهرس كاملاً قبل أن يحدث السلة، وهذا يحدث كثيراً في الواقع عندما يقرر المطور أن «يجهز البيانات عند أول نقرة». والمخرج كما يلي:

```text
== longtask.html?mode=handler  (n=120000, yield via scheduler.yield)
job: 0 ms in 0 slice(s); longest task 422 ms; long tasks seen: 1; max frame gap 416.7 ms
pointerdown inputDelay 2.6 ms, processing 0 ms, duration 432 ms, interactionId 6862
click       inputDelay 4 ms, processing 421.8 ms, duration 432 ms, interactionId 6862
page clock: job 0 -> 0 ms; click handler ran at 1360.9 ms; frame gaps over 50 ms: 416.7
CDP mouse events acknowledged after 464 ms; from sending the click to the handler starting: 32.2 ms
trace: 1 main-thread tasks >= 50 ms: 422.4 ms; tasks 1-50 ms: 1; EventDispatch: mousedown 0 ms, click 421.8 ms
```

في المخرج أعلاه لاحظ التالي:

- الآن تأخير الإدخال صغير، 4 ms في Event Timing و32.2 ms من الخارج، لأن الخيط كان فارغاً عندما نقر المستخدم.
- ولكن زمن المعالجة `processing` أصبح 421.8 ms، ومدة التفاعل كلها `duration` 432 ms، وهي من مضاعفات 8 كما ذكرنا، وهذه القيمة تقع في منطقة «يحتاج تحسيناً» في INP وقريبة من حد «ضعيف».
- التتبع رأى نفس الشيء من زاوية أخرى: إرسال الحدث `click` وحده استغرق 421.8 ms، والمهمة الطويلة 422.4 ms هي النقرة نفسها.
- هنا لم تخدعنا Event Timing، لأن المشكلة في المعالجة وليست في الانتظار قبل الحدث، فالمقياس يرى ما يحدث داخل المعالج بدقة.

والنتيجة النهائية للمستخدم واحدة في الحالتين: نقر الزر ولم يتغير رقم السلة إلا بعد ثلث ثانية أو أكثر، ولكن العلاج مختلف، ففي الحالة الأولى تقسم المهمة التي في الخلفية، وفي الثانية تخرج العمل الثقيل من المعالج، فتحدث السلة أولاً ثم تبني الفهرس بعد أن يرى المستخدم النتيجة. والصورة التالية تبين الحالتين على محور زمني واحد بأرقام هذا التشغيل:

![محوران زمنيان من 0 إلى 480 ms: في الأعلى شريط برتقالي لبناء الفهرس 362.9 ms في مهمة واحدة، والنقرة أرسلت بعد 71 ms وسهم أحمر يبين أنها انتظرت 298.7 ms حتى بدأ المعالج بعد نهاية المهمة، وفي الأسفل شريط للسيناريو الثاني مقسم إلى تأخير الإدخال 4 ms ثم المعالجة 421.8 ms ثم تأخير العرض، والمدة كلها 432 ms](https://arabroot.io/content/images/2026/10/js-event-loop-03-long-task.webp)

نفس العمل يؤخر النقرة بطريقتين: كتأخير إدخال إذا كان قبلها، وكزمن معالجة إذا كان داخلها

![لقطة شاشة من Chrome لصفحة المنتجات في الوضع handler وحالة الفهرسة لم تبدأ والسلة 1، وجدول القياسات: أطول مهمة 422 ms، وأطول فجوة بين إطارين 416.7 ms، ومن إرسال النقرة حتى بدء المعالج 32.2 ms، وEvent Timing فيه inputDelay 4 ms وprocessing 421.8 ms وduration 432 ms](https://arabroot.io/content/images/2026/10/js-event-loop-08-lab-handler.webp)

الوضع handler في Chrome: الانتظار قبل المعالج صغير، ولكن المعالجة نفسها 421.8 ms

## كيف تقسم العمل الطويل؟

إذا كان عندك عمل يحتاج 400 ms من المعالج فلا توجد طريقة لتجعله يحتاج أقل بمجرد إعادة ترتيبه، فالعمل هو العمل. والذي تستطيع فعله هو أن تقرر متى يعمل وعلى أي خيط، وهنا عدة طرق، وكل واحدة تحل جزءاً من المشكلة وتترك جزءاً، كما يلي:

### الطريقة البسيطة: setTimeout بين الأجزاء

تقسم العمل إلى أجزاء، وبعد كل جزء تنتظر وعداً يحل في `setTimeout(r, 0)`، فيصبح باقي العمل مهمة جديدة في آخر الطابور، وبين المهمتين يحصل المتصفح على فرصة لمعالجة النقرة ورسم إطار. وهذه الطريقة تعمل في كل المتصفحات منذ زمن بعيد، ولكن لها عيبان. الأول أن باقي عملك يذهب إلى آخر الطابور، فإذا كانت هناك سكربتات أخرى في الصفحة تضع مهامها في الطابور فسوف تعمل قبل أن يكمل عملك، فيطول زمن عملك الكلي. والثاني هو الحد الأدنى 4 ms الذي قسناه، فالسلسلة المتداخلة من المؤقتات تدفعه مع كل جزء بعد الخامس.

### scheduler.yield(): أعط الدور ثم عد إلى مكانك

الدالة [scheduler.yield()](https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield?ref=arabroot.io) جزء من [واجهة جدولة المهام بالأولوية](https://developer.mozilla.org/en-US/docs/Web/API/Prioritized%5FTask%5FScheduling%5FAPI?ref=arabroot.io)، وهي تعيد وعداً تكمل بعده عملك، والفرق عن `setTimeout` كما يشرح [مقال فريق Chrome](https://developer.chrome.com/blog/use-scheduler-yield?ref=arabroot.io) أن باقي العمل يجدول قبل المهام الجديدة الأخرى من نفس الأولوية، فأنت تعطي المتصفح فرصة ليستجيب للمستخدم، ثم تعود إلى مكانك قبل أن تبدأ سكربتات أخرى عملها. ولاحظ أنها غير موجودة في كل المتصفحات، فتحقق من جدول التوافق في صفحة MDN، ولهذا استخدمنا في التجربة الفحص `globalThis.scheduler?.yield` مع `setTimeout` كبديل.

### ماذا قالت الأرقام؟

في التشغيل الواحد للتجربة كانت النتائج كما يلي للوضعين المقسمين:

```text
== longtask.html?mode=chunk  (n=120000, yield via scheduler.yield)
job: 279.7 ms in 27 slice(s); longest task 0 ms; long tasks seen: 0; max frame gap 66.6 ms
pointerdown inputDelay 12.7 ms, processing 0 ms, duration 16 ms, interactionId 4104
click       inputDelay 11.6 ms, processing 0.5 ms, duration 16 ms, interactionId 4104
page clock: job 1196.7 -> 1476.4 ms; click handler ran at 1303.2 ms; frame gaps over 50 ms: 66.6, 66.6
CDP mouse events acknowledged after 31 ms; from sending the click to the handler starting: 30.9 ms
trace: 0 main-thread tasks >= 50 ms: none; tasks 1-50 ms: 32; EventDispatch: mousedown 0 ms, click 0.5 ms

== longtask.html?mode=chunk  (n=120000, yield via setTimeout)
job: 522.9 ms in 36 slice(s); longest task 0 ms; long tasks seen: 0; max frame gap 16.8 ms
pointerdown inputDelay 2.4 ms, processing 0 ms, duration 16 ms, interactionId 1080
page clock: job 1172.1 -> 1695 ms; click handler ran at 1266.9 ms; frame gaps over 50 ms: none
CDP mouse events acknowledged after 22 ms; from sending the click to the handler starting: 21.6 ms
trace: 0 main-thread tasks >= 50 ms: none; tasks 1-50 ms: 40; EventDispatch: mousedown 0 ms, click 0.5 ms
```

في المخرج أعلاه لاحظ التالي:

- في الطريقتين اختفت المهام الطويلة تماماً، `long tasks seen: 0`، والتتبع لم ير أي مهمة فوق 50 ms، وإنما 32 و40 مهمة صغيرة.
- بدأ معالج النقرة أثناء الفهرسة وليس بعدها: عند 1303.2 ms والفهرسة تعمل من 1196.7 إلى 1476.4 في الأولى، وعند 1266.9 ms في الثانية، فانخفض الانتظار من 298.7 ms إلى 30.9 ms و21.6 ms.
- الفهرسة مع `scheduler.yield()` انتهت في 279.7 ms، وهذا أسرع حتى من المهمة الواحدة في هذا التشغيل، ومع `setTimeout` أخذت 522.9 ms، والفرق قرابة 36 جزءاً يدفع كل منها حد 4 ms وانتظار دوره في آخر الطابور.
- والمفاجأة في `max frame gap`: مع `setTimeout` لم تتجاوز أي فجوة بين إطارين 16.8 ms، أي إطار كل 1/60 ثانية بالضبط، ومع `scheduler.yield()` ظهرت فجوات 66.6 ms، أي أن الشاشة رسمت إطارات أقل أثناء الفهرسة.

وعندما كررنا التشغيل لاحظنا أن الأرقام تتغير كثيراً من مرة لأخرى، ففي أحد التشغيلات انتظرت النقرة مع `scheduler.yield()` 309.5 ms، وهذا أسوأ من بعض تشغيلات المهمة الواحدة، والسبب أن الجهاز كان يشغل أعمالاً أخرى في نفس الوقت. وتشغيل واحد على جهاز مشغول قد يخدعك في أي اتجاه، لذلك كتبنا السكربت `bench.mjs` الذي يكرر تجربة النقرة 15 مرة لكل طريقة ويطبع القيمة الوسيطة Median وأصغر قيمة وأكبرها:

```bash
node content/articles/js-event-loop/lab/bench.mjs 15
```

```text
block                (15 runs)  click wait: median 244.9, min 178.2, max 392.8 ms | job: median 304.6, min 248.3, max 395.0 ms | longest frame gap: median 300.0, min 233.4, max 383.4 ms
chunk                (15 runs)  click wait: median 34.8, min 20.3, max 77.6 ms | job: median 343.1, min 227.4, max 515.4 ms | longest frame gap: median 83.3, min 50.0, max 100.1 ms
chunk&yield=timeout  (15 runs)  click wait: median 20.4, min 7.9, max 118.9 ms | job: median 423.8, min 332.7, max 573.2 ms | longest frame gap: median 16.8, min 16.7, max 16.8 ms
```

وهذه الأرقام أوضح من أي تشغيل واحد:

- **انتظار النقرة:** القيمة الوسيطة 244.9 ms في المهمة الواحدة، و34.8 ms مع `scheduler.yield()`، و20.4 ms مع `setTimeout`، أي أن أي تقسيم خفض الانتظار سبع مرات أو أكثر، والفرق بين طريقتي التقسيم صغير مقارنة بالفرق بينهما وبين المهمة الواحدة.
- **زمن العمل كله:** 343.1 ms مع `scheduler.yield()` مقابل 423.8 ms مع `setTimeout`، فالعودة السريعة إلى العمل وفرت قرابة 80 ms في الوسيط، وهذه هي الميزة التي صممت من أجلها.
- **الإطارات:** مع `setTimeout` كانت أطول فجوة 16.8 ms في كل التشغيلات تقريباً، ومع `scheduler.yield()` كانت بين 50 و100.1 ms. وهذا منطقي مع طريقة عمل `scheduler.yield()`، فبما أن باقي العمل يجدول قبل المهام الأخرى فإن المتصفح يعود إلى الفهرسة بسرعة ويؤجل بعض الإطارات، ويبدو أن Chrome في تجربتنا كان يرسم إطاراً كل 66 إلى 100 ms أثناء العمل. فإذا كانت الصفحة تعرض حركة مستمرة أثناء العمل، مثل شريط تقدم متحرك، فسوف تلاحظ هذا الفرق.

![ثلاث لوحات أعمدة للقيمة الوسيطة من 15 تشغيلاً: انتظار النقرة 244.9 ms للمهمة الواحدة و34.8 ms مع scheduler.yield و20.4 ms مع setTimeout، وأطول فجوة بين إطارين 300 و83.3 و16.8 ms، وزمن المهمة كلها 304.6 و343.1 و423.8 ms](https://arabroot.io/content/images/2026/10/js-event-loop-04-split.webp)

التقسيم بأي طريقة يخفض انتظار النقرة، وscheduler.yield ينهي العمل أسرع، وsetTimeout يحافظ على الإطارات

والخلاصة من هذه الأرقام أن السؤال ليس «أي الطريقتين أفضل؟» وإنما «ما الذي يهمك في هذه الصفحة؟»، فإذا كان عملك هو الأهم ويجب أن ينتهي بسرعة مع بقاء النقرات سريعة فاستخدم `scheduler.yield()`، وإذا كانت الحركة على الشاشة أثناء العمل مهمة فقد يكون `setTimeout` أنسب، أو اجمع بينهما بأن تنتظر `requestAnimationFrame` ثم `setTimeout` قبل الأجزاء التي تريد أن ترى بعدها إطاراً. وإليك اللقطتين من Chrome للوضعين المقسمين:

![لقطة شاشة من Chrome لصفحة المنتجات في الوضع chunk مع scheduler.yield: المهمة 279.7 ms في 27 مهمة، وأطول مهمة 0 ms، وأطول فجوة بين إطارين 66.6 ms، ومن إرسال النقرة حتى بدء المعالج 30.9 ms، وEvent Timing فيه inputDelay 11.6 ms وprocessing 0.5 ms وduration 16 ms](https://arabroot.io/content/images/2026/10/js-event-loop-09-lab-chunk.webp)

الوضع chunk مع scheduler.yield: لا توجد مهام طويلة، والنقرة انتظرت 30.9 ms

![لقطة شاشة من Chrome لصفحة المنتجات في الوضع chunk مع setTimeout: المهمة 522.9 ms في 36 مهمة، وأطول مهمة 0 ms، وأطول فجوة بين إطارين 16.8 ms، ومن إرسال النقرة حتى بدء المعالج 21.6 ms](https://arabroot.io/content/images/2026/10/js-event-loop-10-lab-chunk-timeout.webp)

الوضع chunk مع setTimeout: الإطارات منتظمة كل 16.8 ms، ولكن العمل كله أخذ 522.9 ms

### طرق أخرى ومتى تختارها

بالإضافة إلى الطريقتين السابقتين هناك ثلاث أدوات أخرى ستراها في الكود الحقيقي:

- [**requestIdleCallback**](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback?ref=arabroot.io)**:** ينفذ ردك عندما يكون الخيط فارغاً، ويعطيك مهلة تعرف منها كم بقي لك من الوقت. وهو مناسب للعمل الذي لا يستعجله أحد، مثل إرسال بيانات التحليلات أو تجهيز بيانات قد يحتاجها المستخدم لاحقاً، ولكنه قد لا يعمل أبداً إذا كانت الصفحة مشغولة دائماً، فلا تضع فيه شيئاً يجب أن يحدث.
- **الـ Web Workers:** إذا كان العمل حساباً خالصاً لا يحتاج الـ DOM، مثل فهرسة 120,000 منتج أو تحويل ملف كبير إلى Base64 كما شرحنا في [مقال الترميز](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B1%D9%85%D9%8A%D8%B2-base64-%D9%88-base32-%D9%88-base58/)، فمكانه الصحيح خيط آخر، فلا يأخذ من الخيط الرئيسي شيئاً إلا نقل النتيجة. وثمن ذلك أن البيانات تنسخ بين الخيطين في الرسائل، فإذا كانت النتيجة ضخمة فقد يصبح النسخ نفسه مهمة طويلة، ولذلك استخدم الكائنات القابلة للنقل Transferable مثل `ArrayBuffer` عندما تستطيع.
- **تقليل العمل نفسه:** وهو ما فعلته redBus، فأحياناً لا تحتاج أن تقسم العمل وإنما أن تتوقف عن تكراره، مثل إعادة رسم القائمة كلها مع كل حرف بدلاً من انتظار توقف المستخدم عن الكتابة.

وللوصول إلى سبب بطء تفاعل معين في الإنتاج يقدم Chrome واجهة [Long Animation Frames](https://developer.chrome.com/docs/web-platform/long-animation-frames?ref=arabroot.io)، واختصاراً LoAF، وهي تخبرك بالإطارات التي تأخرت وبالسكربتات التي عملت خلالها، أي أنها تجيب عن سؤال «من الذي حجز الخيط؟» الذي لا تجيب عنه Long Tasks. وقد استخدمتها شركة Taboola لتعرف أي سكربتاتها يؤخر تفاعلات صفحات الناشرين، ثم قسمت عملها بحيث يعطي الخيط للمتصفح بانتظام، فانخفض INP عند أحد الناشرين من 75 إلى 48 ms، كما في [دراسة الحالة على web.dev](https://web.dev/case-studies/taboola-inp?ref=arabroot.io). ولمن يريد قائمة كاملة بأساليب التحسين فإن [دليل تحسين المهام الطويلة](https://web.dev/articles/optimize-long-tasks?ref=arabroot.io) و[دليل تحسين INP](https://web.dev/articles/optimize-inp?ref=arabroot.io) من فريق Chrome هما المرجعان الأساسيان.

⚠️

المستخدم الذي يعتمد على قارئ الشاشة Screen Reader أو على لوحة المفاتيح يتأثر بالمهام الطويلة أكثر من غيره، فالتركيز Focus لا يتحرك وإعلانات المناطق الحية لا تقرأ حتى يفرغ الخيط، فإذا كنت تعمل على [سهولة الوصول](https://arabroot.io/articles/%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D9%88%D9%82%D8%B9%D8%A7-%D9%8A%D8%B3%D8%AA%D8%AE%D8%AF%D9%85%D9%87-%D8%A7%D9%84%D8%AC%D9%85%D9%8A%D8%B9/) في موقعك فاعتبر المهام الطويلة جزءاً منها.

## حلقة الأحداث Event Loop في Node.js

حتى الآن تكلمنا عن المتصفح، وفي Node.js نفس الفكرة الأساسية: خيط واحد ينفذ كودك، وطوابير، وحلقة. ولكن التفاصيل مختلفة، فلا توجد خطوة رسم ولا `requestAnimationFrame`، والحلقة نفسها من مكتبة libuv، وهي مقسمة إلى مراحل Phases بترتيب ثابت كما يشرح [دليل حلقة الأحداث في Node.js](https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick?ref=arabroot.io):

1. **timers:** ردود `setTimeout` و`setInterval` التي حان وقتها.
2. **pending callbacks:** ردود بعض عمليات النظام المؤجلة من الدورة السابقة، مثل بعض أخطاء TCP.
3. **poll:** انتظار أحداث الإدخال والإخراج I/O وتنفيذ ردودها، مثل وصول بيانات من الشبكة أو انتهاء قراءة ملف، وهنا ينتظر Node إذا لم يكن هناك عمل آخر.
4. **check:** ردود `setImmediate`.
5. **close callbacks:** أحداث الإغلاق مثل `socket.on('close')`.

وبعد كل رد في أي مرحلة ينفذ Node طابور `process.nextTick` ثم طابور الوعود، وهذا يعادل نقطة فحص المهام الصغيرة في المتصفح. وقد رأينا في [مقال الخيوط](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/#event-loop) أن ترتيب `nextTick` مقابل الوعود يختلف بين ملف CommonJS وملف ES Module، ولن نعيد ذلك هنا، وإنما سوف نختبر المراحل نفسها.

![خمس مراحل لحلقة Node.js مرتبة من الأعلى: timers ثم pending callbacks ثم poll ثم check ثم close callbacks، وبين كل مرحلتين صندوق متقطع أخضر يقول nextTick ثم Microtasks، وبجانبها ثلاثة صناديق: داخل رد قراءة ملف يسبق setImmediate المؤقت مع المخرج الحقيقي، ومن الملف الرئيسي 39 مرة immediate أولاً و11 مرة timeout أولاً في 50 تشغيلاً، ولا يوجد رسم في Node](https://arabroot.io/content/images/2026/10/js-event-loop-05-node-phases.webp)

في Node.js تأتي check بعد poll مباشرة، فيسبق setImmediate المؤقت داخل ردود الإدخال والإخراج

### التجربة الثالثة: المراحل Phases والتجويع Starvation وتأخير الحلقة

الملف التالي يختبر أربعة أشياء بالترتيب، ولا يستخدم إلا مكتبات Node.js القياسية. احفظه باسم `node-loop.mjs`:

```javascript
// node-loop.mjs: the Node.js side of the lab (standard library only).
//   docker run --rm --name sfe-eventloop -v "$PWD:/lab" -w /lab node:22-alpine node node-loop.mjs
import { readFile } from 'node:fs';
import { monitorEventLoopDelay } from 'node:perf_hooks';

const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const t0 = performance.now();
const at = () => `${(performance.now() - t0).toFixed(1).padStart(6)} ms`;
console.log(`Node.js ${process.version} on ${process.platform}/${process.arch}`);

// 1) inside an I/O callback (poll phase): setImmediate (check phase) always comes before setTimeout 0 (next timers phase)
await new Promise(done => readFile(new URL(import.meta.url), () => {
  console.log('\n== 1) inside an fs.readFile callback');
  setTimeout(() => { console.log('timers phase: setTimeout 0'); done(); }, 0);
  setImmediate(() => {
    console.log('check phase: setImmediate');
    process.nextTick(() => console.log('  nextTick queued inside setImmediate'));
    Promise.resolve().then(() => console.log('  promise queued inside setImmediate'));
  });
  process.nextTick(() => console.log('nextTick (right after the callback)'));
  Promise.resolve().then(() => console.log('promise .then (right after nextTick)'));
}));

// 2) immediates queued together run in one check phase; one queued from inside them waits for the next turn of the loop
await sleep(20);
console.log('\n== 2) 3 setImmediate calls queued together, and one more queued from inside them');
await new Promise(done => {
  setImmediate(() => { console.log('immediate A'); setImmediate(() => { console.log('immediate D (queued by A: next loop turn)'); done(); }); });
  setImmediate(() => console.log('immediate B'));
  setImmediate(() => console.log('immediate C'));
  setTimeout(() => console.log('setTimeout 0 queued after the immediates'), 0);
});

// 3) microtask starvation: a promise chain that keeps queueing itself holds back a timer that was due long ago
await sleep(20);
console.log('\n== 3) a timer due in 0 ms vs. 2,000,000 chained microtasks');
await new Promise(done => {
  const start = performance.now();
  setTimeout(() => { console.log(`timer ran after ${(performance.now() - start).toFixed(1)} ms`); done(); }, 0);
  let n = 0;
  const step = () => { if (++n < 2_000_000) queueMicrotask(step); else console.log(`microtasks finished after ${(performance.now() - start).toFixed(1)} ms`); };
  queueMicrotask(step);
});
await sleep(20);
console.log('   the same 2,000,000 steps, but each one queued with setImmediate');
await new Promise(done => {
  const start = performance.now();
  let timerAt = null;
  setTimeout(() => { timerAt = performance.now() - start; console.log(`timer ran after ${timerAt.toFixed(1)} ms`); }, 0);
  let n = 0;
  const step = () => { if (++n < 2_000_000) setImmediate(step); else { console.log(`immediates finished after ${(performance.now() - start).toFixed(1)} ms`); done(); } };
  setImmediate(step);
});

// 4) event loop delay: one 300 ms synchronous job vs. the same work in slices of ~10 ms
function work(ms) { const end = performance.now() + ms; let x = 0; while (performance.now() < end) x += Math.sqrt(x + 1); return x; }
async function measure(label, job) {
  const h = monitorEventLoopDelay({ resolution: 10 });
  h.enable(); await sleep(100);
  const start = performance.now(); await job(); const took = performance.now() - start;
  await sleep(100); h.disable();
  const ms = (v) => (v / 1e6).toFixed(1);
  console.log(`${label.padEnd(24)} job ${took.toFixed(0)} ms | loop delay p50 ${ms(h.percentile(50))} ms, p99 ${ms(h.percentile(99))} ms, max ${ms(h.max)} ms`);
}
console.log('\n== 4) event loop delay (perf_hooks.monitorEventLoopDelay, resolution 10 ms)');
await measure('one 300 ms task', async () => work(300));
await measure('30 slices of 10 ms', async () => { for (let i = 0; i < 30; i++) { work(10); await new Promise(r => setImmediate(r)); } });
console.log(`\ndone at ${at()}`);
```

ومعه ملف صغير اسمه `race.mjs` يسجل مؤقتاً و`setImmediate` من الملف الرئيسي مباشرة:

```javascript
// race.mjs: setTimeout 0 vs setImmediate from the main module; the order depends on how fast the process starts
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
```

ونشغل الاثنين في Container من Docker Image الرسمي `node:22-alpine`، والملف `race.mjs` خمسين مرة ونعد أي السطرين ظهر أولاً، ثم يحذف Docker الـ Container تلقائياً بسبب الخيار `--rm`:

```bash
docker run --rm --name sfe-eventloop -v "$PWD:/lab" -w /lab node:22-alpine sh -c 'node node-loop.mjs; echo; echo "== race.mjs, 50 runs"; for i in $(seq 50); do node race.mjs | head -1; done | sort | uniq -c'
```

والمخرج سوف يكون كما يلي:

```text
Node.js v22.23.4 on linux/x64

== 1) inside an fs.readFile callback
nextTick (right after the callback)
promise .then (right after nextTick)
check phase: setImmediate
  nextTick queued inside setImmediate
  promise queued inside setImmediate
timers phase: setTimeout 0

== 2) 3 setImmediate calls queued together, and one more queued from inside them
immediate A
immediate B
immediate C
setTimeout 0 queued after the immediates
immediate D (queued by A: next loop turn)

== 3) a timer due in 0 ms vs. 2,000,000 chained microtasks
microtasks finished after 1103.6 ms
timer ran after 1104.1 ms
   the same 2,000,000 steps, but each one queued with setImmediate
timer ran after 0.9 ms
immediates finished after 7388.1 ms

== 4) event loop delay (perf_hooks.monitorEventLoopDelay, resolution 10 ms)
one 300 ms task          job 300 ms | loop delay p50 10.1 ms, p99 302.8 ms, max 302.8 ms
30 slices of 10 ms       job 304 ms | loop delay p50 10.1 ms, p99 29.7 ms, max 29.7 ms

done at 9587.7 ms

== race.mjs, 50 runs
     39 immediate
     11 timeout
```

وهذا المخرج يحتاج قراءة قسماً قسماً:

- **القسم 1:** داخل رد `readFile` نحن في مرحلة poll، فبعد الرد مباشرة عمل `nextTick` ثم الوعد، ثم انتقلت الحلقة إلى المرحلة التالية وهي check فعمل `setImmediate`، ثم دارت الحلقة إلى timers في الدورة التالية فعمل المؤقت. وهذا الترتيب مضمون في كل مرة داخل ردود الإدخال والإخراج. ولاحظ أن `nextTick` والوعد اللذين أضيفا داخل `setImmediate` نفذا بعده مباشرة وقبل المؤقت، لأن Node يفرغ الطابورين بعد كل رد.
- **القسم 2:** الثلاثة A وB وC نفذت معاً في نفس مرحلة check، أما D التي أضافتها A أثناء المرحلة فلم تنفذ في نفس المرحلة، وإنما في الدورة التالية بعد المؤقت. وهذا عكس طابور المهام الصغيرة تماماً، فطابور `setImmediate` يأخذ ما كان فيه عند بداية المرحلة فقط، ولذلك لا يمكن أن يجوع الحلقة.
- **القسم 3:** هذا هو التجويع بالأرقام، فمؤقت مدته صفر انتظر 1104.1 ms لأن سلسلة من مليوني مهمة صغيرة كانت تضيف نفسها إلى الطابور، ولم يخرج Node من الطابور حتى فرغ. ثم عندما نفذنا نفس الخطوات بـ `setImmediate` عمل المؤقت بعد 0.9 ms، ولكن الخطوات نفسها أخذت 7388.1 ms بدلاً من 1103.6 ms، لأن كل خطوة أصبحت تدور دورة كاملة في الحلقة. وهذا هو المقابل الدقيق لما رأيناه في المتصفح بين المهمة الواحدة والتقسيم: إعطاء الدور للآخرين يكلف وقتاً، فلا تعط الدور بعد كل خطوة صغيرة وإنما بعد كل جزء بحجم معقول.
- **القسم 4:** الدالة `monitorEventLoopDelay` من [وحدة perf\_hooks](https://nodejs.org/docs/latest-v22.x/api/perf%5Fhooks.html?ref=arabroot.io) تقيس كم تأخرت الحلقة عن موعدها، وهي في السيرفر ما يقابل الفجوة بين الإطارين في المتصفح. مع المهمة الواحدة كانت p99 وأكبر قيمة 302.8 ms، أي أن أي طلب HTTP وصل في هذه الفترة انتظر حتى 300 ms قبل أن يبدأ، ومع الأجزاء انخفضت إلى 29.7 ms مع نفس زمن العمل تقريباً، 304 ms مقابل 300 ms. والقيمة الوسيطة 10.1 ms في الحالتين هي دقة القياس التي اخترناها `resolution: 10`.
- **race.mjs:** من الملف الرئيسي ظهر `immediate` أولاً 39 مرة و`timeout` أولاً 11 مرة من 50، والسبب أن [توثيق المؤقتات](https://nodejs.org/docs/latest-v22.x/api/timers.html?ref=arabroot.io) يجعل المدة 0 تساوي 1 ms، فإذا بدأت الحلقة قبل أن يمر 1 ms لم يكن المؤقت جاهزاً في مرحلة timers فتسبقه check، وإذا تأخرت قليلاً كان جاهزاً فيسبق. فلا تعتمد على هذا الترتيب خارج ردود الإدخال والإخراج.

والدرس العملي للسيرفر واضح من القسم الرابع، فكل طلب يعالجه Node.js على نفس الخيط، ومعالج واحد يحسب شيئاً ثقيلاً 300 ms يؤخر كل الطلبات الأخرى التي وصلت في نفس الوقت، كما يشرح دليل [لا تحجز حلقة الأحداث](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop?ref=arabroot.io) في توثيق Node.js. ولهذا السبب تظهر المشكلة في اختبار الضغط Load Testing كما شرحنا في [مقال اختبار الضغط](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%AD%D9%85%D8%A7%D9%84-%D9%88%D8%A7%D9%84%D8%B6%D8%BA%D8%B7-load-%D9%88-stress-testing-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) قبل أن تظهر في اختبار طلب واحد، وإذا كنت تراقب سيرفرات Node.js فأضف تأخير الحلقة إلى مقاييسك بجانب زمن الاستجابة.

💡

قد تقرأ أن `process.nextTick` «أسرع» من الوعد فتستخدمه في كل مكان، ولكن [توثيق process](https://nodejs.org/docs/latest-v22.x/api/process.html?ref=arabroot.io) نفسه ينصح باستخدام `queueMicrotask()` في أغلب الحالات، لأن `nextTick` طابور خاص بـ Node.js، وتكراره بلا نهاية يجوع الحلقة كما رأينا مع الوعود تماماً.

## أخطاء شائعة

1. **اعتبار `async` طريقة لتشغيل الكود في الخلفية:** الدالة `async` تعمل على نفس الخيط، وحلقة `for` ثقيلة داخلها تحجز الخيط تماماً كما لو كانت دالة عادية، و`await` لا يعطي الدور للمتصفح إلا إذا كان ما تنتظره ينتهي في مهمة أخرى. فـ `await` على وعد محلول مسبقاً لا يسمح بالرسم ولا بالنقرة.
2. **تقسيم العمل بـ `Promise.resolve().then` أو `queueMicrotask`:** هذه مهام صغيرة تنفذ في نفس نقطة الفحص، فالعمل المقسم بها لا يزال مهمة واحدة طويلة من وجهة نظر المتصفح، والتقسيم الصحيح يكون بمهمة جديدة: `setTimeout` أو `scheduler.yield()` أو MessageChannel.
3. **الاعتماد على دقة `setTimeout`:** المدة حد أدنى وليست موعداً، وتصبح 4 ms بعد التداخل، وثانية أو دقيقة في الألواح المخفية، فلا تبن عداداً تنازلياً بزيادة رقم كل ثانية، وإنما احسب الوقت المتبقي من `Date.now()` عند كل تحديث.
4. **حركة بـ `setInterval` بدلاً من `requestAnimationFrame`:** المؤقت لا يعرف موعد الإطار، فيحسب أحياناً مرتين في إطار واحد ويتخطى إطاراً آخر، فتتقطع الحركة.
5. **إضافة عنصر «جار التحميل» ثم بدء الحساب في نفس المهمة:** لن يرسم المتصفح العنصر قبل الحساب، فأعط الدور للمتصفح بعد إضافة العنصر بانتظار إطار ثم مهمة جديدة.
6. **قياس الأداء على حاسوب المطور فقط:** المهمة التي تأخذ 40 ms على حاسوب سريع قد تأخذ أضعاف ذلك على هاتف متوسط، فقس بأجهزة المستخدمين الحقيقيين عبر web-vitals، أو على الأقل بتبطيء المعالج في DevTools.
7. **الوثوق بتشغيل واحد للقياس:** كما رأينا تغيرت النتيجة من تشغيل لآخر حتى انقلب الترتيب بين الطريقتين، فكرر القياس وانظر إلى القيمة الوسيطة وأكبر قيمة.
8. **أداة قياس تعمل داخل الصفحة:** سكربت المراقبة أو الاختبار الذي ينفذ كوداً في الصفحة ينتظر الخيط مثل أي كود آخر، فقد يقيس بعد انتهاء المشكلة، وهذا ما حدث لنا مع `Runtime.evaluate`.
9. **معالجة الأخطاء في الوعود وحدها:** الوعد المرفوض دون `catch` لا يوقف الصفحة، فقد يفشل طلب `fetch` بسبب [سياسة CORS](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/) مثلاً ويبقى الزر في حالة «جار الإرسال» إلى الأبد، فأضف `try/catch` حول كل `await` يغير حالة الواجهة.

## أين تضع هذا العمل؟ جدول قرار Decision Table

الجدول التالي يجمع الأدوات التي رأيناها، ومتى تختار كل واحدة:

| ما تريده                                          | الأداة                                   | لماذا                                                  | انتبه إلى                                                |
| ------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------- |
| إكمال عمل بدأ للتو قبل أي حدث أو رسم              | await أو queueMicrotask                  | ينفذ في نفس نقطة الفحص فلا يرى المستخدم حالة نصف محدثة | لا تكرره بلا نهاية، فهو يجوع الحلقة                      |
| تعديل بصري مع كل إطار                             | requestAnimationFrame                    | يعمل مرة واحدة قبل كل رسم                              | لا يعمل في الألواح المخفية                               |
| عمل طويل يجب أن ينتهي بسرعة مع بقاء النقرات سريعة | scheduler.yield() كل 10 ms تقريباً       | يعود إلى عملك قبل المهام الأخرى                        | غير موجود في كل المتصفحات، وقد يقلل الإطارات أثناء العمل |
| عمل طويل مع حركة مستمرة على الشاشة                | setTimeout(r, 0) بين الأجزاء             | يعطي المتصفح كل إطار في موعده                          | حد 4 ms بعد التداخل، وزمن العمل الكلي أطول               |
| عمل غير مستعجل                                    | requestIdleCallback                      | يعمل فقط عندما يفرغ الخيط                              | قد لا يعمل أبداً في صفحة مشغولة                          |
| حساب ثقيل لا يحتاج الـ DOM                        | Web Worker                               | يعمل على خيط آخر تماماً                                | نسخ البيانات في الرسائل له ثمن                           |
| عمل ثقيل في سيرفر Node.js                         | worker\_threads أو أجزاء مع setImmediate | لا يؤخر الطلبات الأخرى                                 | راقب تأخير الحلقة بـ monitorEventLoopDelay               |

وقبل أن تنشر صفحة فيها JavaScript ثقيلة راجع النقاط التالية:

- هل سجلت تتبع أداء في DevTools أثناء التفاعلات المهمة، وهل فيه مهام فوق 50 ms؟
- هل معالجات الأحداث تحدث الواجهة أولاً ثم تؤجل العمل الثقيل؟
- هل العمل الذي يعمل في الخلفية مقسم بالوقت، ويعطي الدور للمتصفح كل 10 ms تقريباً؟
- هل الحسابات التي لا تحتاج الـ DOM في Worker؟
- هل تجمع INP من المستخدمين الحقيقيين، وتنظر إلى أسوأ الصفحات وليس إلى المتوسط فقط؟
- هل تعتمد المؤقتات المهمة، مثل تجديد التوكن، على فحص الوقت الفعلي وليس على دقة `setTimeout`؟

## تدريب عملي

- قبل أن تشغل `order.html` على جهازك اكتب الترتيب الذي تتوقعه، ثم أضف `MutationObserver` يراقب عنصراً تغيره في السكربت، وتوقع أين سيظهر رده بين الأسطر.
- غير مدة الجزء في `longtask.html` من 10 ms إلى 50 ms ثم إلى 2 ms، وشغل `bench.mjs` في كل مرة، وارسم كيف يتغير انتظار النقرة وزمن العمل كله.
- انقل الفهرسة إلى Web Worker، وقس كم يستغرق إرسال النتيجة إلى الصفحة، ثم قارن انتظار النقرة بالأرقام التي في هذا المقال.
- افتح صفحة من موقعك في DevTools، وفعل تبطيء المعالج أربع مرات في لوح Performance، ثم سجل تتبعاً أثناء أهم تفاعل فيها، وابحث عن أطول مهمة واعرف من الذي سببها.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- كود الصفحة يعمل على خيط رئيسي واحد مع معالجات الأحداث والرسم، وكل مهمة تعمل حتى النهاية دون مقاطعة، فالمهمة الطويلة تؤخر كل شيء بعدها.
- في كل دورة تأخذ الحلقة مهمة واحدة، ثم تفرغ طابور المهام الصغيرة كله، ثم ترسم إذا حان وقت إطار، ولذلك يسبق `.then` المؤقت دائماً، أما موقع `requestAnimationFrame` من المؤقت فيتغير مع موعد الإطار كما رأينا في خمسة تحميلات.
- `setTimeout` حد أدنى وليس موعداً، ويصبح 4 ms على الأقل من المؤقت السابع في السلسلة المتداخلة، ويبطأ كثيراً في الألواح المخفية.
- INP يقسم زمن التفاعل إلى تأخير الإدخال وزمن المعالجة وتأخير العرض، وفي تجربتنا انتظرت النقرة 298.7 ms خلف مهمة فهرسة، وأخذت 421.8 ms عندما كانت الفهرسة داخل المعالج.
- تقسيم العمل خفض انتظار النقرة من 244.9 ms إلى 34.8 ms مع `scheduler.yield()` وإلى 20.4 ms مع `setTimeout` في القيمة الوسيطة لخمسة عشر تشغيلاً، والأولى أنهت العمل أسرع، والثانية حافظت على الإطارات.
- في Node.js تأتي check بعد poll، فيسبق `setImmediate` المؤقت داخل ردود الإدخال والإخراج، والمهام الصغيرة التي تضيف نفسها تجوع الحلقة، وتأخير الحلقة هو المقياس الذي يكشف المهام الطويلة في السيرفر.

وفي مرحلة [هندسة الواجهات وتطبيقات الويب](https://arabroot.io/start/#stage-web-frontend) تجد المقالات التي تكمل هذا المقال، من طريقة رسم المتصفح للصفحة إلى بناء واجهة عربية سليمة:

[![](https://arabroot.io/content/images/2026/10/browser-internals-dom-cover-cover.webp)دليلكيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلاتماذا يفعل المتصفح بين وصول HTML وظهور الصفحة، ولماذا تتجمد صفحة فيها ألفا صف، وكيف تقيس كل مرحلة في Chrome وتصلحها.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D9%85%D8%AA%D8%B5%D9%81%D8%AD-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84-dom-%D9%88%D8%A7%D9%84%D8%AA%D8%AE%D8%B7%D9%8A%D8%B7-%D9%88%D8%A7%D9%84%D8%B1%D8%B3%D9%85/)[![](https://arabroot.io/content/images/2026/10/css-layout-rtl-cover-cover.webp)دليلCSS للعربية واتجاه RTL: الخصائص المنطقية Logical Propertiesكيف تكتب ملف CSS واحداً يعمل بالعربية والإنجليزية، ولماذا تخطئ margin-left في الصفحة العربية، مع قياسات حقيقية من Chrome.](https://arabroot.io/articles/css-%D9%84%D9%84%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D8%A7%D9%84%D8%AE%D8%B5%D8%A7%D8%A6%D8%B5-%D8%A7%D9%84%D9%85%D9%86%D8%B7%D9%82%D9%8A%D8%A9-%D9%88-rtl/)[![](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-cover-cover.webp)دليلالتعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟لماذا ينقلب النص المختلط في الواجهة العربية وكيف تعزله، وكيف تنسق الأرقام والتواريخ وصيغ الجمع العربية بأدوات المتصفح نفسها.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B9%D8%B1%D9%8A%D8%A8-i18n-%D9%88%D8%A7%D9%84%D9%86%D8%B5-%D8%AB%D9%86%D8%A7%D8%A6%D9%8A-%D8%A7%D9%84%D8%A7%D8%AA%D8%AC%D8%A7%D9%87-bidi-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/threading-cover-cover.webp)دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/interview-frontend-cover-cover.webp)دليلأسئلة مقابلة مطور Frontend من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور Frontend، لكل سؤال إجابة المبتدئ وإجابة الخبير وأسئلة متابعة، مع تمارين عملية شغلناها فعلاً.](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/)[![](https://arabroot.io/content/images/2026/10/e2e-playwright-cover-cover.webp)دليلاختبارات E2E مع Playwright: من أول اختبار إلى CIكيف تكتب اختبارات متصفح ثابتة لموقع عربي مع Playwright، وتشخص الاختبار المتقلب، وتقسمها على أكثر من جهاز في خط CI.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-e2e-%D9%85%D8%B9-playwright-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ تجارب المتصفح في Chrome 156.0.8078.49 بدون واجهة عبر DevTools Protocol، وتجربة Node.js في Container من `node:22-alpine` مع Node.js v22.23.4.