scheduler.yield() και Long Tasks: Σπάστε το Main Thread για Καλύτερο INP (2026)

Η scheduler.yield() σπάει τα long tasks σε ασφαλή chunks και βελτιώνει άμεσα το INP. Οδηγός με παραδείγματα, LoAF debugging και fallbacks για Safari και Firefox.

scheduler.yield() Οδηγός για INP (2026)

Ενημερώθηκε: 12 Αυγούστου 2026

Η scheduler.yield() είναι μια native Web API που σας επιτρέπει να «σπάτε» ένα long task σε μικρότερα κομμάτια, παραδίδοντας τον έλεγχο πίσω στον browser ώστε να επεξεργαστεί input events και rendering πριν συνεχίσετε. Σε αντίθεση με το παραδοσιακό κόλπο setTimeout(fn, 0), η scheduler.yield() επιστρέφει με προτεραιότητα continuation. Δηλαδή, το υπόλοιπο της εργασίας σας δεν πηγαίνει στο τέλος της ουράς πίσω από κάθε άλλο timer. Είναι σήμερα το πιο άμεσο εργαλείο για τη βελτίωση του INP όταν έχετε βαριά JavaScript στο main thread.

  • Ένα long task είναι κάθε εργασία στο main thread που ξεπερνά τα 50ms. Τέτοιες εργασίες είναι η κύρια αιτία κακού INP και υψηλού TBT.
  • Η scheduler.yield() είναι σταθεροποιημένη στο Chromium από την έκδοση 129 (Σεπτέμβριος 2024) και ενεργοποιημένη by default το 2026 σε Chrome, Edge και Opera.
  • Το await scheduler.yield() είναι περίπου 30 φορές πιο γρήγορο από await new Promise(r => setTimeout(r, 0)) στο round-trip, γιατί αποφεύγει το clamping των 4ms σε nested timers.
  • Χρησιμοποιήστε scheduler.postTask() με priorities (user-blocking, user-visible, background) για ολόκληρες εργασίες, και scheduler.yield() μέσα σε ένα ενεργό callback.
  • Το Long Animation Frames API (LoAF) αντικαθιστά σταδιακά το παλαιό Long Tasks API και σας δείχνει ποιο script έκανε block, όχι μόνο ότι κάτι πήγε αργά.
  • Fallback pattern σε μία γραμμή: scheduler?.yield?.() ?? new Promise(r => setTimeout(r)). Καλύπτει Safari και παλαιότερους Firefox.

Τι είναι τα long tasks και γιατί καταστρέφουν το INP

Το W3C ορίζει ως long task κάθε εργασία που εκτελείται στο main thread για περισσότερο από 50ms. Η επιλογή του κατωφλίου δεν είναι τυχαία. Με ένα ρυθμό ανανέωσης 60fps, ο browser έχει μόλις 16.67ms για κάθε frame. Ένα task διάρκειας 50ms θα «κλέψει» χρόνο από τουλάχιστον τρία διαδοχικά frames. Και αν συμπέσει με ένα input event (click, tap, keypress), ο χρήστης θα δει καθυστέρηση στην ανταπόκριση.

Το πρόβλημα γίνεται ορατό στο INP (Interaction to Next Paint), τη μετρική που από τον Μάρτιο του 2024 αντικατέστησε επίσημα το FID στα Core Web Vitals. Το INP μετρά την «χειρότερη» καθυστέρηση από το input event μέχρι το επόμενο paint σε ένα session. Αν το main thread είναι μπλοκαρισμένο με ένα long task τη στιγμή που ο χρήστης κάνει tap, ο browser δεν μπορεί ούτε καν να τρέξει τον event handler σας μέχρι να αποδεσμευτεί.

Σε πραγματικές μετρήσεις που έχω δει σε DTC ιστότοπους πελατών, το 60–80% των «κακών» INP samples (πάνω από 200ms) πηγάζει από τρεις πηγές: hydration σε client-side frameworks, third-party analytics scripts, και βαρύ JSON parsing σε client-side transformations. Και οι τρεις έχουν κοινή θεραπεία, το yielding.

Πώς μετράμε τα long tasks

Η κλασική μέθοδος είναι με το PerformanceObserver:

// Παρακολούθηση long tasks σε production
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(
      `Long task: ${entry.duration.toFixed(0)}ms`,
      `attributed to ${entry.attribution?.[0]?.name ?? 'unknown'}`
    );
  }
});
observer.observe({ type: 'longtask', buffered: true });

Το πρόβλημα με το Long Tasks API είναι ότι σας λέει μόνο ότι κάτι πήγε αργά, όχι τι. Γι' αυτό το 2026 δουλεύουμε πλέον με το Long Animation Frames API, που το καλύπτω παρακάτω.

Πώς λειτουργεί η scheduler.yield()

Η scheduler.yield() επιστρέφει ένα Promise που resolves «σύντομα», αλλά με ένα κρίσιμο χαρακτηριστικό. Το continuation (ο κώδικας μετά το await) μπαίνει σε ξεχωριστή, high-priority ουρά, όχι στο τέλος της μακροουράς μαζί με timers και messages. Αυτό σημαίνει ότι ο browser θα προλάβει να επεξεργαστεί ένα pending input event ή ένα rendering pass, αλλά θα επιστρέψει σε εσάς πριν από άλλα background tasks.

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    doExpensiveWork(items[i]);

    // Κάθε 50 items, δώσε ανάσα στο main thread
    if (i % 50 === 0) {
      await scheduler.yield();
    }
  }
}

Η ζωτική διαφορά από την παλιά μας φίλη setTimeout(fn, 0): το HTML spec επιβάλλει timer clamping στα 4ms για nested timers (δηλαδή, μετά τα πρώτα 5 nested calls). Αυτό σημαίνει ότι ένας βρόχος με await new Promise(r => setTimeout(r, 0)) στην πράξη τρέχει με ρυθμό ~250 iterations/second, όχι όσο επιτρέπει το CPU. Η scheduler.yield() δεν έχει τέτοιο clamping.

Το άλλο σημαντικό: η scheduler.yield() κληρονομεί την priority από τον καλούντα. Αν σας καλεί ένας click handler (user-blocking), το continuation θα ξανατρέξει με priority user-blocking. Αν σας καλεί ένα background analytics beacon, θα ξανατρέξει με background priority. Αυτό δεν το κάνει καμία άλλη μέθοδος yielding.

scheduler.yield() vs setTimeout(0) vs postTask()

Η διαφορά μεταξύ αυτών των τριών προσεγγίσεων φαίνεται μικρή στα λόγια αλλά αλλάζει δραματικά το throughput και την responsiveness στην πράξη. Ας τις βάλουμε δίπλα-δίπλα.

Χαρακτηριστικόscheduler.yield()setTimeout(fn, 0)scheduler.postTask()
Priority του continuationΚληρονομείται από τον callerΠάντα ίδια (κανονική)Ρητά ορισμένη (3 επίπεδα)
Timer clamping (4ms)ΌχιΝαι, μετά τα 5 nested callsΌχι
Round-trip latency (μέσος όρος)~0.5ms~4–15ms~1ms
Επιτρέπει rendering ενδιάμεσαΝαιΝαιΝαι
Επιτρέπει άλλα user-blocking eventsΝαιΝαιΕξαρτάται από priority
Ιδανικό γιαΒρόχους/σπάσιμο μεγάλης δουλειάςFallback / legacy codeΟλόκληρα, ανεξάρτητα tasks
Browser support (Αύγ. 2026)Chrome/Edge/Opera 129+ΠαντούChrome/Edge/Opera 94+

Ένας απλός κανόνας: αν είστε μέσα σε μια εργασία και θέλετε να συνεχίσετε στη συνέχεια, χρησιμοποιήστε await scheduler.yield(). Αν θέλετε να προγραμματίσετε μια νέα, ανεξάρτητη εργασία για αργότερα, χρησιμοποιήστε scheduler.postTask(fn, { priority: 'background' }). Το setTimeout(0) έχει πλέον μόνο ρόλο fallback. Ειλικρινά, αυτή είναι η όλη ουσία.

Πώς να σπάσετε ένα long task στην πράξη

Ας δούμε ένα ρεαλιστικό σενάριο. Ένα e-commerce checkout πρέπει να επικυρώσει 500 line items πριν αποστείλει την παραγγελία. Η αρχική υλοποίηση, χωρίς yielding:

// ΠΡΙΝ: μπλοκάρει το main thread για ~400ms σε mid-range Android
function validateCart(items) {
  const errors = [];
  for (const item of items) {
    const result = expensiveValidation(item); // ~0.8ms/item
    if (!result.ok) errors.push(result.error);
  }
  return errors;
}

button.addEventListener('click', async () => {
  const errors = validateCart(cart.items);
  showResults(errors);
});

Σε ένα Moto G Power (το device που όλοι πρέπει να δοκιμάζουμε αλλά κανείς δεν το κάνει), αυτό δίνει INP ~430ms, δηλαδή «poor» κατηγορία. Το είδα μόλις πέρσι σε πελάτη fashion e-shop, όπου το «Add to bag» πάγωνε την οθόνη για μισό δευτερόλεπτο σε κάθε tap. Η μεταγραφή με yielding:

// ΜΕΤΑ: yields κάθε ~5ms, INP πέφτει σε ~110ms
async function validateCart(items) {
  const errors = [];
  let lastYield = performance.now();

  for (const item of items) {
    const result = expensiveValidation(item);
    if (!result.ok) errors.push(result.error);

    // Yield αν έχουμε ξοδέψει >5ms από το τελευταίο yield
    if (performance.now() - lastYield > 5) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
  return errors;
}

// Helper με fallback για browsers χωρίς scheduler.yield
function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Δύο λεπτομέρειες που έχουν σημασία εδώ. Πρώτον, το κατώφλι των 5ms, όχι 50ms. Στόχος δεν είναι να αποφύγετε ένα long task αλλά να δώσετε στον browser ευκαιρίες να κάνει ενδιάμεσα paint και να χειριστεί input. Δεύτερον, ο έλεγχος με performance.now() αντί για fixed batching (κάθε N items) είναι ανθεκτικός σε items διαφορετικού μεγέθους. Δεν θα πάθετε regression αν κάποιο item τύχει να παίρνει 20ms αντί 0.8ms.

Pattern: chunked processing με requestIdleCallback fallback

Για μη-κρίσιμες εργασίες (π.χ. αναλυτικά, cache warming, prerendering), συνδυάστε το με requestIdleCallback:

async function processInBackground(items, chunkSize = 100) {
  for (let i = 0; i < items.length; i += chunkSize) {
    // Περιμένετε να είναι ο browser idle
    await new Promise((resolve) =>
      requestIdleCallback(resolve, { timeout: 1000 })
    );

    const chunk = items.slice(i, i + chunkSize);
    for (const item of chunk) {
      processItem(item);
    }
  }
}

Long Animation Frames API: εντοπίστε ποιο script φταίει

Το κλασικό Long Tasks API έχει ένα καίριο κενό. Σας λέει ότι κάτι πήγε αργά, αλλά όχι τι. Το Long Animation Frames API (LoAF), σταθεροποιημένο στο Chromium 123 και ενεργοποιημένο by default, καταγράφει ολόκληρα «αργά frames» και σας δίνει breakdown ανά script.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.group(`LoAF: ${entry.duration.toFixed(0)}ms frame`);
      console.log('Rendering:', entry.renderStart - entry.startTime, 'ms');
      console.log('Style/Layout:', entry.styleAndLayoutStart, 'ms');
      // Το χρυσό: attribution ανά script
      for (const script of entry.scripts) {
        console.log(
          `${script.duration}ms - ${script.name || script.sourceURL} ` +
          `(${script.invoker})`
        );
      }
      console.groupEnd();
    }
  }
});
observer.observe({ type: 'long-animation-frame', buffered: true });

Το script.invoker σας δείχνει γιατί έτρεξε το script, τιμές όπως 'IMG.onload', 'setTimeout', 'user-callback' κ.λπ. Σε ένα πρόσφατο audit εντόπισα με αυτή τη μέθοδο ένα third-party CDP script που έκανε 180ms JSON.parse σε κάθε page view, κάτι που το κλασικό Long Tasks API μου έλεγε απλά ως «anonymous 200ms task». Χωρίς το LoAF, θα ψαχνόμουν ακόμα.

Για production, στείλτε τα LoAF entries στο RUM tool σας. Δείτε τον οδηγό μου για RUM με web-vitals.js για την αρχιτεκτονική.

Web Workers vs scheduler.yield(): ποιο να διαλέξετε

Το ερώτημα «yield ή web worker;» έρχεται σχεδόν σε κάθε consultation. Η απάντηση εξαρτάται από τη φύση της εργασίας:

  • Καθαρή υπολογιστική εργασία χωρίς DOM access (image processing, cryptographic hashing, large JSON parse, ML inference): Web Worker. Το main thread μένει εντελώς ελεύθερο.
  • Εργασία που απαιτεί DOM ή state του component tree (validation, rendering lists, form processing): scheduler.yield(). Οι Workers δεν έχουν πρόσβαση στο DOM.
  • Batch από ανεξάρτητα, χρονικά ευέλικτα tasks (analytics, prefetching): scheduler.postTask() με background priority.

Ένα υβριδικό pattern που δουλεύει καλά: κάνετε το heavy lifting σε Worker και επιστρέφετε chunked αποτελέσματα που κάνετε merge στο main thread με yielding μεταξύ chunks:

// worker.js
self.onmessage = async (e) => {
  const results = heavyComputation(e.data);
  // Στείλτε σε chunks των 100 για να μη μπλοκάρει το main thread στο merge
  for (let i = 0; i < results.length; i += 100) {
    self.postMessage({ chunk: results.slice(i, i + 100) });
  }
  self.postMessage({ done: true });
};

// main.js
const worker = new Worker('/worker.js');
const allResults = [];
worker.onmessage = async (e) => {
  if (e.data.done) return finalize(allResults);
  allResults.push(...e.data.chunk);
  updateProgressUI(allResults.length);
  await scheduler.yield(); // κρατάμε το UI responsive
};

Browser support και production-ready fallbacks

Ας είμαστε ρεαλιστές. Τον Αύγουστο 2026, το browser landscape για την Scheduler API είναι το εξής:

  • Chrome/Edge/Opera 129+: πλήρης υποστήριξη scheduler.yield() και scheduler.postTask().
  • Firefox: scheduler.postTask() από την 121, scheduler.yield() ακόμα behind flag (dom.enable_web_scheduler_yield). Origin trial αναμένεται Q4 2026.
  • Safari: καμία από τις δύο δεν υποστηρίζεται. Υπάρχει positive signal στο WebKit standards positions αλλά χωρίς ETA.

Ο caniuse δείχνει περίπου 72% global support για scheduler.yield(). Αυτό είναι υπεραρκετό για progressive enhancement. Το 28% των χρηστών σας απλά θα πάρει την setTimeout συμπεριφορά, που είναι χειρότερη μεν αλλά όχι broken.

Το production-ready helper που χρησιμοποιώ σε όλα τα projects:

// yield-to-main.js - μια γραμμή, καλύπτει όλα τα browsers
export const yieldToMain = () => {
  // Chrome/Edge/Opera 129+
  if ('scheduler' in globalThis && 'yield' in scheduler) {
    return scheduler.yield();
  }
  // Firefox με postTask
  if ('scheduler' in globalThis && 'postTask' in scheduler) {
    return scheduler.postTask(() => {}, { priority: 'user-visible' });
  }
  // Safari και παλαιότερα
  return new Promise((resolve) => setTimeout(resolve, 0));
};

Ο συνδυασμός με σωστό third-party script governance δίνει τα καλύτερα αποτελέσματα. Δεν έχει νόημα να yielding το δικό σας code αν ένα analytics tag τρέχει 250ms sync στο load event.

Debugging με το Chrome Performance panel

Το DevTools Performance panel στο Chrome 128+ έχει ενσωματωμένο LoAF visualization. Ο workflow μου είναι απλός:

  1. Ανοίξτε DevTools, μετά Performance και ενεργοποιήστε τα checkboxes «Enable advanced paint instrumentation» και «Long animation frames».
  2. Κάντε throttling σε 4× CPU slowdown. Αυτό προσεγγίζει mid-range Android.
  3. Record, κάντε την interaction που σας ενδιαφέρει, μετά Stop.
  4. Στο timeline, ψάξτε τα κίτρινα «Long Frame» blocks. Κάντε click και δείτε το «Related Node» και τα scripts breakdown.
  5. Το «Interactions» track δείχνει με πράσινο/κίτρινο/κόκκινο το INP κάθε αλληλεπίδρασης. Κάντε click σε ένα κόκκινο και το panel θα σας δείξει ακριβώς ποιο long task φταίει.

Ένα trick: το «Bottom-Up» view με grouping «by URL» σας δείχνει σε 30 δευτερόλεπτα ποιο third-party origin τρώει το CPU σας. Αν το top entry είναι connect.facebook.net ή www.googletagmanager.com, το πρόβλημα δεν είναι το δικό σας code. Είναι governance.

Για μακροσκελείς μετρήσεις σε production, καταγράψτε INP με το web-vitals.js library και συσχετίστε με LoAF attribution. Έτσι έχετε lab και field δεδομένα που δείχνουν το ίδιο πράγμα, όχι σπάνιο φαινόμενο αλλά όχι και εγγυημένο.

Συχνές Ερωτήσεις

Ποια είναι η διαφορά ανάμεσα σε scheduler.yield() και setTimeout(fn, 0);

Και τα δύο δίνουν στον browser ευκαιρία να επεξεργαστεί άλλες εργασίες, αλλά το scheduler.yield() επιστρέφει με continuation priority. Δηλαδή, το υπόλοιπο του κώδικά σας τρέχει πριν από άλλα timer callbacks. Το setTimeout(fn, 0) υπόκειται σε 4ms clamping σε nested calls, ενώ το scheduler.yield() όχι, καθιστώντας το περίπου 30 φορές πιο γρήγορο σε βρόχους.

Υποστηρίζεται η scheduler.yield() σε Firefox και Safari;

Τον Αύγουστο 2026, όχι από default. Το Firefox το έχει πίσω από flag (dom.enable_web_scheduler_yield) και το Safari δεν το έχει υλοποιήσει καθόλου. Χρησιμοποιήστε ένα fallback pattern με setTimeout(resolve, 0) για αυτούς τους browsers. Καλύπτει περίπου το 28% των global χρηστών με progressive degradation, όχι breakage.

Πόσο συχνά πρέπει να καλώ scheduler.yield() μέσα σε ένα loop;

Ελέγξτε με performance.now() και yield αν έχουν περάσει >5ms από το τελευταίο yield. Yielding σε κάθε iteration προσθέτει overhead ~10μs ανά κλήση που γίνεται σημαντικό σε loops χιλιάδων επαναλήψεων. Το κατώφλι των 5ms κρατά κάθε task chunk πολύ κάτω από το όριο των 50ms για long tasks και δίνει άφθονο χρόνο για rendering στα 60fps.

Τι είναι το Long Animation Frames API και σε τι διαφέρει από το Long Tasks API;

Το Long Animation Frames API (LoAF) καταγράφει ολόκληρα «αργά frames», όχι μόνο tasks >50ms αλλά και το style/layout/paint κόστος τους, και σας δίνει attribution ανά script (source URL, invoker type, function name). Το κλασικό Long Tasks API σας λέει μόνο ότι κάτι πήγε αργά χωρίς να ξέρετε τι. Το LoAF είναι το εργαλείο επιλογής για production RUM το 2026.

Πώς διορθώνω το Lighthouse warning "Avoid long main-thread tasks";

Πρώτα εντοπίστε τα scripts με το Performance panel του DevTools ή το LoAF API. Οι τρεις πιο κοινές αιτίες είναι: hydration σε client-side frameworks (χρησιμοποιήστε streaming SSR ή islands architecture), third-party tags (deferrάρετέ τα ή τρέξτε τα σε Web Worker με Partytown), και βαρύ JSON parsing (μεταφέρετε σε Web Worker ή σπάστε το με scheduler.yield()). Στοχεύστε σε κανένα task >50ms σε mid-range hardware.

Σχετικά με τον Συγγραφέα Marcus Halloway

Marcus has spent the last 9 years on Core Web Vitals, mostly on the browser side. He worked at Cloudflare on the Workers team, shipping early-hints and 103 response support for the Pages product, and before that did two years at Vercel debugging Next.js hydration regressions across enterprise customers. He still maintains a small open-source library for measuring CLS on client-side route transitions, which he refuses to rewrite in TypeScript on principle. His current obsession is third-party script governance: the embedded chat widgets, A/B testing tags, and CDP snippets that quietly destroy TBT on real devices. He consults part-time for two DTC brands and writes here about lab-vs-field discrepancies, the actual cost of a 200KB JS bundle on a Moto G Power, and why your synthetic Lighthouse score is lying to you.