Why does your page freeze after a click?
Bob’s HACK button freezes the page for a second, and the fix he finds is the old setTimeout(…, 0) trick. This post follows his click through the event loop to see what that trick really changes.
Every web developer has seen this code at least once in their life:
setTimeout(() => { /*Do some stuff*/ }, 0);However, a lot of people take it as some magic that should be applied when things don’t work as expected (race conditions, slowness, etc.). More advanced developers know that it postpones the code execution to the “next tick” (or some variation of it). Only a few would know how to explain what exactly happens there step by step.
Let’s follow Bob and find out.
Bob is a senior developer at a startup, and he has some JavaScript code to write. And of course, what could be better than hacking into the Pentagon database at the click of a button? We all know from the movies that it should take no more than two minutes.
So Bob makes a HACK button and styles it himself, with a nice 300-millisecond transition on its colors when you press it. Under the button there’s a label, the title in his code, that says what’s going on. This is his first try:
function hack() { title.textContent = 'Hacking'; const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); title.textContent = 'Hacked';}He deploys it and clicks. Here’s his button, so you can clicktap it too:
Press HACK, then click or type a few times.Press HACK, then tap anywhere on the page a few times.
Pressing HACK runs this version for real, and it freezes this page for about a second.
Click to hackTap to hackIf you pressed it, you saw what Bob saw. The color stopped partway through its transition, “Hacking” never showed up, and your clicks and key pressestaps waited until the page came back. The last line under the button sums it up.
Something ain’t right.
Why did the page freeze?
It comes down to who does the work in a browser. We’ve all heard that browsers are single-threaded. While this view is quite simplistic (we do have the means to execute code in parallel in a modern browser), it is true to a degree.
A lot of major browser-related stuff happens on one thread, called the Main threadThe thread that runs a page’s JavaScript by default. The browser also handles input, lays out the page and paints it on the same thread, so while one script is running none of that can happen.MDN: Main thread. This includes HTML parsing, layout, styling, painting to the screen, handling various events (like click or keyboard events), executing script tags and more.
So the thread that runs Bob’s hack() is the same thread that draws his button and handles your clicks and key pressestaps, and it does one thing at a time. That explains the freeze, but not “Hacking”: Bob’s code sets the label before the slow part even starts, so you’d expect to read “Hacking” for about a second, and you never do.
For that we need to look at how the main thread decides what to do next. Things keep arriving: clicks, key presses, scripts, timers, and a screen that wants a new frame many times a second. Executing every such bit the moment it arrives would wreak havoc (think of a browser handling a click event and then pausing to execute a script that just arrived), so browsers run all of it through a loop.
How does one thread take turns?
Games had to solve that long before browsers did, so let’s start there. Every game has a main game loop, and whether we’re talking about games from the 90s or modern games, they all have the same stages. The first stage is handling user input, then based on this input the game updates the world state, and then based on the current world state it renders the frame. The number of revolutions of this loop in one second is what we call frames per second.
Now say one of these stages takes more time than we expect: updating the world state takes a long time, or the rendering itself is very heavy. The next frame comes late, the picture freezes for a moment and the frame rate drops. If you play games, you’ve seen it, and you’ve probably pressed jump in the middle of one and watched the jump come too late.
Browsers are not very different from games. They also have this kind of loop. It works a bit differently, and it’s called an Event loopThe loop the browser runs on the main thread. Each turn takes one task from a task queue and runs it, then runs microtasks until their queue is empty, then renders if a rendering opportunity has arrived.MDN: the job queue and event loop.
For now let’s look at two of its stages: run a task (we’ll see what a task is in a moment), and then render the current state of the page on the screen, if there is a Rendering opportunityA moment when the browser is able to put a new frame on the screen. The display sets the pace, about every 16.7ms on a 60Hz screen, and the browser can space them further apart, for example in a hidden tab. If the event loop comes round and no rendering opportunity has arrived, it skips rendering.The HTML Standard’s definition. The “if” is there because we don’t want to render more than needed. If our screen refreshes 60 times per second, there’s no point in rendering more frames than that. So on each lap (each time round the loop), when the loop reaches the render stage and no new frame is due yet, it skips rendering and goes round again.
The ring is the loop, and the dot is where it is now.
So setting title.textContent changes the page, but the screen shows the change only when the loop gets to the render stage. Bob’s code sets “Hacking” first and does the slow part after, so why doesn’t the loop draw it in between? To answer that, we need to know what the loop actually runs.
What does the loop run?
Tasks. In addition to the event loop, the main thread has a Task queueWhere work waits for the main thread: a click handler, a script that finished loading, a setTimeout callback whose time is up. Each one is a task. A browser can keep several task queues and choose which one to serve first.The HTML Standard on task queues. Every time the user clicks on a button, a script tag loads or a timeout elapses, a task is added to the task queue (you’ll also see tasks called macrotasks). Every time the browser cycles through the event loop it takes the first task available on the task queue and executes it to the end, because nothing can stop a task halfway. After this task is finished the browser can continue to the render stage and then to the next event loop cycle.
Chips are waiting work; the one nearest the arrow runs next.
One queue for everything has a problem, though. Say the page has a couple of timers and a script queued up, and you clicktap. With a single queue the click waits behind all of them, and the page feels slow for no good reason. So browsers may implement multiple task queues, and pick which one to serve on each lap. A click is more important than evaluating some background JavaScript, so most browsers keep input (clicks and key presses; a tap fires a click too) in a queue of its own, the input queue, and serve it first.
What happened to Bob’s click?
It’s a user input event, so it lands in the input queue, and on the next lap the loop runs the whole hack() function as one task. The first line sets the label to “Hacking”. That changes the page, but rendering comes after the task, so nothing is drawn yet. Then hackIntoPentagon, decrypt and processData run, and the task isn’t done until all of them are.
For about a second the loop can’t move. Your clicks and key pressestaps land in the input queue and wait. The button’s color transition stops too, because its frames are drawn in the render stage. Then the last line sets the label to “Hacked”, the task ends, and the loop finally renders the page as it is right now, with “Hacked”. “Hacking” was in the page for a whole second and never made it to the screen.
Here’s the same click one step of the loop at a time. The button in the drawing shows what Bob’s button showed at each step. Press Play, or step through it with Prev and Next.
An example run, as Chrome runs it.
function hack() { title.textContent = 'Hacking'; const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); title.textContent = 'Hacked';}Nothing is queued, so the loop waits.
So it’s stuck, and it feels stuck. We also lose an important bit of visual feedback that tells the user “it’s hacking”.
A user who clicks and sees nothing happen for a second will click again, or decide the page is broken. Google even measures how long that wait is, with a metric called INPInteraction to Next Paint, one of Google’s Core Web Vitals. It measures how long the page takes to show a response after a click, tap or key press, and reports roughly the slowest such interaction in a visit. 200ms or less counts as good.web.dev: Interaction to Next Paint (INP), and counts 200ms or less as good. Bob’s click took about a second.
Bob’s fix
Bob doesn’t know any of this. He asks his AI coding assistant, a model trained on generations of hacks, and it hands him the classic answer: setTimeout. He wraps the whole hacking code in a setTimeout and keeps the line that sets “Hacking” outside of it:
function hack() { title.textContent = 'Hacking'; setTimeout(() => { const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); title.textContent = 'Hacked'; }, 0);}He deploys it and clicks:
The setTimeout fix. It freezes the page for about a second.
Click to hackTap to hackBob sees “Hacking” and calls it a day. Press it yourself and watch what happens next: the color still stops partway, your clicks and key pressestaps still wait, and the page freezes for the same second as before. Bob only tests it in Chrome, by the way, so he never finds out that in Safari or on an iPhone “Hacking” might not show up at all. If that’s where you’re reading this, we’ll get to it right after we see what setTimeout changed.
What did setTimeout change?
Less than it looks. setTimeout is a web API, which means the browser provides it (the JavaScript language itself has no timers), and the only thing it does is queue a task in the task queue after X milliseconds. With 0 there is no wait, unless timers are nested more than five levels deep (a setTimeout inside a setTimeout callback, and so on), in which case the browser waits at least 4ms.
The click is still a single task, but now it only sets the label to “Hacking” and schedules the rest to execute in 0 milliseconds. Such a short timeout of course elapses immediately, but setTimeout callbacks are tasks of their own. Hence, now the hacking code goes to the end of the queue as another task, the click handler is done, and the browser is free to continue to the render stage, which draws the page as it is right now, with “Hacking” in it. At least in Chrome, which draws the frame after a click before it runs any timer the click set. Not every browser does that.
Then, on the next lap, the loop takes the hacking task. Here’s Bob’s fix one step of the loop at a time:
An example run, as Chrome runs it.
function hack() { title.textContent = 'Hacking'; setTimeout(() => { const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); title.textContent = 'Hacked'; }, 0);}The setTimeout fix puts the slow part in a setTimeout. Nothing is queued yet.
The callback is still one task that runs for a second. All Bob did was move the slow part out of the click’s task and into a task of its own, a lap later, and the lap in between is the one that draws “Hacking”. It fools INP too: the click’s own wait now looks short, because the next paint is “Hacking”, while any click in the second after it still waits for the whole hack.
Does “Hacking” show up in every browser?
Only in the ones that promise it, and Chrome does. Bob’s fix shows “Hacking” there because of a scheduler policy Chrome calls prioritize rendering after input, shipped in Chrome 109 in January 2023: after a click or a key press, the next frame gets the highest priority until it is produced. In 2025 Chrome went further and started to hold back the page’s other tasks, timers included, until that frame is produced.
Safari has no such rule. A timer set during a click may run before the next frame or after it, depending on what the page happens to be doing at that moment. On an iPhone, that goes for Chrome too, since as of October 2026 every browser there runs on Safari’s engine, WebKit. So there Bob’s fix shows “Hacking” only some of the time. When the timer runs before the frame, the next frame drawn already says “Hacked”.
Fix Bob’s fix
A few months later Alice, a web developer too, joins the team. She was hoping to write the most beautiful code, but this is a startup, so she gets Bob’s legacy code (Bob was great. We fired him, long story). Her first job is to fix Bob’s fix, because it hacks, but it still sucks: the page freezes for a second, and on an iPhone “Hacking” doesn’t always show up. She looks at the code, sees this setTimeout, and… oh my God.
Alice is smarter than Bob. She leaves the iPhone for later and starts with the freeze, from what we saw in Bob’s fix: the lap between two tasks is when the loop draws a frame and handles your clicks and key pressestaps. So she decides to split the slow part into chunks, with a setTimeout around each of the three slow functions. Every chunk becomes a task of its own, with a lap for the loop in between. This is what the actual code would look like:
function hack() { title.textContent = 'Hacking'; setTimeout(() => { const data = hackIntoPentagon(); setTimeout(() => { const decryptedData = decrypt(data); setTimeout(() => { processData(decryptedData); title.textContent = 'Hacked'; }, 0); }, 0); }, 0);}And this, let’s agree, is a nightmare to maintain and debug and even to read.
So she writes it the way she’d like to read it: change the text content and then tell the browser to take a breather. Then hack into the Pentagon, and then again tell the browser to take a break, and again and again.
async function hack() { title.textContent = 'Hacking'; await yieldToMain(); const data = hackIntoPentagon(); await yieldToMain(); const decryptedData = decrypt(data); await yieldToMain(); processData(decryptedData); title.textContent = 'Hacked';}It’s very easy to read, and you can understand the semantics from it. It’s also easy to debug: you can put a breakpoint after every place in your code where you decided to call this yieldToMain function, which hands control back to the event loop. But if you look under the hood, it’s still good old setTimeout:
function yieldToMain() { return new Promise((resolve) => { setTimeout(resolve, 0) })}She deploys it and clicks:
Split with yieldToMain. It freezes the page in three short bursts.
Click to hackTap to hackPress it and watch: the one long freeze turns into a few short ones, with frames drawn between them.
Why does yieldToMain work?
It’s harder to see than with Bob’s fix. Previously Bob explicitly passed the hacking code as a callback to setTimeout, and with what we know about the event loop we could see why it runs as a separate task. Now hackIntoPentagon is just executed after a seemingly unrelated promise that resolves in 0 milliseconds, and nothing hands it to setTimeout. To see how it still gets a task of its own, we need to talk about two things.
The first one is await. It is nothing more than syntactic sugar for promises. In fact, every async function can be written as a chain of promises, and everything that comes after await is just the callback of the promise that yieldToMain returns.
Here’s Alice’s hack() written without await. Each .then callback is the whole rest of hack() from that point on, so each one is smaller than the one before it:
function hack() { title.textContent = 'Hacking'; return yieldToMain().then(() => { const data = hackIntoPentagon(); return yieldToMain().then(() => { const decryptedData = decrypt(data); return yieldToMain().then(() => { processData(decryptedData); title.textContent = 'Hacked'; }); }); });}The second one is another queue, called the microtask queue. Unlike user input events or setTimeout callbacks (which are tasks), promise callbacks are microtasks.
Like tasks, microtasks are stored in a queue and are executed within an event loop cycle. But that’s about it when it comes to similarity. The task queue is served one task per lap of the event loop. The microtask queue is executed every time the JavaScript execution stack empties (the code that’s running has finished), before passing control back to the event loop. And once the browser starts on it, it runs them all and keeps going until the queue is completely empty.
Each callback is a promise callback, and promise callbacks are microtasks.
This includes microtasks that were scheduled during a microtask that is currently being executed (yes, you can go into an infinite loop with microtasks). It also means that a microtask never gives the browser a turn. The loop can’t render until the microtask queue is empty, so work split into microtasks alone freezes the page just like one long task.
Now we can see that everything after each await serves as a callback for a promise returned by yieldToMain, which, in turn, is nothing else but a promise that is resolved by setTimeout. And the setTimeout callback is a task of its own, so between one chunk and the next the loop finishes a task and gets its turn to render and take input. That’s all, no magic, only a clear, concise algorithm, just as we like.
Here’s Alice’s click one step of the loop at a time:
An example run, as Chrome runs it.
function hack() { title.textContent = 'Hacking'; return yieldToMain().then(() => { const data = hackIntoPentagon(); return yieldToMain().then(() => { const decryptedData = decrypt(data); return yieldToMain().then(() => { processData(decryptedData); title.textContent = 'Hacked'; }); }); });}function yieldToMain() { return new Promise((resolve) => { setTimeout(resolve, 0) })}You press HACK. The click joins the input queue.
Did Alice solve the problem?
It’s better, but you can still feel the delay, right? Of course it depends on how much time each chunk of hack() takes, and when the rendering is done. The last line under her button counts the freezes, and the longest is still about 0.4s.
And there’s a second problem. On her iPhone, “Hacking” might show up only after the first short freeze. When the setTimeout behind her first await runs before the frame, as Bob’s can, hackIntoPentagon runs first, and “Hacking” waits for the lap that the next await gives the loop.
How does Alice get “Hacking” on every screen?
She needs the browser to tell her when it’s about to draw a frame, and that’s what requestAnimationFrame (rAF for short) does: it runs your callback right before the next render. So she waits for the frame first and sets the timeout from inside the rAF callback. She tries it on Bob’s version first, where there’s only one timer and it’s easiest to see:
function hack() { title.textContent = 'Hacking'; requestAnimationFrame(() => { setTimeout(() => { const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); title.textContent = 'Hacked'; }, 0); });}She deploys that, opens it on her iPhone and taps:
Wait for the frame. It freezes the page for about a second.
Click to hackTap to hackPress it: “Hacking” shows up about one frame later, in every browser, and then the page freezes for the same second.
The setTimeout inside the rAF callback is still needed. The rAF callback runs as part of drawing the frame, right before the paint, so if the slow part ran inside it, that frame would wait for the whole hack. By the time it was painted the label would already say “Hacked”, just like on Bob’s first try. The setTimeout moves the slow part out into a task of its own, and a task queued while the frame is being drawn can only start once that frame is drawn. Here’s that one step of the loop at a time:
An example run, as Chrome runs it.
function hack() { title.textContent = 'Hacking'; requestAnimationFrame(() => { setTimeout(() => { const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); title.textContent = 'Hacked'; }, 0); });}This version asks for the next frame before it queues the slow part.
Her own version gets the same wait, once, before its first chunk. Still, the page freezes after every click, because every version runs the slow part on the main thread, and there’s only one of those. So does that mean the page will be stuck no matter what we do?
What’s the proper fix?
Some would argue that the hacking code shouldn’t be running in the browser at all and should move to a server. But there’s still a way to run some heavy stuff in the browser without blocking the main thread, and that’s a web worker, one of those means to execute code in parallel I mentioned earlier. The page starts the worker from a file of its own, and on every click it sends the worker a message. This is the page’s main.js:
const worker = new Worker('hack-worker.js');
function hack() { title.textContent = 'Hacking'; worker.postMessage('hack');}
worker.onmessage = () => { title.textContent = 'Hacked';};And this is hack-worker.js, with the slow part in it:
onmessage = () => { const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); postMessage('done');};A web worker. The page doesn’t freeze.
Click to hackTap to hackPress it and the color runs all the way through, “Hacking” shows right away, and your clicks and key pressestaps are handled the whole time the hacking runs.
worker.postMessage hands the message to the worker. The worker runs on a thread of its own, with an event loop of its own, so its onmessage runs there as a task while the main thread goes on drawing frames and handling your input.
Here’s the click with both event loops in the drawing, the main thread’s and the worker’s, one step at a time:
An example run, as Chrome runs it.
main.jsconst worker = new Worker('hack-worker.js');function hack() { title.textContent = 'Hacking'; worker.postMessage('hack');}worker.onmessage = () => { title.textContent = 'Hacked';};hack-worker.jsonmessage = () => { const data = hackIntoPentagon(); const decryptedData = decrypt(data); processData(decryptedData); postMessage('done');};The page has one thread so far, the main thread, with its event loop.
The worker can’t touch the page, though. That’s fine here, because we’re talking about hacking into the Pentagon. It has nothing to do with the main thread and it does not require access to the DOM, so you can do the whole job in the worker thread (or on that server) without blocking the main thread at all.
What if the work has to stay on the main thread?
If there is some code that needs to run on the main thread, code that requires access to the DOM, and you don’t want it to be bundled with the rest of the code and block the visual feedback of the click, this can and should be split with setTimeout and yieldToMain.
And these days you don’t have to write yieldToMain yourself. There is a dedicated Scheduler API for this. scheduler.yield() in particular does the same job as yieldToMain, so you should use it .
Chrome and Edge have had it since version 129, and Firefox since 142, on computers and on Android. Safari doesn’t have it, on the Mac or on the iPhone. Every other browser on an iPhone runs on Safari’s engine, so none of them has it either.
The good thing is that there is a polyfill. Behind the scenes this polyfill does the yieldToMain trick on the browsers that don’t support the API, and uses the native API on the ones that do, so you can load it everywhere. Google Chrome Labs maintains one, scheduler-polyfill. In Safari it queues the code after the yield through a MessageChannel (another way to queue a task), so there that code waits in line with the page’s other tasks, and you still need to wait for a frame first if “Hacking” must show in Safari.
async function hack() { title.textContent = 'Hacking'; await scheduler.yield(); const data = hackIntoPentagon(); // ...}It does the job a bit better, too. The code after scheduler.yield() resumes ahead of the other tasks the page has queued, while clicks, key presses and rendering can still run in between your chunks, which is what you yield for.
So what do you do with all this?
We followed Bob’s click through the event loop, where one thread runs one task at a time and renders only between tasks. Bob’s setTimeout got “Hacking” on the screen by moving the slow part one task later, and the freeze stayed just as long. Alice cut the slow part into chunks, so the loop got a lap between them and the freezes got shorter. Waiting for a frame made sure “Hacking” showed in every browser. And the worker took the slow part off the main thread, so nothing froze at all.
If I had to choose one thing for you to take from this post, it’s that main thread execution time is a limited resource, and while you’re developing you should be mindful of how your code uses it. Every task you give it is time in which the page can’t draw or react, and the people using your page have never heard of the event loop. They only see a page that didn’t react. So when something in your app feels stuck, open the Performance panel in Chrome DevTools and look for the long tasks (any task over 50ms gets a red flag there), and use the Long Animation Frames API to catch them on real users’ devices. Every time a frame takes more than 50ms, it tells you which scripts held it up and for how long, and it takes a few lines to .
You read it with a PerformanceObserver. Each entry is one frame that took more than 50ms, and its scripts list says which scripts ran in that frame, where each one came from and how long it ran:
new PerformanceObserver((list) => { for (const frame of list.getEntries()) { for (const script of frame.scripts) { console.log(script.sourceURL, script.invoker, script.duration); } }}).observe({ type: 'long-animation-frame', buffered: true });invoker is what started the script, a click listener or a timer, say. In a real app you’d send these to your analytics.
Chrome and Edge have had it since version 123. Firefox and Safari don’t, so what you collect only comes from people on Chromium browsers, and MDN still marks the API as experimental.
Long animation frame timing on MDN ↗