I watched a search box die in the most boring way possible.
Three letters. The caret blinked, considered its options, and then the results showed up in one guilty dump about half a second later.
The profiler said "scripting." The ticket said "make search faster." The pull request that got merged was debounce(300).
The freeze moved. It used to happen while you typed. Now it happens the moment you pause, which is a very specific way to make software feel like it is sighing at you.
The search was never slow. It was rude. It took the only lane on the main thread and stayed there until it was finished.
There is one lane
The main thread handles input, JavaScript, style, layout, and paint, and it handles them one at a time. While your loop is running, a click is not being processed slowly. It is standing in a line you have not opened.
Making that loop finish sooner is real engineering. Virtualize the list. Skip work you don't need. If the job is pure computation and never touches the DOM, put it in a worker and go have a coffee.
A lot of interface work cannot leave. Filtering rows you are about to paint. Walking a table to mark matches. Building the nodes for the thing a person just asked for. That work is allowed to take time. It is not allowed to hold the lane for the whole visit.
The platform already has a name for the offense. A long task is anything on the main thread that runs past 50 milliseconds. Cross that line and a frame gets missed. Input starts to feel late. The fix is a way to hand the lane back every time you reach it.
The queue already has priorities
The API is called Prioritized Task Scheduling, which sounds like a meeting. It behaves like a bouncer.
Three lanes, in plain language:
-
user-blockingis the keystroke or the click happening right now. -
user-visibleis work the person asked for and is waiting to see. Results. The next page. The thing on screen. -
backgroundis analytics, prefetch, and anything that should lose a fight with typing.
if (globalThis.scheduler?.postTask) {
scheduler.postTask(() => sendAnalytics(payload), {
priority: "background",
});
}
Useful, and weirdly rare in application code. The method I actually want in a search box is scheduler.yield(). You are already inside a task. You call it. The browser gets a chance to do anything more urgent, including paint, and then your function continues.
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
Support, as of October 2026: scheduler.yield() is in Chrome and Edge 129+, and in Firefox 142+. postTask() has been in Chromium since 94, and it landed in Firefox in the same 142 release as yield(). Safari still has neither. There is a WebKit draft. It is not what your iPhone is running.
The setTimeout(0) line keeps Safari from breaking. Keep reading, because that fallback is a different promise wearing the same syntax.
Debounce optimizes the wrong clock
Debounce waits until the typing pauses. Then it runs the entire search as a single task.
Interaction to Next Paint can look healthy while this is happening. The input handler returned immediately. It armed a timer. INP measures interactions, and a timer is not an interaction, so the chart goes green. A third of a second later the long task shows up with your function's name on it. INP only catches that freeze when it overlaps some other interaction. The person using the page catches it every time.
The work did not get smaller. The rudeness got a calendar invite.
Yield on a budget
Yielding has a cost. Await once per row and you will spend the afternoon scheduling, and the results will dribble in like a slideshow. The version that works is a deadline.
async function filterRows(rows, query, signal) {
const q = query.trim().toLowerCase();
if (!q) return [];
const matches = [];
let deadline = performance.now() + 50;
for (let i = 0; i < rows.length; i++) {
if (signal.aborted) return;
if (rows[i].name.toLowerCase().includes(q)) matches.push(rows[i]);
if (performance.now() >= deadline) {
await yieldToMain();
if (signal.aborted) return;
deadline = performance.now() + 50;
}
}
return matches;
}
Fifty is the long-task line, not a number I liked the shape of. Stay near it and the browser keeps getting a turn. Blow past it and you are the reason the caret stopped.
After scheduler.yield(), the rest of your function is queued at user-visible priority, unless you were already inside a postTask(), in which case it keeps that task's priority. A new keystroke is user-blocking. It is allowed to cut in front. That is the entire feature. Your loop resumes once the urgent work has been handled.
You also do not have to paint partial results. Yielding hands the lane back. It does not require a streaming UI. Computing in slices and painting once at the end is a perfectly good design. Painting on every slice is how a freeze gets replaced with layout thrash, which people will also, correctly, complain about.
setTimeout(0) leaves the lane too. Then it sits at the back of the macrotask queue, behind other timers, behind network callbacks, behind whatever a library scheduled "real quick." Yield means urgent things may pass you. A zero timeout means everyone may pass you. Ship the fallback. Know which one you are in.
Cancel the search you no longer mean
This is the bit that gets left out of the short version, and it is the bit that makes the technique work.
Yield without cancellation and every keystroke starts a new loop while the old ones are only napping. You type n. You type no. The first search wakes up, the second search wakes up, and they take polite turns painting a list nobody asked for.
let current;
input.addEventListener("input", () => {
current?.abort();
current = new AbortController();
filterRows(rows, input.value, current.signal).then((matches) => {
if (matches) render(matches);
});
});
Read the signal again after you wake up. The abort arrives during the pause. If you don't look at it, you will render stale rows with a clear conscience.
Same work. Different manners.
Thirty thousand rows. Same filter. There's a little extra CPU per row so the cost is obvious on a fast machine. That loop stands in for ranking or highlighting, because a bare includes() over 30k strings is too cheap to feel.
The yellow square is moved from requestAnimationFrame, so it freezes when your code owns the thread. I ran the "er" search in Chrome. The blocking pass was one task of about 500 ms, and it held a frame for the whole thing. The yielding pass took the same half-second overall. The longest task stayed at the 50 ms budget.
Same work. The second one stopped parking.
The sandbox below is that page. Block is the single long task. Yield hands the lane back every 50 ms and throws away the previous search when you type again. The cancelled counter going up is the feature, not a bug.
One visual trap, since I burned an hour on it once: a CSS animation that only changes transform or opacity can keep gliding through a long task. The compositor thread does not need the main thread for that. Use it as your "is the page frozen?" test and the page can be stuck while the animation smiles at you. Drive the test from requestAnimationFrame, or from the long-task number next to it.
When it happens in production, get a name
Guessing which loop did it is how the wrong function ends up debounced. Long Animation Frames will tell you which script, and which function, blocked rendering.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const worst = entry.scripts?.reduce(
(best, script) =>
script.duration > (best?.duration ?? 0) ? script : best,
null
);
console.log({
duration: Math.round(entry.duration),
blocking: Math.round(entry.blockingDuration),
fn: worst?.sourceFunctionName,
invoker: worst?.invoker,
url: worst?.sourceURL,
});
}
});
observer.observe({ type: "long-animation-frame", buffered: true });
Support: Chromium 123 and up. Chrome, Edge, Opera, Samsung Internet. Firefox doesn't have it yet. Safari doesn't either. Firefox's stated position is positive, which is engineer for "we don't hate this, and also you can't use it."
Where LoAF exists, use it. Where it doesn't, a Long Task entry still tells you that something crossed 50 milliseconds. LoAF tells you who. sourceFunctionName is empty for plenty of anonymous callbacks, so keep the invoker and the URL. A name of "" is still a clue if you know which file it came from.
What yielding will not rescue
Slicing a task that should never have been on the main thread is just organized rudeness.
Pure computation belongs in a worker. Parsing a large file, diffing a document, crunching numbers that never read the DOM. Yielding is for work that has to stay: it closes over component state, it writes to the document, and cloning it across a thread would be a bigger project than the bug.
startTransition is a different queue. It lets React interrupt its own rendering. It does not slice the for loop in your event handler, the one that runs before setState is ever called. I have seen a transition get blamed for a freeze that was a plain synchronous loop sitting in front of it. The loop did not ask React for permission, and React could not have given it.
If the whole job is 10 milliseconds, leave it alone. Cutting a short task into pieces makes it longer. The budget starts to matter when you might cross 50ms. Before that it is ceremony.
And once you do paint: content-visibility: auto lets the browser skip layout and paint for off-screen content. Pair it with contain-intrinsic-size, or the scrollbar collapses and the page jumps. That CSS is widely supported, including Safari. It will not break up your loop. It stops you spending the lane on pixels that are currently off stage.
So
The next time a search box feels slow, look at the length of the longest task before you look for a faster algorithm.
A smaller total is a good goal. It is a useless goal if one turn on the main thread is long enough that the browser cannot take the keystroke still under the person's finger.
The priority system is already in the browser. debounce(300) is a timer.
What is the longest task in the app you shipped this month? If you don't know, that is the whole performance strategy so far: a hope, and a timer.


