DEV Community

Cover image for Your UI is freezing because you never gave the browser a break
Parsa Jiravand
Parsa Jiravand

Posted on

Your UI is freezing because you never gave the browser a break

Every frontend developer has shipped it: a button that doesn't respond, a list that stutters during a sort, an animation that freezes mid-frame. The cause is almost always the same — a long synchronous task has taken over the main thread, and the browser can't do anything else until it's done.

JavaScript, the render loop, input event handling, and layout all share one thread. When your code runs for more than ~50ms without stopping, the browser queues every pending paint, click, and scroll behind it. Users experience this as lag — or as an app that simply ignores them.

The fix is yielding: handing control back to the browser at safe checkpoints so it can catch up before your code continues.

Why setTimeout(0) isn't the answer

The classic workaround is setTimeout(fn, 0) — break work into chunks, schedule each with a zero-delay timeout. It works, but the costs are real. You shatter your logic across multiple callbacks, lose the clean async/await call stack, and the "zero" delay isn't zero — browsers clamp it and the overhead accumulates across hundreds of chunks.

requestIdleCallback sounds more appropriate (only run during idle time), but idle callbacks can be starved indefinitely when the browser stays busy. It's unreliable for work that must actually complete.

requestAnimationFrame is useful for visual tasks synchronized to frames — it's the wrong tool for a data transform that just happens to be expensive.

scheduler.yield() — one line

The Scheduler API provides a first-class primitive for this:

async function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    if (i % 100 === 0) {
      await scheduler.yield();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That await is the entire API. It returns a Promise that resolves in the next task, after the browser has handled pending paints and input events. Your loop resumes exactly where it left off — no callback restructuring, no index bookkeeping, no split logic.

The meaningful difference from setTimeout(0): scheduler.yield() is priority-aware. When you yield, the scheduler runs higher-priority work first — user input and visual updates — before your continuation. All setTimeout callbacks compete at equal priority regardless of urgency.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Yield on time, not item count

Yielding too rarely means tasks still block; yielding too often adds overhead and slows total completion. The practical target: yield roughly every 50ms of CPU work, the threshold at which browsers flag a task as "long" in DevTools.

async function processItems(items) {
  let lastYield = performance.now();

  for (const item of items) {
    processItem(item);

    if (performance.now() - lastYield > 50) {
      await scheduler.yield();
      lastYield = performance.now();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This yields on elapsed time rather than item count, which is accurate when individual item costs vary. A 10ms item and a 0.1ms item both need different yield frequencies — time-based checking handles both without tuning.

A real-world pattern: progressive table rendering

Loading a large dataset, building DOM nodes, and inserting them into a table is one of the most common long-task offenders. With scheduler.yield(), the table builds progressively and the page stays interactive throughout:

async function renderTable(rows) {
  const fragment = document.createDocumentFragment();
  let lastYield = performance.now();

  for (const row of rows) {
    fragment.appendChild(buildRow(row));

    if (performance.now() - lastYield > 50) {
      table.appendChild(fragment.cloneNode(true)); // flush what we have
      fragment.replaceChildren();
      await scheduler.yield();
      lastYield = performance.now();
    }
  }

  table.appendChild(fragment); // flush remainder
}
Enter fullscreen mode Exit fullscreen mode

A user who clicks a button while this loop runs will see that click handled immediately — it's no longer lost to a blocked thread.

Browser support, and the one detail that breaks the polyfill

scheduler.yield() is in Chrome 129 and Firefox 142. Safari has not shipped it — so this is not Baseline, and a bare scheduler.yield() is not something you ship unguarded. You need a fallback:

const yieldToMain = globalThis.scheduler?.yield?.bind(globalThis.scheduler)
  ?? (() => new Promise((resolve) => setTimeout(resolve, 0)));
Enter fullscreen mode Exit fullscreen mode

That globalThis. prefix is the whole ballgame, and it is the detail almost every snippet of this polyfill gets wrong. Writing scheduler?.yield?.bind(scheduler) looks safer — optional chaining, after all — but optional chaining only guards against null and undefined values. It does nothing for an identifier that was never declared. In Safari there is no scheduler global at all, so evaluating the bare word scheduler throws ReferenceError: Can't find variable: scheduler before ?. is ever consulted. The polyfill written to protect Safari crashes only in Safari.

globalThis.scheduler is a property lookup on an object that definitely exists, so it evaluates to undefined and ?. short-circuits exactly as intended.

Replace scheduler.yield() with yieldToMain() throughout your code. In Chrome and Firefox you get priority-aware yielding; everywhere else you get the setTimeout fallback, which still unblocks the thread — it just isn't priority-aware. The async structure is identical either way.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The takeaway

Open DevTools → Performance, record a slow interaction, and look for the red "Long task" markers. Each one is a stretch where your code held the thread and blocked the browser. Pick the longest offender, find the hot loop inside it, and add await yieldToMain() at a logical checkpoint — a time-based check works well. That one await is often the difference between an app that feels broken and one that feels instant.


Thanks for reading! Let's stay connected:

Top comments (0)