Skip to main content

02 - scheduler.yield() and Long Tasks: Unlock the Main Thread

02 - scheduler.yield() e Long Tasks: Sblocca il Main Thread: scheduler.yield() e Long Tasks: Sblocca il Main Thread Il browser ha un solo main thread.

What do you think about this article?

At a Glance

02 - scheduler.yield() e Long Tasks: Sblocca il Main Thread: scheduler.yield() e Long Tasks: Sblocca il Main Thread Il browser ha un solo main thread.

scheduler.yield() and Long Tasks: Unlock the Main Thread

The browser has only one main thread. All of the JavaScript runs in sequence on this thread your application, the layout and paint of the page, and the response to user input. The model of executing JavaScript e run-to-completion: a JavaScript task, once started, runs to the end without interruption. If that task takes 300ms, for those 300ms the browser can't do anything else: it doesn't respond to clicks, it doesn't update the interface, it doesn't process other events.

This is the fundamental problem that causes high NPI. The traditional solution was to use setTimeout(fn, 0) o Promise.resolve().then() to "divide" the work, but these approaches have prioritization problems and do not tell the browser the intent to give up control. scheduler.yield() and the native solution designed explicitly for this purpose.

What You Will Learn

  • How the main thread works and JavaScript's run-to-completion model
  • What are Long Tasks and how to identify them with Chrome DevTools
  • How scheduler.yield() differs from setTimeout and Promise.resolve()
  • Practical patterns to break up heavy tasks while preserving priority
  • scheduler.postTask() for tasks with explicit priority
  • How to measure INP improvement after optimizations

The Problem: Run-to-Completion and Long Tasks

Imagine having an event handler that, at the click of a button, must process an array of 10,000 elements and update the DOM. Without optimizations:

// PROBLEMA: questo handler blocca il main thread per centinaia di ms
document.getElementById('process-btn').addEventListener('click', () => {
  const results = [];

  // Elaborazione sincrona di 10.000 elementi
  for (const item of largeDataset) {
    results.push(expensiveTransform(item)); // ogni chiamata costa ~0.1ms
  }

  // Aggiornamento DOM con tutti i risultati
  renderResults(results);
});

This handler runs synchronously for ~1000ms (10,000 items * 0.1ms). During that entire second, the browser cannot respond to other inputs, it cannot update the animation of a loading spinner, and cannot process other events. The INP for this interaction will be > 1000ms.

View the Problem with Performance Observer

// Monitora i Long Tasks in produzione
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`Long Task: ${Math.round(entry.duration)}ms`, {
        startTime: entry.startTime,
        attribution: entry.attribution,
      });
    }
  }
});

observer.observe({ type: 'longtask', buffered: true });

scheduler.yield(): The Native Solution

scheduler.yield() and a new Browser Scheduler API (Baseline 2024, 87% support browser) which allows a JavaScript task to relinquish control to the browser in a controlled manner. The task can then resume where it stopped, with the assurance that the browser had the possibility of processing pending events.

Here's how to rewrite the previous example with scheduler.yield():

document.getElementById('process-btn').addEventListener('click', async () => {
  const results = [];
  const CHUNK_SIZE = 100; // processa 100 elementi per volta

  for (let i = 0; i < largeDataset.length; i += CHUNK_SIZE) {
    // Processa un chunk di elementi
    const chunk = largeDataset.slice(i, i + CHUNK_SIZE);
    for (const item of chunk) {
      results.push(expensiveTransform(item));
    }

    // Cedi il controllo al browser tra un chunk e l'altro
    // Il browser puo ora processare input pendenti, animazioni, ecc.
    await scheduler.yield();
  }

  // Aggiorna il DOM con i risultati finali
  renderResults(results);
});

With this pattern, instead of a single Long Task of 1000ms, we have 100 tasks of 10ms each, with "yield points" between one and the other. The browser can respond to input between chunks, bringing the INP from >1000ms to <50ms for the initial response.

scheduler.yield() vs setTimeout(0) vs Promise.resolve()

The fundamental difference between scheduler.yield() and the previous techniques is in the prioritization:

Method Priority Behavior Problem
setTimeout(fn, 0) Low Adds to the macrotask queue Minimum 4ms delay, unpredictable priority
Promise.resolve() High (microtasks) Runs before other tasks It doesn't really give up control to the browser
scheduler.yield() Inherits from the calling task Gives up control, maintains original priority None (except 87% browser support)

The key point of scheduler.yield() and which maintains the priority of the calling task. If the user just clicked a button (high priority), subsequent chunks will still have it high priority compared to other background tasks. With setTimeout(0), chunks they would be demoted to low priority, possibly being overtaken by other tasks.

scheduler.postTask(): Full Priority Control

For more advanced scenarios, scheduler.postTask() allows you to schedule tasks with explicit priority: 'user-blocking' (criticism, e.g. response to user input), 'user-visible' (important, e.g. content rendering), e 'background' (not urgent, e.g. analytics, prefetch).

// Task critico: risposta diretta a un input utente
await scheduler.postTask(
  () => {
    updateUIImmediately();
  },
  { priority: 'user-blocking' }
);

// Task visibile: aggiornamento interfaccia non urgente
await scheduler.postTask(
  () => {
    updateSecondaryContent();
  },
  { priority: 'user-visible' }
);

// Task background: analytics, prefetch
await scheduler.postTask(
  () => {
    sendAnalyticsData();
    prefetchNextPage();
  },
  { priority: 'background' }
);

You can also delete pending tasks via TaskController:

const controller = new TaskController({ priority: 'user-visible' });

// Avvia il task
const taskPromise = scheduler.postTask(
  () => { doWork(); },
  { signal: controller.signal }
);

// Cancella il task se l'utente naviga via
window.addEventListener('beforeunload', () => {
  controller.abort();
});

Practical Patterns for JavaScript Frameworks

React: Heavy Rendering Splitting

function HeavyList({ items }: { items: Item[] }) {
  const [visibleItems, setVisibleItems] = useState<Item[]>([]);

  useEffect(() => {
    const renderChunks = async () => {
      const CHUNK_SIZE = 50;
      const allItems: Item[] = [];

      for (let i = 0; i < items.length; i += CHUNK_SIZE) {
        const chunk = items.slice(i, i + CHUNK_SIZE);
        allItems.push(...chunk);

        // Aggiorna lo stato con il chunk corrente
        setVisibleItems([...allItems]);

        // Cedi il controllo prima del prossimo chunk
        if ('scheduler' in window && 'yield' in scheduler) {
          await scheduler.yield();
        } else {
          // Fallback per browser non supportati
          await new Promise(resolve => setTimeout(resolve, 0));
        }
      }
    };

    renderChunks();
  }, [items]);

  return <ul>{visibleItems.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}

Angular: Heavy Computation in OnInit

@Component({
  selector: 'app-data-table',
  template: `<div *ngFor="let row of processedRows">{{ row.value }}</div>`
})
export class DataTableComponent implements OnInit {
  processedRows: ProcessedRow[] = [];

  constructor(private ngZone: NgZone) {}

  async ngOnInit() {
    // Esegui fuori da Angular zone per evitare change detection
    // ad ogni yield
    await this.ngZone.runOutsideAngular(async () => {
      const CHUNK_SIZE = 100;
      const results: ProcessedRow[] = [];

      for (let i = 0; i < this.rawData.length; i += CHUNK_SIZE) {
        const chunk = this.rawData.slice(i, i + CHUNK_SIZE);
        results.push(...chunk.map(r => this.processRow(r)));

        if ('scheduler' in window) {
          await (window as any).scheduler.yield();
        }
      }

      // Rientra in Angular zone solo per l'aggiornamento finale
      this.ngZone.run(() => {
        this.processedRows = results;
      });
    });
  }
}

How to Verify Improvement

After implementing scheduler.yield(), check the INP improvement:

  1. Chrome DevTools Performance Panel: record the same interaction before and after optimization. You should see many short tasks instead of one long one.
  2. web-vitals.js in production: monitor INP P75 for a few days afterward the deployment. A typical improvement is 40% to 70% of the INP value.
  3. Interaction to Next Paint trace: in the Performance panel, the input delay should be significantly reduced, as Long Tasks no longer block the main thread at click time.

Browser and Fallback support

scheduler.yield() and supported by Chrome 115+, Edge 115+, Safari 17+ (87% of users). For Firefox and older browsers, use fallback new Promise(resolve => setTimeout(resolve, 0)). Create a utility function that abstracts the difference:

async function yieldToMain(): Promise<void> {
  if ('scheduler' in window && 'yield' in (window as any).scheduler) {
    return (window as any).scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

Conclusions

scheduler.yield() It is the most powerful tool available today for improving INP on complex JavaScript interfaces. The technique of breaking heavy tasks into chunks with yield points intermediate and conceptually simple but has a huge impact on the perceived user experience.

The key is to first identify problematic interactions with Chrome DevTools, then apply the chunking+yield pattern only where necessary. Over-engineering with yield everywhere can introduce unnecessary overhead; apply it to tasks that you know are heavy (processing large datasets, rendering of many DOM elements, expensive computations).

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects