Improve INP and web performance with `scheduler.yield()` in Chromium browsers

If your web app feels sluggish despite having clean code, the problem isn't what you're doing, it's when you're doing it. Long-running JavaScript blocks the main thread, freezing your UI and destroying your Interaction to Next Paint (INP) scores. The new scheduler.yield() API solves this by letting you break up heavy tasks without the overhead of traditional techniques.

This tutorial shows you exactly how to use scheduler.yield() to keep your apps responsive, with real code examples that demonstrate the measurable difference it makes.

What's the problem?

JavaScript is single-threaded. When you run a heavy operation, parsing large datasets, complex calculations, DOM manipulations, the browser can't respond to user input, clicks don't register, and scrolling stutters. Your INP metric skyrockets.

INP (Interaction to Next Paint) measures the time between a user interaction and when the browser paints the next frame. Google's Core Web Vitals considers anything over 200ms poor. Long tasks (>50ms) are the primary culprit.


Traditional solutions:

  • setTimeout(fn, 0) - Works, but adds 4ms+ delay per yield
  • requestIdleCallback() - Only runs when the browser is idle, not during active interaction
  • requestAnimationFrame() - Designed for animations, not task scheduling
  • Web workers - Great for heavy computation, but can't access the DOM

These have their place, but they're either too slow or too limiting for many scenarios.

What is scheduler.yield()?

scheduler.yield() is a new API in Chromium-based browsers (Chrome 115+, Edge 115+) that provides cooperative task scheduling. It yields control back to the browser immediately, allowing it to handle pending user interactions, then resumes your task.

In plain terms, it’s a way of saying to the browser:

“Hey, I’m doing some heavy work — go ahead and do your important stuff (like responding to user clicks or drawing the next frame) before I continue.”

The key difference is that it's optimized for this specific use case. It has lower overhead than setTimeout() and resumes faster because the browser prioritizes continuation tasks.

When we think of JavaScript, we often imagine it running one thing after another — start a task, finish it completely, then move on to the next. But that model breaks down when users are involved.

Browser support

As of late 2025, the scheduler.yield() function is supported in various Chromium-based browsers, including Google Chrome, Microsoft Edge, Opera, Firefox, and Brave. However, as it is a relatively new API, support may still be limited in other browsers such as Webview and Safari. So you'll need a fallback:

if ('scheduler' in window && 'yield' in scheduler) {
  // scheduler.yield() is supported
} else {
  // Fall back to setTimeout
}

Basic usage

Here's the simplest way to use it:

async function processLargeDataset(items) {
  const results = [];
  
  for (let i = 0; i < items.length; i++) {
    // Process item
    results.push(expensiveOperation(items[i]));
    
    // Yield every 10 items
    if (i % 10 === 0) {
      await scheduler.yield();
    }
  }
  
  return results;
}

What happens here:

  1. Every 10 iterations, scheduler.yield() pauses execution
  2. The browser processes any pending input events (clicks, scrolls, typing)
  3. The browser repaints if needed
  4. Your function resumes from exactly where it left off
  5. The entire process remains in one async function—no complex state management

Data table rendering

Let's build something practical, like rendering a large data table without freezing the UI. We'll compare both without scheduler.yield() and with scheduler.yield() so you can see the difference.

Create the HTML structure

Create a new file named index.html and add the following:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>scheduler.yield() Demo</title>
</head>
<body>
  <h1>scheduler.yield() Performance Demo</h1>
  <div class="indicator-label">
    Watch this animation & counter - they will freeze during blocking operations
    <span style="margin-left: 20px;">Counter: <strong id="counter">0</strong></span>
  </div>
  <div id="animation-indicator">
    <div id="moving-box"></div>
  </div>
  <div class="controls">
    <button id="render-blocking">Render 5000 Rows (Blocking)</button>
    <button id="render-yielding">Render 5000 Rows (Yielding)</button>
    <button id="clear">Clear Table</button>
  </div>
  
  <div id="progress-container">
    <div id="progress-bar"></div>
    <div id="progress-text">0%</div>
  </div>
  
  <div id="metrics">
    <div class="metric">Last operation: <strong id="last-time">-</strong></div>
    <div class="metric">Longest task: <strong id="longest-task">-</strong></div>
    <div class="metric">UI responsive: <strong id="responsive">-</strong></div>
  </div>
  
  <table id="data-table">
    <thead>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Email</th>
        <th>Department</th>
        <th>Salary</th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>
</body>
<script src="script.js"></script>
</html>

This sets up the structure for both tests: a table, buttons to trigger both approaches, and metrics to track how smooth the UI feels during rendering.

boilerplate.gif

The blocking Approach

Let's start with how most developers naturally write this code. Create a script.js file and add:


// Add a counter to visually show blocking
  let counter = 0;
    setInterval(() => {
      counter++;
      const counterElement = document.getElementById('counter');
      if (counterElement) {
        counterElement.textContent = counter;
      }
  }, 100);
  
  // Generate fake data
  function generateData(count) {
    const departments = ['Engineering', 'Sales', 'Marketing', 'HR', 'Finance'];
    const names = ['Alice', 'Bob', 'Carol', 'David', 'Eve', 'Frank', 'Grace', 'Henry'];
    
    return Array.from({ length: count }, (_, i) => ({
      id: i + 1,
      name: `${names[i % names.length]} ${String.fromCharCode(65 + (i % 26))}`,
      email: `user${i}@company.com`,
      department: departments[i % departments.length],
      salary: Math.floor(Math.random() * 100000) + 50000
    }));
  }
  
  // Blocking render
  function renderTableBlocking(data) {
    const tbody = document.getElementById('data-table tbody');
    const startTime = performance.now();
    let longestTask = 0;
    // Clear table
    tbody.innerHTML = '';
    // Measure the blocking task
    const taskStart = performance.now();
    data.forEach(row => {
      const tr = document.createElement('tr');
      tr.innerHTML = `
        <td>${row.id}</td>
        <td>${row.name}</td>
        <td>${row.email}</td>
        <td>${row.department}</td>
        <td>$${row.salary.toLocaleString()}</td>
      `;
      tbody.appendChild(tr);

      let dummy = 0;
      for (let i = 0; i < 5000; i++) {
        dummy += Math.sqrt(i) * Math.random();
      }
    });
    longestTask = performance.now() - taskStart;
    const totalTime = performance.now() - startTime;
    updateMetrics(totalTime, longestTask, 'No (blocked)');
  }

function updateMetrics(totalTime, longestTask, responsive) {
  document.getElementById('last-time').textContent = `${totalTime.toFixed(2)}ms`;
  document.getElementById('longest-task').textContent = `${longestTask.toFixed(2)}ms`;
  document.getElementById('responsive').textContent = responsive;
}

document.getElementById('render-blocking').addEventListener('click', () => {
  const data = generateData(5000);
  renderTableBlocking(data);
});

document.getElementById('clear').addEventListener('click', () => {
  document.getElementById('data-table tbody').innerHTML = '';
  updateMetrics(0, 0, '-');
});

Open your page and click "Render 5000 Rows (Blocking)". While it's rendering, you will see a pause in the animation, which means the page is frozen before it restarts after rendering

bolier.gif

Look at the metrics, you'll see something like 259.50ms-259.30ms for both the last operation and longest task. During that entire period, the browser couldn't respond to anything. This is what kills user experience and INP scores.

The scheduler.yield() approach

Now let's fix it. Add this code:

// The yielding approach
async function renderTableWithYield(data) {
  const tbody = document.getElementById('data-table tbody');
  const startTime = performance.now();
  const CHUNK_SIZE = 50; // Rows per chunk
  
  for (let i = 0; i < data.length; i += CHUNK_SIZE) {
    const chunk = data.slice(i, i + CHUNK_SIZE);
    
    // Render this chunk
    chunk.forEach(row => {
      const tr = document.createElement('tr');
      tr.innerHTML = `
        <td>${row.id}</td>
        <td>${row.name}</td>
        <td>${row.email}</td>
        <td>${row.department}</td>
        <td>${row.salary}</td>
      `;
      tbody.appendChild(tr);

        let dummy = 0;
          for (let i = 0; i < 5000; i++) {
            dummy += Math.sqrt(i) * Math.random();
        }
    });
    
    // Yield to the browser
    await scheduler.yield();
  }
  
  const duration = performance.now() - startTime;
  console.log(`Rendered ${data.length} rows in ${duration.toFixed(2)}ms`);
}

// Total time increases slightly (~900ms), but UI stays responsive
document.getElementById('render-yielding').addEventListener('click', async () => {
  const data = generateData(5000);
  await renderTableYielding(data);
});

What changed:

  • Total time increases from 259ms to ~518.00ms
  • The UI responds to the animation within 50-100ms throughout
  • INP stays under 100ms, which is good
  • The user perceives the app as faster because they can interact immediately

Why this works is that in each loop iteration, it processes 50 rows (~35ms of work), calls await yieldToMain(), the browser handles any clicks or events, the browser repaints the screen, and the loop resumes with the next chunk. 

The key is that no single task blocks the thread for more than 50ms. The browser gets regular opportunities to respond to user input, making the app feel fast even though total execution time increased slightly.

1.gif

Adding a Progress Indicator

Since we're yielding regularly, we can easily update progress:

async function renderTableWithProgress(data) {
  const tbody = document.getElementById('data-table tbody');
  const progressBar = document.getElementById('progress');
  const progressText = document.getElementById('progress-text');
  const CHUNK_SIZE = 50;
  
  for (let i = 0; i < data.length; i += CHUNK_SIZE) {
    const chunk = data.slice(i, i + CHUNK_SIZE);
    
    chunk.forEach(row => {
      const tr = document.createElement('tr');
      tr.innerHTML = `
        <td>${row.id}</td>
        <td>${row.name}</td>
        <td>${row.email}</td>
        <td>${row.department}</td>
        <td>${row.salary}</td>
      `;
      tbody.appendChild(tr);
    });
    
    // Update progress
    const progress = ((i + CHUNK_SIZE) / data.length) * 100;
    progressBar.style.width = `${Math.min(progress, 100)}%`;
    progressText.textContent = `Loading: ${Math.min(Math.round(progress), 100)}%`;
    
    await scheduler.yield();
  }
  
  progressBar.style.width = '100%';
  progressText.textContent = 'Complete!';
}

This would be nearly impossible with the blocking approach; you'd see 0% jump to 100% instantly. 

Now add some CSS to make the progress bar visible. Add this to your <head>:

<style>
  body {
    font-family: system-ui, sans-serif;
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
  }
  
  .controls {
    display: flex;
    gap: 10px;
    margin-bottom: 20px;
  }
  
  button {
    padding: 12px 24px;
    background: #0066cc;
    color: white;
    border: none;
    border-radius: 6px;
    cursor: pointer;
  }
  
  button:hover {
    background: #0052a3;
  }
  
  #progress-container {
    width: 100%;
    height: 40px;
    background: #e0e0e0;
    border-radius: 8px;
    margin-bottom: 20px;
    position: relative;
    display: none;
    overflow: hidden;
  }
  
  #progress-bar {
    height: 100%;
    background: #4CAF50;
    width: 0%;
    transition: width 0.2s;
  }
  
  #progress-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    font-weight: bold;
  }
  
  #metrics {
    padding: 20px;
    background: #f5f5f5;
    border-radius: 8px;
    margin-bottom: 20px;
  }
  
  .metric {
    margin: 8px 0;
  }
  
  #data-table {
    width: 100%;
    border-collapse: collapse;
    background: white;
  }
  
  #data-table th,
  #data-table td {
    padding: 12px;
    border: 1px solid #ddd;
    text-align: left;
  }
  
  #data-table th {
    background: #f8f9fa;
    font-weight: 600;
  }
</style>

Real measurement: Before and after

Let's measure the actual INP impact so you can see the difference in Chrome DevTools. 

Let’s say what if the user starts a new render while one is already running? If the user types "john" quickly, you might start rendering results for "j", then "jo", then "joh", then "john", all overlapping. Here's a search filter that processes results to test this out:

Without scheduler.yield()


function filterAndRenderBlocking(items, searchTerm) {
  const startTime = performance.now();
  
  // Filter
  const filtered = items.filter(item => 
    item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
    item.description.toLowerCase().includes(searchTerm.toLowerCase())
  );
  
  // Sort
  filtered.sort((a, b) => a.relevance - b.relevance);
  
  // Render
  const container = document.querySelector('#results');
  container.innerHTML = '';
  filtered.forEach(item => {
    const div = document.createElement('div');
    div.className = 'result-item';
    div.innerHTML = `
      <h3>${item.name}</h3>
      <p>${item.description}</p>
      <span class="score">${item.relevance}</span>
    `;
    container.appendChild(div);
  });
  
  console.log(`Completed in ${performance.now() - startTime}ms`);
}

// With 10,000 items: ~563.30ms blocked time
searchInput.addEventListener('input', (e) => {
  filterAndRenderBlocking(allItems, e.target.value);
});

Using Chrome DevTools Performance panel, this shows:

  • Total time -  563.30ms
  • Longest task - 563.30ms (the entire operation is one blocking task)
  • INP - 2248ms (red - Poor)
  • UI responsive: No (blocked)

During those 563.30ms, the browser cannot process any user input. The animation freezes, clicks and interaction don't register, and the INP score suffers because input events queue up behind the blocking task.

2.gif

With schedule.yield()


async function filterAndRenderYielding(items, searchTerm) {
  const startTime = performance.now();
  const container = document.querySelector('#results');
  container.innerHTML = '';
  
  const CHUNK_SIZE = 100;
  const filtered = [];
  
  // Filter in chunks
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => {
      if (item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
          item.description.toLowerCase().includes(searchTerm.toLowerCase())) {
        filtered.push(item);
      }
    });
    await scheduler.yield();
  }
  
  // Sort (this is fast enough to not need yielding with this dataset)
  filtered.sort((a, b) => a.relevance - b.relevance);
  
  // Render in chunks
  for (let i = 0; i < filtered.length; i += CHUNK_SIZE) {
    const chunk = filtered.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => {
      const div = document.createElement('div');
      div.className = 'result-item';
      div.innerHTML = `
        <h3>${item.name}</h3>
        <p>${item.description}</p>
        <span class="score">${item.relevance}</span>
      `;
      container.appendChild(div);
    });
    await scheduler.yield();
  }
  
  console.log(`Completed in ${performance.now() - startTime}ms`);
}

// With 10,000 items: ~181.00ms total, but broken into small tasks
searchInput.addEventListener('input', (e) => {
  filterAndRenderYielding(allItems, e.target.value);
});

Type quickly in the search box. Notice how previous renders get cancelled automatically? Only the latest search completes. Here are the measurement results:

  • Total time - 181.00ms (It can also take a longer time overhead due to yielding)
  • Longest task - 6.80ms (huge reduction in longest task)
  • INP- 186ms (green - Good)
  • UI responsive: Yes

The user experience is dramatically better. The UI remains responsive throughout because no single task gets blocked for long, the animation continues smoothly, and user interactions are processed immediately. The user experiences this as dramatically faster.

3.gif

Here’s a link to the full code on GitHub

Advanced pattern

Adaptive yielding

Not all operations are equal. Sometimes you want to yield more or less frequently based on the work being done:


async function processWithAdaptiveYield(items) {
  const results = [];
  let operationCount = 0;
  const YIELD_INTERVAL = 50; // Yield after 50ms of work
  let lastYieldTime = performance.now();
  
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveOperation(items[i]));
    operationCount++;
    
    // Check if 50ms has passed since last yield
    const elapsed = performance.now() - lastYieldTime;
    if (elapsed > YIELD_INTERVAL) {
      await scheduler.yield();
      lastYieldTime = performance.now();
      console.log(`Yielded after ${operationCount} operations (${elapsed.toFixed(2)}ms)`);
      operationCount = 0;
    }
  }
  
  return results;
}

The reason this matters is that if each operation is fast (1ms), yielding every 10 items is wasteful. If each operation is slow (20ms), yielding every 10 items means 200ms blocks. Time-based yielding adapts automatically.

Prioritizing user-initiated work

User-initiated actions (like clicking a button) should take priority over background work (like preloading data). You can build a simple priority system:


class TaskScheduler {
  constructor() {
    this.queue = [];
    this.running = false;
  }
  
  async schedule(task, priority = 0) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, priority, resolve, reject });
      this.queue.sort((a, b) => b.priority - a.priority); // Higher priority first
      this.processQueue();
    });
  }
  
  async processQueue() {
    if (this.running || this.queue.length === 0) return;
    
    this.running = true;
    
    while (this.queue.length > 0) {
      const { task, resolve, reject } = this.queue.shift();
      
      try {
        const result = await task();
        resolve(result);
      } catch (error) {
        reject(error);
      }
      
      // Yield between tasks
      await yieldToMain();
    }
    
    this.running = false;
  }
}

const scheduler = new TaskScheduler();

// Usage
document.querySelector('#high-priority-btn').addEventListener('click', () => {
  scheduler.schedule(async () => {
    console.log('High priority task running');
    await renderTableYielding(generateData(1000));
  }, 10); // High priority
});

document.querySelector('#low-priority-btn').addEventListener('click', () => {
  scheduler.schedule(async () => {
    console.log('Low priority task running');
    await preloadAdditionalData();
  }, 1); // Low priority
});

This ensures user actions always jump to the front of the queue, even if background tasks are already queued.

Cross-browser fallback

scheduler.yield() isn't universally supported yet. Here's a production-ready abstraction:


// Feature detection and fallback
const yieldToMain = (() => {
  if ('scheduler' in window && 'yield' in scheduler) {
    return () => scheduler.yield();
  }
  
  // Fallback to setTimeout
  return () => new Promise(resolve => setTimeout(resolve, 0));
})();

// Usage remains identical
async function processItems(items) {
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    // ... do work ...
    await yieldToMain();
  }
}

For even better fallback behavior, you can use requestIdleCallback with a timeout:

const yieldToMain = (() => {
  if ('scheduler' in window && 'yield' in scheduler) {
    return () => scheduler.yield();
  }
  
  if ('requestIdleCallback' in window) {
    return () => new Promise(resolve => {
      requestIdleCallback(resolve, { timeout: 50 });
    });
  }
  
  return () => new Promise(resolve => setTimeout(resolve, 0));
})();

Common mistakes and how to avoid them

Mistake 1: Yielding inside tight loops
// DON'T DO THIS
async function processItemsBadly(items) {
  for (const item of items) {
    await processItem(item);
    await scheduler.yield(); // Yielding after EVERY item!
  }
}

If you have 10,000 items and each takes 1ms, you'll yield 10,000 times. The overhead from yielding (even if small) adds up to seconds of wasted time.

// DO THIS INSTEAD
async function processItemsWell(items) {
  for (let i = 0; i < items.length; i++) {
    await processItem(items[i]);
    
    if (i % 100 === 0) { // Yield every 100 items
      await scheduler.yield();
    }
  }
}
Mistake 2: Not handling errors
// FRAGILE - Errors break the entire operation
async function renderWithoutErrorHandling(data) {
  for (let i = 0; i < data.length; i += CHUNK_SIZE) {
    renderChunk(data.slice(i, i + CHUNK_SIZE)); // What if this throws?
    await scheduler.yield();
  }
}

// ROBUST - Errors are handled gracefully
async function renderWithErrorHandling(data) {
  const errors = [];
  
  for (let i = 0; i < data.length; i += CHUNK_SIZE) {
    try {
      renderChunk(data.slice(i, i + CHUNK_SIZE));
    } catch (error) {
      errors.push({ index: i, error });
      console.error(`Error rendering chunk ${i}:`, error);
    }
    await scheduler.yield();
  }
  
  if (errors.length > 0) {
    console.warn(`Completed with ${errors.length} errors`);
  }
}
Mistake 3: Forgetting about memory

When processing huge datasets, you might run into memory issues:

// MEMORY HOG - Creates thousands of DOM elements at once
async function renderAllAtOnce(data) {
  const fragment = document.createDocumentFragment();
  
  for (let i = 0; i < data.length; i++) {
    const element = createComplexElement(data[i]);
    fragment.appendChild(element); // All staying in memory
    
    if (i % 50 === 0) await scheduler.yield();
  }
  
  document.body.appendChild(fragment); // Finally added to DOM
}

// MEMORY EFFICIENT - Adds to DOM in chunks
async function renderInChunks(data) {
  const container = document.querySelector('#container');
  
  for (let i = 0; i < data.length; i += 50) {
    const fragment = document.createDocumentFragment();
    const chunk = data.slice(i, i + 50);
    
    chunk.forEach(item => {
      const element = createComplexElement(item);
      fragment.appendChild(element);
    });
    
    container.appendChild(fragment); // Add each chunk to DOM
    await scheduler.yield();
  }
}
When and when NOT to use scheduler.yield()

When deciding whether to use scheduler.yield(), think about the task you are handling. This method is helpful when dealing with large datasets, rendering many DOM elements, performing complex calculations in loops, or processing user-generated content, like parsing and validation. If a task takes longer than 50 milliseconds, you should use scheduler.yield() to improve performance.

However, not every task needs yielding. If a task is expected to finish in less than 50 milliseconds or if you are already using a Web Worker, you can skip this method. Also, avoid using it for tasks that need to be completed in one go, like database transactions or important calculations, where it's essential to finish them without interruption.

// This doesn't need yielding - it's already fast
function quickCalculation(numbers) {
  return numbers.reduce((sum, n) => sum + n, 0);
}

// This definitely needs yielding - lots of DOM work
async function buildComplexUI(data) {
  for (const section of data) {
    renderSection(section);
    await scheduler.yield();
  }
}

Conclusion

scheduler.yield() gives you a simple, performant way to keep your UI responsive during heavy operations. You break up long tasks without sacrificing code clarity or adding much overhead.

The API is straightforward, the fallback is simple, and the performance gains are measurable. If you're building JavaScript-heavy applications and care about user experience, this should be in your toolkit.

The web is getting more interactive and complex. Tools like scheduler.yield() help us build that complexity without sacrificing the snappy, responsive feel users expect. Start using it today, your INP scores will thank you.

Customize your view

Manage your font size, color, and background

Font size

Aa

Aa

Color

Background

Light
Dim
Dark