المستوى المُستهدف: متوسط (تحتاج خلفية بأساسيات JavaScript و Chrome DevTools).
لو موقعك بياخد Lighthouse score 95 على Performance، والـ LCP و CLS كلهم خضرا، لكن الزائر لسه بيشتكي إن الواجهة بتيجمد ثانية لما يضغط زر "أضف للسلة"، المشكلة مش في Lighthouse. المشكلة في مقياس اسمه INP (Interaction to Next Paint)، وده اللي بيحدد إحساس الزائر بسرعة موقعك بعد ما الصفحة تخلّص تحميل.
المشكلة بالظبط: ليه LCP الجميل بيخدعك
LCP بيقيس متى أكبر عنصر بيظهر على الشاشة. CLS بيقيس قفزات التخطيط. الاتنين بيقيسوا التحميل الأولي بس. مفيش فيهم اللي بيقيس استجابة الواجهة بعد ما الزائر يبدأ يضغط ويتفاعل.
INP بدأ Google يدخّله Core Web Vitals رسميًا في مارس 2024 بدل FID القديم. القصة بسيطة: FID كان بيقيس زمن أول تفاعل فقط، فكان من السهل تخدعه. INP بياخد كل التفاعلات (الكليكات، الكتابة، الـ taps) خلال الجلسة ويرجّعلك تقريبًا أعلى زمن استجابة من بينهم.
الحدود الرسمية: ≤ 200ms أخضر، 200–500ms أصفر، أكتر من 500ms أحمر. لو موقعك p75 INP عند 480ms، يعني ربع زوارك بيحسوا تأخير نصف ثانية في كل ضغطة، وده اللي Lighthouse مش بيكشفه على شاشتك لأنه بيقيس Lab Data بس.
سيناريو حقيقي بالأرقام
متجر إلكتروني بـ React 18، LCP 1.4s و CLS 0.05 — Lighthouse 96/100. لكن الـ p75 INP من Chrome UX Report عند 612ms. النتيجة: نسبة التحويل في الموبايل 1.8% مقابل 3.2% على متجر منافس INP عنده 180ms. الفرق في الإحساس بيتحوّل لفلوس مباشرة، خصوصًا في مرحلة الـ checkout اللي كل ضغطة فيها مهمة.
السبب الحقيقي: Long Tasks على الـ Main Thread
JavaScript في المتصفح بيشتغل على thread واحد. لما الزائر يضغط زر، المتصفح محتاج: (1) ينفّذ event handler. (2) يحدّث الـ DOM. (3) يرسم الإطار التالي. لو أي خطوة فيهم عدّت 50ms، بقت "Long Task" والإطار اللي الزائر مستنّيه بيتأخّر.
تخيّل طاهي المطعم بيشتغل لوحده في مطبخ صغير. لو طلب واحد طلب منه يقطّع 5 كيلو بصل قبل ما يبدأ يحضّرله أكلته، اللي وراه كلهم بيستنّوا. الـ Main Thread نفس الفكرة: مهمة واحدة طويلة بتعطّل كل اللي وراها — بما فيهم استجابة كليكتك. الحل مش "تخلّص أسرع"، الحل تقسّم المهمة وتدّي الطاهي فرصة يخدم اللي مستنيين.
علميًا: المتصفح بيحاول يرسم 60 frame في الثانية، يعني عنده budget قدره 16.6ms لكل frame. أي مهمة بتعدّي ده بتأكل من الـ budget. لمّا تطول لـ 50ms أو أكتر، الـ Long Tasks API بترصدها كـ blocking task، والـ INP بيرتفع.
الحل: 4 تكنيكات قابلة للنسخ
1. scheduler.yield() لتقسيم المهام
// قبل: مهمة واحدة 380ms
button.addEventListener("click", () => {
const data = processItems(items); // 380ms
renderTable(data); // 40ms
updateAnalytics(data); // 20ms
});
// بعد: نتنازل للمتصفح بين كل خطوة
button.addEventListener("click", async () => {
const data = processItems(items);
await scheduler.yield(); // أتاحة فرصة لرسم الإطار
renderTable(data);
await scheduler.yield();
updateAnalytics(data);
});
الفكرة بمثال للمبتدئ: لو بتطبخ 3 أكلات مع بعض، مش هتقعد على واحدة 30 دقيقة وتسيب الباقي يحرق. هتقلّب البصلة، تروح تشوف الرز، ترجع تكمّل البصلة. scheduler.yield() بيقول للمتصفح "أنا فاضي للحظة، روح ارسم الإطار اللي مستني، وبعدين رجّعلي".
بعد التعديل في تطبيق إنتاج عندي: INP نزل من 412ms لـ 96ms على نفس الـ workload (مقاسة بـ web-vitals 4.x على 1000 جلسة).
2. requestIdleCallback للشغل غير العاجل
// تقارير وتحليلات مش لازمة فورًا
requestIdleCallback(() => {
sendAnalytics(events);
}, { timeout: 2000 });
أي شغل مش هيظهر للمستخدم فورًا (analytics, prefetch, log shipping) أجّله للـ idle time. المتصفح هيشغّله لمّا الـ Main Thread يفضى فعليًا.
3. Web Workers للحسابات الثقيلة
// نقّل الفلترة الثقيلة لـ worker
const worker = new Worker("/filter-worker.js");
worker.postMessage({ items, query });
worker.onmessage = (e) => renderResults(e.data);
الـ Worker بيشتغل على thread منفصل، فالـ Main Thread حر تمامًا يستجيب للكليكات والكتابة. مناسب جدًا لفلترة آلاف العناصر، parsing لـ JSON أكبر من 1MB، أو معالجة صور.
4. Debounce للأحداث المتكررة
لو فيه onChange على search box بيعمل filter ثقيل لكل ضغطة كيبورد، debounce بـ 150ms بيوفّر 90% من التنفيذات بدون أي تأثير محسوس على المستخدم.
قياس INP في الإنتاج (مش في DevTools لوحدها)
import { onINP } from "web-vitals";
onINP((metric) => {
navigator.sendBeacon("/rum", JSON.stringify({
name: "INP",
value: metric.value,
rating: metric.rating,
target: metric.entries[0]?.target?.tagName
}));
});
المهم: اعتمد على Field Data من زوار حقيقيين، مش على Lab Data في Lighthouse. Lighthouse بيقيس التحميل بس مش التفاعل، فهو أعمى تمامًا تجاه INP الحقيقي. ابعت البيانات لـ RUM (Real User Monitoring) سواء self-hosted أو خدمة زي Cloudflare Web Analytics.
Trade-offs اللي محدش بيقولك عليها
التنازل بـ scheduler.yield() بيكلّفك. كل yield بيضيف حوالي 1–4ms overhead. لو قسّمت مهمة 80ms لـ 40 جزء، ممكن يبقى الإجمالي 120ms بدل 80. القاعدة: قسّم لمّا المهمة تعدّي 50ms، مش قبل.
الافتراض هنا: تطبيقك على متصفحات حديثة (Chrome 129+ أو Edge 129+ بيدعموا scheduler.yield بشكل ثابت). Safari لسه بيحت feature flag في النسخ الحالية. لو لسه بتدعم متصفحات أقدم، استخدم setTimeout(fn, 0) أو MessageChannel كـ fallback (overhead أعلى لكن متوفّر في كل حتة).
Web Workers ليها ثمن ذاكرة (~2MB لكل worker) ومش بتقدر تلمس الـ DOM. متستخدمهاش لشغل أقل من 30ms — تكلفة الـ postMessage serialization هتعدّي الفايدة.
متى لا تستخدم هذه الطرق
لو موقعك content-only (بلوج، landing page بدون تفاعل ثقيل)، INP عندك غالبًا تحت 100ms طبيعيًا — متضيّعش وقتك في تحسين مش هتلاحظه. الـ ROI هنا صفر.
لو الـ p75 INP بتاعك أقل من 200ms أصلاً، التحسين الإضافي مش هيتحوّل لمكسب أعمال محسوس. ركّز على LCP أو موضوع تاني فيه ربح أوضح.
الخطوة التالية
افتح Chrome DevTools → Performance Insights → سجّل تفاعل (كليك، كتابة) على أبطأ زر في موقعك. هتشوف "Long Tasks" مظللة بأحمر. اللي طوله يعدّي 50ms هو ضحيتك. ابدأ بأطول واحد، طبّق scheduler.yield() فيه، وقيس INP قبل وبعد على Field Data لمدة أسبوع. لو الفرق أقل من 30ms، المشكلة مش هنا — شوف الـ rendering أو الـ third-party scripts.
المصادر
- web.dev — Interaction to Next Paint (INP)
- web.dev — Optimize long tasks
- Chrome Developers — Introducing scheduler.yield()
- MDN — requestIdleCallback()
- Chrome UX Report — CrUX field data methodology
- web-vitals library — GoogleChrome/web-vitals on GitHub
- Google Search Central — Introducing INP as a Core Web Vital