# Why does your page freeze after a click?

Published 2026-10-27 · https://www.justjeb.com/post/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:

```js
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:

```js
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 click it too:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

If you pressed it, you saw what Bob saw. The color stopped partway through its transition, "Hacking" never showed up, and your clicks and key presses 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 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 presses, 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 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 opportunity. 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.

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 queue. 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.

One queue for everything has a problem, though. Say the page has a couple of timers and a script queued up, and you click. 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 presses 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.

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

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 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:

```js
function hack() {
  title.textContent = 'Hacking';
  setTimeout(() => {
    const data = hackIntoPentagon();
    const decryptedData = decrypt(data);
    processData(decryptedData);
    title.textContent = 'Hacked';
  }, 0);
}
```

He deploys it and clicks:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

Bob sees "Hacking" and calls it a day. Press it yourself and watch what happens next: the color still stops partway, your clicks and key presses 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:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

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](https://github.com/chromium/chromium/commit/b5e9a80d7148), 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](https://github.com/chromium/chromium/commit/d601e31f08cd), 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 presses. 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:

```js
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.

```js
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`:

```js
function yieldToMain() {
  return new Promise((resolve) => {
    setTimeout(resolve, 0)
  })
}
```

She deploys it and clicks:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

Press 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:

```js
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.

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:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

## 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:

```js
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:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

Press 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:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

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`:

```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:

```js
onmessage = () => {
  const data = hackIntoPentagon();
  const decryptedData = decrypt(data);
  processData(decryptedData);
  postMessage('done');
};
```

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

Press it and the color runs all the way through, "Hacking" shows right away, and your clicks and key presses 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:

[Interactive figure: open it on the page](https://www.justjeb.com/post/why-does-your-page-freeze-after-a-click)

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](https://developer.mozilla.org/en-US/docs/Web/API/Scheduler) for this. `scheduler.yield()` in particular does the same job as `yieldToMain`, so you should use it where it's available.

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](https://github.com/GoogleChromeLabs/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.

Browser support on MDN ↗

```js
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](https://developer.chrome.com/docs/devtools/performance) 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](https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Long_animation_frame_timing) 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 set up.

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:

```js
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 ↗
