loke.dev
Header image for Yield or Freeze

Yield or Freeze

Interaction to Next Paint is the new performance gold standard—here is why your main thread needs more breathing room.

· 5 min read

Yield or Freeze

I used to spend hours micro-optimizing my JavaScript functions, shaving off 2ms here and 5ms there, thinking I was a performance wizard. Then I’d load the site on a mid-range Android phone, click a "Filter" button, and... nothing. The UI would stay frozen for half a second before snapping into place. It was infuriating. I’d done the work! The logic was fast! But I eventually realized that being "fast" doesn't matter if you're hogging the main thread so hard that the browser can't even acknowledge the user clicked the button in the first place.

This is the core of the shift from First Input Delay (FID) to Interaction to Next Paint (INP). We’ve moved past worrying only about the very first interaction. Now, the browser is watching every single click, tap, and keypress to see if you’re making the user feel like the app has crashed.

The Main Thread is a Single-Track Mind

The browser’s main thread is like a very talented, very overworked chef. It handles everything: parsing HTML, executing JavaScript, calculating styles, and—crucially—painting the pixels on the screen.

If you give that chef a task that takes 300ms (like processing a massive JSON blob or rendering a complex list), they can't stop halfway through to plate a salad. The "salad" in this metaphor is the visual feedback for a user's click. The user clicks, the browser notes the event, but it can't actually *show* the "clicked" state or open a menu until your 300ms JavaScript task finishes.

That’s "jank." And INP is the metric that finally puts a number on that frustration.

Breaking Up the Monolith

The solution isn't always "make the code faster." Sometimes, the solution is "make the code stop." We need to yield control back to the browser so it can breathe, paint a frame, and then get back to our heavy lifting.

Think of it as adding "pause" points in your long-running loops.

The Old School: setTimeout(0)

For years, we used setTimeout(() => {}, 0) to hack our way into yielding. It pushes the remaining work to the end of the task queue.

function processMassiveArray(items) {
  const chunk = 100;
  let index = 0;

  function doWork() {
    const end = Math.min(index + chunk, items.length);
    for (let i = index; i < end; i++) {
      // Expensive logic here
      heavyCalculation(items[i]);
    }

    index = end;

    if (index < items.length) {
      // Yield to the browser before the next chunk
      setTimeout(doWork, 0);
    }
  }

  doWork();
}

This works, but it's a bit of a blunt instrument. setTimeout has some overhead, and if you have multiple things yielding this way, you can end up with "task starvation" where the work you actually want to finish gets buried under other low-priority events.

The Modern Way: scheduler.yield()

There is a relatively new API specifically designed for this: scheduler.yield(). It’s part of the Prioritized Task Scheduling API. It tells the browser: "Hey, I’m at a good stopping point. If you have any urgent UI updates or input to handle, go ahead. But please put me back at the front of the line as soon as you're done."

Unlike setTimeout, scheduler.yield() tries to maintain the priority of the task.

async function validateLargeForm(data) {
  for (const field of data) {
    validate(field);

    // If the browser has pending work (like a click), 
    // this will pause the loop, let the paint happen, 
    // and then resume immediately.
    if (shouldYield()) { 
      await scheduler.yield();
    }
  }
}

// A simple helper to check if we've been running too long
function shouldYield() {
  // Yield if we've been hogging the thread for more than 50ms
  return performance.now() - lastYieldTime > 50;
}

*Note: scheduler.yield() is rolling out in modern browsers (Chrome 115+). For older browsers, you'll want a polyfill or a fallback to setTimeout.*

Why This Matters for INP

INP measures the time from the user interaction until the next paint.

If you have a 200ms task, your INP is likely 200ms.
If you break that 200ms task into four 50ms tasks with yields in between, the browser can sneak a "paint" frame in between those chunks. If the user clicks during the second chunk, the browser only has to wait a maximum of 50ms before it can respond visually.

You just turned a "Poor" INP score into a "Good" one without actually changing the total amount of work performed.

The "Gotcha": State Management

Yielding isn't free. When you yield, you are making your synchronous code asynchronous. This opens the door for race conditions.

Imagine a user clicks a "Sort" button. You start sorting a giant list and yield halfway through. While you are paused, the user clicks "Clear All." If your sorting function resumes without checking if the data still exists or if the operation is still relevant, you're going to have a bad time.

Always use an AbortController or a simple boolean check after a yield:

async function renderList(items, signal) {
  for (const item of items) {
    if (signal.aborted) return; // User cancelled or navigated away
    
    renderItem(item);
    await scheduler.yield();
  }
}

Yielding is a Mindset

We’ve been trained to think that the faster a script finishes, the better. But on the web, responsiveness beats raw speed every time. A user would rather wait 500ms for a list to finish loading if the "Loading..." spinner stays smooth and the "Cancel" button actually works, than wait 300ms for a frozen screen to suddenly pop into existence.

Next time you're writing a loop that touches more than a hundred DOM nodes or processes a meaty data set, don't just optimize the logic. Ask yourself: "Am I giving the browser enough room to breathe?"

Yield or freeze. The choice is yours.