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 yieldrequestIdleCallback()- Only runs when the browser is idle, not during active interactionrequestAnimationFrame()- 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:
- Every 10 iterations,
scheduler.yield()pauses execution - The browser processes any pending input events (clicks, scrolls, typing)
- The browser repaints if needed
- Your function resumes from exactly where it left off
- 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.
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
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.
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.
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.
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.