Skip to content
DERKONLINE

Break Up Long Tasks So Your Main Thread Stays Responsive

Use scheduler.yield and web workers to chop blocking work into chunks so the main thread answers every tap instantly.

Derrick S. K. Siawor7 min read

A web page has one main thread, and that thread does everything: runs your JavaScript, handles clicks, and paints the screen. It can only do one of those at a time. So when a chunk of your JavaScript runs for 300 milliseconds straight, the page cannot respond to a tap, cannot show a hover state, cannot paint anything new, for that entire 300 milliseconds. The user clicked a button and nothing happened, and they are already wondering if the page is broken.

This is the mechanism behind janky, unresponsive interfaces, and it has a metric now. Interaction to Next Paint (INP) measures the delay between a user interaction and the screen updating in response, and the dominant cause of bad INP is exactly this: long tasks hogging the main thread so the browser cannot respond. Bringing that number down is its own discipline, covered in cutting your INP below 200ms before it tanks rankings. The 2025 Web Almanac found the median mobile Total Blocking Time was 1,916 milliseconds, up 58 percent from the year before. Nearly two seconds, on the median mobile page, where the browser simply cannot react to the user. That is a lot of pages feeling broken.

Why one long task is worse than the same work split up

A "long task" is browser terminology for any task that occupies the main thread for more than 50 milliseconds. The total amount of work matters, but the way it is packaged matters more for responsiveness. The same computation done in one unbroken 300ms task blocks every interaction for 300ms. The same computation split into six 50ms tasks gives the browser five gaps in between, and in each gap it can check for a click, respond to input, and paint. The total CPU time is identical. The user experience is night and day, because responsiveness is about whether the browser ever gets a turn, not about how much total work there is.

So the technique is yielding: deliberately breaking a long task into smaller pieces and handing control back to the browser between them, so user input and rendering get a chance to happen. The question is how to yield well, because the old ways of doing it had real downsides.

scheduler.yield: the right tool, finally

The clean modern answer is scheduler.yield(). You await it inside a long loop, and it returns control to the browser, lets it handle any pending input and paint, then resumes your work right where it left off. The key advantage over the old workarounds is that it yields immediately and then continues your task as a priority, rather than dropping your remaining work to the back of the queue behind everything else.

async function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    doExpensiveWork(items[i]);
    // every N items, give the browser a turn
    if (i % 50 === 0) {
      await scheduler.yield();
    }
  }
}

scheduler.yield() shipped stable in Chrome 129 in September 2024 and is now supported across major browsers, with Safari being the notable exception. Because Safari lacks it, you feature-detect and fall back, but where it exists it is the best option available.

There is one cost to be aware of. Yielding has overhead, the bookkeeping of pausing and resuming. If you yield too often, between tiny units of work, you can spend more time pausing and resuming than doing the actual work. So you yield periodically, every several iterations or after a batch, not on every single item. The goal is gaps frequent enough that input never waits more than a frame or two, but not so frequent that the overhead dominates.

isInputPending: useful to know, no longer the recommendation

You will see isInputPending() recommended in older articles. It lets you check whether the user is trying to interact, so you can yield only when there is actually input waiting, and keep churning when there is not. It sounds smart, and it had its moment.

It is no longer Google's recommendation. It can return false negatives, and more importantly it only accounts for input, not for the other performance-critical work the main thread owes, like animations and rendering updates. A task that does not yield because no input is pending still blocks the next paint. So isInputPending() optimises for the wrong signal. The cleaner mental model is to yield periodically regardless, which gives the browser room for input and rendering both. Know isInputPending() exists, but reach for scheduler.yield().

A simple cross-browser fallback for where scheduler.yield() is missing is to yield via a setTimeout of 0 or a MessageChannel postMessage, which also returns control to the browser, just with less priority on resumption:

function yieldToMain() {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

When yielding is not enough: move it off the thread entirely

Decision tree for long tasks: yield with scheduler.yield for DOM work vs offload heavy work to a Web Worker

Yielding keeps the main thread responsive by sharing it. But some work has no business on the main thread at all. If you are parsing a large file, running an image transform, doing heavy data crunching, or anything genuinely CPU-bound that takes meaningful time, the right move is not to slice it into pieces that share the main thread, it is to get it off the main thread completely with a Web Worker.

A Web Worker runs JavaScript on a separate thread, with no access to the DOM, communicating with the main thread via messages. You hand it the heavy computation, it churns away on its own thread, and the main thread stays completely free to respond to the user the whole time. The result comes back as a message when it is done. For sustained heavy work, this is categorically better than yielding, because the main thread is never blocked at all rather than blocked-then-released in slices.

The division of labour:

  • Yield with scheduler.yield() for work that must touch the DOM or that is moderately long, where slicing it gives the browser enough gaps to stay responsive.
  • Offload to a Web Worker for genuinely heavy, DOM-independent computation, so the main thread never carries it in the first place.

Get that split right and the user gets an interface that responds to every tap instantly, even while real work is happening, because either the work is being sliced to leave room, or it is not on the responsive thread at all. Not all the heavy code on your page is yours, either, which is why taming the third-party scripts wrecking your page speed and shrinking your Next.js bundle with server components that ship zero JS attack the problem from the other direction.

The mindset that keeps interfaces snappy

The underlying discipline is treating the main thread as a scarce, shared resource that the user has first claim on. Any time your code is about to run a loop over a large collection, do a non-trivial transform, or hydrate a big chunk of UI, ask whether it could block input for more than a frame. If it could, either yield through it so the browser gets turns, or move it off the thread so it never competes for those turns at all.

This is the kind of detail that separates an interface that feels premium from one that feels like a prototype, and it does not show up in a screenshot, only in the hand. The same instinct, never letting the user perceive a stall, is the case for making slow feel fast with optimistic UI and smart skeletons. A page that paints beautifully but freezes for a beat every time you tap something reads as cheap, no matter how good it looks. Long-running leaks make this worse over time, which is why finding the memory leaks making your SPA slower by the hour belongs in the same conversation. And whether a tap registers at all in the lab versus on real devices is exactly the gap between your Lighthouse score and what field data tells you. Responsiveness is a feel, and the feel comes from never making the user wait on a thread that was busy doing something it could have yielded.

When we build websites and web apps that are meant to feel fast under the finger, this is part of the baseline: heavy work goes to a worker, long client tasks yield, and the main thread stays free for the one thing the user actually notices, which is whether the page answers when they touch it. If your app looks right but feels sluggish to interact with, the cause is almost always a long task somewhere blocking the thread, and it is findable and fixable.