Introduction.
Modern web apps can feel heavy. Sometimes, just one long-running JavaScript function is enough to freeze the interface, leaving users frustrated and unsure whether the app is still working or has frozen. The new Prioritized Task Scheduling API introduces a small but powerful method: scheduler.yield(). It allows a user to pause execution, giving the browser a chance to handle more important tasks (such as clicks or typing), and then continue right where they left off. In this article, you’ll look at the problem of blocking the Main Thread, explore old workarounds, and see how scheduler.yield()
makes life easier.
What is scheduler.yield()?
So, what is scheduler.yield()
? It’s a method of the Scheduler interface from the new Prioritized Task Scheduling API. This method allows you, as a developer, to pause your JavaScript execution and explicitly yield control back to the Main Thread – so it can handle other pending important tasks, like user interactions, clicks, typing, etc., and then continue execution from where you left off. In simpler terms, when you call scheduler.yield()
, you're telling the browser:
"Wait, take a breath, let's pause the current task and focus on other no less or more important tasks. Once you've done, come back and continue execution from where we left off."
This makes your page more responsive, especially when running long or heavy JavaScript tasks. It can also help improve metrics like Interaction to Next Paint (INP) – which is all about how quickly the browser responds to user input.
Terminology.
Before you dive deeper, let’s quickly go over a few basic terms that will be used throughout the article.
- Main Thread – This is the central place where the browser does most of its work. It handles rendering, layout, and runs most of your JavaScript code.
- Long task – This is any JavaScript task that keeps the Main Thread busy for too long, usually more than 50 milliseconds. When that happens, the page can freeze or feel unresponsive.
- Blocking task – Is a synchronous operation on the Main Thread that prevents the browser from processing other important things, like responding to clicks or updating the UI. Usually, long tasks are blocking tasks.
The Problem.
To understand the beauty of scheduler.yield()
, you first need to understand what problem it's trying to solve. JavaScript runs on a single thread. That means it can only do one thing at a time. If your code keeps the thread busy, everything else – rendering, button clicks, input typing has to wait. In an ideal world, you’d always split heavy tasks into small pieces. But reality is messy. You deal with legacy code, third-party scripts, or unavoidable heavy computations. And when that happens, users get stuck with frozen pages.
Task Processing in the Browser.
As a quick refresher, here's an example diagram of how JavaScript processes tasks – in other words, how the Task Processing in the Browser works. I’m sure many of you have seen diagrams like this before – the task queues, the event loop, the call stack. This one isn’t perfect, but it gives you the big picture.
Please note: the "Event Loop" itself is not a part of the JavaScript (ECMAScript) specification. If you look into the ECMAScript spec, you won’t find it there at all. The Event Loop is defined in the HTML Standard.
Let’s walk through the main ideas step by step:
- All synchronous code goes straight to the Call Stack and runs line by line, function by function. It follows the LIFO principle – last in, first out. JavaScript runs in a single thread, which means it can do only one thing at a time.
- Asynchronous operations (like
setTimeout
,fetch
) are handled outside the Main Thread – by the Web APIs (provided by the browser or environment). Once they’re done, they don’t go back directly into the Call Stack. Instead, their callbacks are queued – either in the microtasks queue (e.g.Promise.then
,queueMicrotask
) or the task queue (e.g.setTimeout
,setInterval
). - When the Call Stack is empty, the Event Loop checks the microtasks queue and runs all microtasks one by one in order.
- Only after that, it takes one runnable task from the chosen tasks queue. Importantly, task queues are sets, not strict FIFO queues: the event loop picks the first task that is ready to run, not necessarily the one that was added first.
- If, during the process, new microtasks are added, they are run before the next task from task queue. So microtasks always get priority.
- This loop keeps going: all microtasks → one task from task queue → repeat.
- New synchronous code gets into the Call Stack when new tasks arrive, like a user clicking a button, a new script being run, or when a microtask or task from task queue runs its callback.
This is a very brief and superficial explanation, just to remind you how it works. Since it will further closely intersect with the topic.
The Problem Description.
Now that you’ve refreshed your understanding of how JavaScript executes tasks, let’s take a closer look at the real problem that comes with this model. The issue is simple: when a task takes too long on the main thread, it blocks everything else – user interactions, rendering updates, and animations. This leads to UI freezes and poor responsiveness. The obvious first thought might be: "Well, just don't write long or heavy functions, and that's it. The problem is solved!". And yes, that’s true – in an ideal world, you’d always split heavy code into smaller parts, optimize everything, and avoid blocking the main thread. But let’s be honest – many of us have run into these issues, even if we weren’t the ones who originally caused them. Even if you were not the "culprit" of this behavior, you have to work with it. To make this more concrete, let’s simulate a simple but realistic case. Imagine you have to process a large array, and each element requires some non-trivial computation – something that takes time and uses CPU, which in turn blocks the main thread.
For this, we’ll create a function called blockingTask()
that acts as a blocking task for the main thread for the specified period of time. The function simulates this kind of "heavy" computation on each element of the array.
Unfortunately code blocks don’t display line numbers. In my explanations, I sometimes refer to specific lines (e.g., "line 5 does X"). To follow along, just count lines from the top of each code block.
function blockingTask(ms = 10) {
const arr = [];
const start = performance.now();
while (performance.now() - start < ms) {
// Perform pointless computation to block the CPU.
arr.unshift(Math.sqrt(Math.random()));
}
return arr;
}
There is nothing fancy about the function, here is all it does:
- It accepts an argument – the number of milliseconds. This is the minimum time the function will run, thus occupying the main thread.
- It creates an empty array.
- It creates a start time (as a current time).
- Then runs a
while
loop until the specified time has passed. - Inside the loop, it just does random, meaningless calculations to simulate load.
- Finally, it returns the result of the calculations.
The function doesn’t do anything useful, but it does simulate a real-world scenario of heavy load. This function will be used inside another simple function.
Imagine a common situation where you need to loop through an array of data and apply that heavy work to each item.
To do this, we will create a heavyWork()
function:
function heavyWork () {
const data = Array.from({ length: 200 }, (_, i) => i)
const result = []
for (let i = 0; i < data.length; i++) {
result.push(blockingTask(10))
}
return result;
}
In which the following happens:
- On line 2, it creates an array of 200 items, just numbers from 0 to 199. I want to note that 200 items are not that many, but it will be enough to see the essence of the problem.
- Then, a new empty "result" array is created to store the processed values.
- Line 5 declares a loop that goes through the entire length of the data array.
- Inside the loop, we run the
blockingTask()
function, simulating 10 milliseconds of work for each element, and the result is added to the "result" array. Once again, I want to remind you that, for the demo, theblockingTask()
function does not carry any semantic load. It simply performs some imaginary, resource-intensive work. In the real world, it could be some labor-intensive processing of an array element. - Finally, it returns the resulting array.
And that's where the amazing part comes in. Just 10 milliseconds per element, and only 200 elements – but together, they block the main thread for 2 full seconds. That’s enough to cause a noticeable freeze in the UI. No clicks, no typing – just a frozen page.
The Problem Demonstration.
Now it’s time to look at the problem not just in theory, but in action. This is not a full-fledged demo just yet – think of it as a simplified visual to help you clearly see the issue.
Here’s what you see:
- The left window, titled "Configuration", lets you turn the main thread blocking on and off – meaning whether the
blockingTask()
function is actually running. You can also togglescheduler.yield()
functionality – we'll get to that part later. - The window titled "Heavy Task" runs the
heavyWork()
function. This is the one that processes an array usingblockingTask()
on each element if the main thread blocking is enabled. - And the window titled "Logger" just logs the current time to the console, including milliseconds.
Let’s see what happens when the Main Thread blocking is turned off, so the tasks are very light. It's just a loop over an array of 200 elements, without any complex calculations.
What you observe:
- The user clicks the "OK" button – the
heavyWork()
function runs, and instantly returns. This is indicated by the messageHEAVY_TASK_DONE
in the console, followed by the result – an array of numbers. - Then the user clicks the "Log" button three times, to log the current time to the console – timestamps appear immediately, with a slight difference in time.
- User runs the
heavyWork()
function again, and again, instant response. - Finally, the user closes two windows, which actually just removes those elements from the DOM. No delays, no hiccups.
In this case, everything feels fast and responsive. The browser has no trouble handling the interactions, because the main thread stays free. Tasks are performed almost instantly and consistently.
Now, let’s enable the Main Thread blocking, so that for each element of the array, the blockingTask()
function would be called with a delay of only 10 milliseconds.
And now you can observe that user interaction with UI elements has become less smooth, UI freezes have appeared. Let's break it down to what is happening here and what you can observe from it:
- The user presses the "OK" button, thereby launching the
heavyWork()
function. And the first lag that occurs is that the "OK" button visually stays pressed. Why? Because the browser can’t repaint whileheavyWork()
is still blocking the main thread. And it is important to understand that we are talking not only about the current task, but about the call stack as a whole. - During this time, the user clicks the "Log" button four times – nothing happens. The clicks are registered and their handlers added to the queue, but the browser can’t react. Only after
heavyWork()
finishes do you see the console output: first theheavyWork()
result, then the four timestamps – all printed in a batch. And only after that, the "OK" button changed its state and became unpressed. - Next, the user clicks the "OK" button again. Same behavior – stuck button. Then, while the
heavyWork()
task is running, he tries to close a window by clicking the "X" icon three times. Again, no visual response. Only once the task ends do we see the window disappear. - And finally, one more attempt to run
heavyWork()
and close the last window. Same freeze.
What does this show? This simple demo shows how long tasks block the browser’s ability to respond to user actions. Even though each blocking call takes just 10 milliseconds, chaining 200 of them together results in a 2-second freeze. The user can’t interact with buttons, the interface doesn't repaint. Events get queued up, but not processed until the call stack is clear. This is not just a performance problem – it’s a user experience problem. And that’s exactly the kind of issue we want to solve – ideally, without having to manually split our logic into dozens of callbacks.
The Problem Solution.
Now that you understand the problem, let’s talk about possible solutions. Of course, the best strategy is to avoid long tasks in the first place by keeping code efficient and breaking things up early. But, as you've seen, stuff happens. Whether it's legacy code, unavoidable computations, or just not enough time to optimize, sometimes, you have to deal with it. Over the years, before the Prioritized Task Scheduling API appeared, various workarounds and tricks have been come up with to improve responsiveness. But the core idea behind all of them – and behind scheduler.yield()
as well – is pretty simple:
- Break a task into smaller pieces or so-called chunks.
- And once in a while, pause to let the browser catch its breath.
In other words, you give the main thread a chance to run more urgent tasks, like user interactions or rendering updates, and then you come back to finish your own work.
Here's what the concept of heavyWork()
function looks like in pseudocode:
function heavyWork() {
// Do heavy work...
/**
* Take a breather!
* Yield the execution to the Main Thread...
* */
// Continue to do heavy work...
}
What’s happening here:
- You run a chunk of your task.
- Then, you pause, allowing the browser to handle other high-priority tasks (like UI updates).
- Continue executing the function from where it left off.
Old Problem-Solving Approaches.
Before scheduler.yield()
came along, the most common trick for dealing with long blocking tasks was to use setTimeout()
. By calling it with a 0 (zero) delay, you add its callback task to the end of the tasks queue, allowing other tasks to run first. In other words, you tell the browser:
"Run this bit of code later, after you’ve handled everything else".
That’s how you can give the main thread a short breather between chunks of heavy work. Here’s what the updated heavyWork()
function might look like using this approach:
async function heavyWork() {
// Yield to Main Thread to avoid UI blocking before heavy work
await new Promise(resolve => setTimeout(resolve, 0))
const data = Array.from({ length: 200 }, (_, i) => i)
const result = []
// Interval at which execution will be yielded to the main thread (approx. ~ 25%).
const yieldInterval = Math.ceil(data.length / 4)
for (let i = 0; i < data.length; i++) {
// Yield control to Main Thread to update UI and handle other tasks.
if (i % yieldInterval === 0) {
await new Promise(resolve => setTimeout(resolve, 0))
}
result.push(threadBlockingEnabled ? blockingTask(10) : data[i])
}
return result
}
Let’s break down what’s going on here:
- Line 3: A
Promise
is created and its executor runs immediately, scheduling asetTimeout()
with zero delay. The timeout’s callback (which resolves thePromise
) is added to the end of the tasks queue. Because ofawait
, the rest of theasync
function is paused. Technically, this continuation is added to the microtask queue, waiting for thePromise
to resolve. The JavaScript engine checks the Call Stack – once it’s empty, the Event Loop kicks in. First, it looks at the microtask queue – but since thePromise
isn’t resolved yet, there’s nothing to run. Then, the Event Loop picks the task from the queue (in our example, it is thesetTimeout()
callback), runs it, and this resolves thePromise
. Now that thePromise
is resolved, the microtask containing the continuation of theasync
function is run. In simple terms, line 3 gives the browser a chance to "catch its breath" before heavy work begins. Therefore, by calling this method before doing any heavy work, gives a browser a moment to re-render UI updates, such as unfreezing a clicked button. - Line 9: We calculate how often we want to yield to the Main Thread, roughly every 25% of the work. This number can vary depending on how heavy the task is.
- Lines 13-15: Inside the loop, if the condition for yielding interval is met, execution is transferred to the main thread, that is, the
setTimeout()
technique is repeated, allowing the browser process user interactions or redraw the interface.
Essentially, this approach works – it’s relatively simple and does improve responsiveness. But there are trade-offs. One big issue is that setTimeout()
isn't built for precise scheduling. It puts tasks at the end of the tasks queue, and anything already in that queue can delay your continuation.
For example, let’s say some other part of the page uses setInterval()
to run tasks regularly:
setInterval(() => { /* Another heavy work... */ })
async function heavyWork() {
// Yield to Main Thread to avoid UI blocking before heavy work
await new Promise(resolve => setTimeout(resolve, 0))
const data = Array.from({ length: 200 }, (_, i) => i)
const result = []
// Interval at which execution will be yielded to the main thread (approx. ~ 25%).
const yieldInterval = Math.ceil(data.length / 4)
for (let i = 0; i < data.length; i++) {
// Yield control to Main Thread to update UI and handle other tasks.
if (i % yieldInterval === 0) {
await new Promise((resolve, reject) => setTimeout(resolve, 0))
}
result.push(threadBlockingEnabled ? blockingTask(10) : data[i])
}
return result
}
Now your own task – the next chunk of heavyWork()
function – might get delayed by one or more of these interval callbacks. The browser just runs whatever is next in line, and you don’t control the order. So while setTimeout()
sort of lets you yield, you don’t know exactly when you’ll get control back.
There are other ways to approach the situation. It could be the requestAnimationFrame()
function, which lets you schedule work right before the next repaint. Often used in conjunction with setTimeout()
, and has similar drawbacks. Or requestIdleCallback()
that runs your code during a browser idle time. It’s not quite an alternative, but good for background, less important work, that helps the main thread to be free for more critical tasks. In general, we could discuss other strategies for solving and preventing such problems. However, to stay on topic, let’s move on and see what scheduler.yield()
brings to the table.
Scheduler.yield().
scheduler.yield()
– is a modern way to pause execution, and yield control to the main thread, which allows the browser to perform any pending high-priority work, and then continue execution from where it left off. What actually happens under the hood? When the await scheduler.yield()
expression is reached, the execution of the current function in which it was called is suspended, and yields control to the main thread, thereby breaking up, or pausing, the current task. The continuation of the function, that is, the execution of the remaining part of it, from where it left off, is a separate, newly scheduled microtask in the Event Loop.
The beauty of scheduler.yield()
is that the continuation after scheduler.yield()
remains at the front of the queue, and is scheduled to run BEFORE any other non-essential tasks that have been queued. The key difference from the setTimeout()
approach is that with setTimeout()
, these continuations typically run after any new tasks that have already been queued, potentially causing long delays between yielding to the main thread and their completion.
The following diagram illustrates how the three approaches compare in practice:
-
In the first example, without yielding to the main thread: At first, the long "Task 1" runs uninterrupted, blocking the main thread and UI accordingly. Then, a user event is processed – a button click triggered during the execution of "Task 1". And finally, "Task 2" is executed –
setTimeout()
callback scheduled earlier or during the execution of the long task. -
In the second example, using
setTimeout()
as a yielding to the main thread: The execution queue is different. At first, the long "Task 1" runs. Then, when the yield to the main thread happens, "Task 1" pauses to let the browser breathe, and the button click is processed. But after the button click is processed, thesetTimeout()
callback will be executed first, which could have been scheduled in advance or during the execution of "Task 1". And finally, only after that, the continuation of "Task 1" will be executed. -
In the last example, using
scheduler.yield()
: After the long "Task 1" has been paused and the user click event has been processed, then the continuation of "Task 1" is prioritized and runs before any queuedsetTimeout()
tasks.In summary,
scheduler.yield()
is a more intelligent and predictable way to give the main thread breathing room. It avoids the risk of your code being pushed too far back in the queue and helps maintain performance and responsiveness, especially in complex applications.
Priorities.
So, what causes such a difference in behavior? It's all about priorities! As developers, we don't usually think about the order of execution of tasks in the event loop in terms of priorities. More precisely, we have a good understanding of what microtasks and task queues are, and the order in which they run. But if you look deeper, you’ll notice that there are also implicit priorities at play. For example, a button click handler, fired by user action, will typically execute before a setTimeout()
callback, even though both are tasks from the tasks queue. As mentioned earlier, scheduler.yield()
is a part of the Prioritized Task Scheduling API – an extensive and feature-rich interface that deserves its own separate full-fledged discussion and is clearly beyond the scope of this talk. Nevertheless, it is important to mention one of its key features: the introduction of a clear task priority model. Prioritized Task Scheduling API simply makes these priorities explicit, making it easier to determine which task will run first, and enables adjusting priorities to change the order of execution, if needed. Here is a quick look at the main priority levels it defines:
- "user-blocking" – The highest priority tasks that directly affect user interaction, such as handling clicks, taps, and critical UI operations.
- "user-visible" – Tasks that affect UI visibility or content, but are not critical for immediate input.
- "background" – Tasks that are non-urgent, and can be safely postponed without affecting the current user experience, and are not visible to the user.
By default, scheduler.yield()
has a "user-visible" priority. Also, Prioritized Task Scheduling API exposes the postTask()
method, designated to schedule tasks with a specified priority from the above. While it won't go into details about this method here, it is worth mentioning that if scheduler.yield()
was scheduled from within a postTask()
, it inherits its priority.
How to use scheduler.yield().
Once you understand how it all works – the types of tasks, the problem caused by long blocking operations, and the priorities, the use of scheduler.yield()
becomes straightforward. But it should be used wisely and with due caution. Here is an updated version of the heavyWork()
function using scheduler.yield()
. Now, instead of setTimeout()
, you just need to call await scheduler.yield()
, and the rest part remains unchanged.
async function heavyWork() {
// Yield to Main Thread to avoid UI blocking before heavy work
await scheduler.yield()
const data = Array.from({ length: 200 }, (_, i) => i)
const result = []
// Interval at which execution will be yielded to the main thread (approx. ~ 25%).
const yieldInterval = Math.ceil(data.length / 4)
for (let i = 0; i < data.length; i++) {
// Yield control to Main Thread to update UI and handle other tasks.
if (i % yieldInterval === 0) {
await scheduler.yield()
}
result.push(threadBlockingEnabled ? blockingTask(10) : data[i])
}
return result
}
Now, when a user starts a heavyWork()
function using scheduler.yield()
, the difference is immediately noticeable. Firstly, the "OK" button does not stick, and secondly, user click events on the "Log" button are successfully processed, which does not block the user's interaction with the page.
That is, at first, the heavyWork()
function was launched, and the button was re-rendered without sticking. While this heavy task was being executed, the user pressed the "Log" button. The event was processed successfully, and the data was printed to the console. Then the heavyWork()
function continued, and its final result was printed to the console. After the completion, the user pressed the "Log" button again. In short, you can give your browser a break with just one line.
Demo.
Functionality Description.
Now that you’ve explored the theory, let’s move on to practice and look at a real working demo. This is a simulated banking application. Of course, it’s fictional and simplified, but it captures just enough of the real-world complexity to help you understand how blocking the main thread affects interactivity, and how scheduler.yield()
can help.
Here’s what the user sees in the interface:
-
Balance section – By default, the account balance is hidden behind a placeholder of asterisks. This is a familiar pattern in real banking apps, where sensitive information is hidden unless explicitly revealed by the user. A button labeled "Show balance" toggles visibility.
-
Bank card – A visual representation of a bank card, shown front side by default, where some details are displayed: card type in the top left corner, last 4 digits of the card, the cardholder's name, and payment system, at the bottom right corner of the card. There are two buttons to the right of the card:
-
Show card details – which flips the card when clicked. The back side of the card reveals sensitive card data like its full number, expiration date, and CVV code. Although the card number is generally not considered private information, some applications still prefer not to show the full number by default, but only if the user initiates it. However, I know and even use banks that generally do not allow you to see the bank card number in the application.
-
Generate report – by clicking this button, this feature supposedly generates a list of transactions on the card and displays them in the table below. It imitates the real functionality where users can generate reports on bank card transactions. In reality, these reports can be complex tables with many customizable filters and the ability to download the report as a file. Such operations might involve heavy computations, process a huge amount of data, making them resource-intensive and time-consuming. For the sake of the demo, it's simplified. Under the hood, the "Generate report" button triggers the previously discussed
heavyWork()
function, which simply blocks the main thread using theblockingTask()
function, which was also discussed above. After that, static mock transaction data is simply rendered into the table.
-
The behavior of the application can be customized using the various settings on the configuration panel on the left side. You may have noticed its simplified version in earlier screenshots.
Now it’s time to explain what it does:
- Main Thread blocking – determines whether the Main Thread will be blocked. In fact, when this option is enabled, the
blockingTask()
function is executed. - Scheduler.yield() – Toggles whether
scheduler.yield()
is used. - Data array length – Controls how many elements are iterated by the
heavyWork()
function. The more elements, the longer it takes. - Blocking time duration – Specifies how many milliseconds each element of the array takes to process.
- Yield interval – Defines how often
scheduler.yield()
is called, as a percentage of progress through the array. That is, the lower this number, the more often it will be called. In earlier examples, we used a 200-element array with a 10ms delay and a 25% interval – a good balance for visible impact without excessive delay. With larger datasets, a smaller interval is often better. But, as always, it depends.
Demonstration.
Having sorted out all the functionality and configuration, let’s walk through a real usage scenario and see how blocking the main thread affects user experience. To start, we enable Main Thread blocking and disable scheduler.yield()
. We'll also increase the array length a bit, so the heavy operation takes longer, giving us time to observe the effects. So, the user clicks the "Generate report" button. Behind the scenes, this triggers the heavyWork()
function, which processes 1000 elements, where each element takes 10 milliseconds.
Watch what happens: The "Generate report" button stays stuck, it doesn’t unpress, and the UI doesn’t re-render. While the report is being generated, the user tries to click the "Show card details" then "Show balance" buttons, but nothing responds. The interface is completely frozen, there's no animation, no feedback, no sense of progress. This is a classic example of a bad user experience. The app appears frozen, even though it's technically still working. The user doesn’t know whether to wait or reload the page.
Let's address these shortcomings using scheduler.yield()
by adjusting some configurations. Here is how the configuration now looks: The Main Thread is still blocked. This time, the option to use scheduler.yield()
is enabled. The array length is slightly increased, just for clarity. The blocking time remains the same, 10 milliseconds. The scheduler.yield()
response interval is reduced to 5% for smoother responsiveness, since the array length has been increased.
And now with the updated configuration, the same user flow looks completely different. The first thing that catches the eye is that after the "Generate report" button has been clicked, it re-renders correctly, and the loading animation appears. While the report is being generated, the user successfully interacts with the UI: they can flip the card and toggle the balance. The application remains responsive, even if the animations are slightly less smooth, it’s a huge step forward compared to the previous freeze. This is a much better experience. The user is informed, in control, and not left guessing whether the app is working. And all it took was… one method call: scheduler.yield()
. Of course, the actual implementation can be further optimized, but even in this simple form, the difference is striking.
Conclusion.
So, today you learned about giving your browser a break, the importance of yielding to the Main Thread to perform higher priority tasks, and the advantages and disadvantages of these techniques. There are certainly more nuances to cover, and the Prioritized Task Scheduling API has other capabilities that were not covered in this article. But my goal was to give you a solid foundation, enough to start experimenting, and enough to start thinking differently about how your code plays with the browser. Thanks for reading, and give your browser a break once in a while!
Useful Links.
- Online demo – https://let-your-browser-take-a-breather.onrender.com/
- Demo GitHub repository – https://github.com/WOLFRIEND/let_your_browser_take_a_breather
- Diagram – https://drive.google.com/file/d/1FLKKPaseyypE3pVXXn7Cj0aWac3rCayn/view