---
title: "I Set the Spinner Before the Work. Why Doesn't It Show? · Ihab Khattab"
description: "An interview question about a loading spinner that never appears, and the follow-ups that walk through when the browser gets to paint: tasks, microtasks, timers, requestAnimationFrame, and proving it in DevTools."
url: "https://ihabkhattab.com/blog/the-spinner-that-never-shows/"
---

# I Set the Spinner Before the Work. Why Doesn't It Show?

Oct 31, 2026 · 6 min read

Part 2 of **The Follow-Up**

Each post is one frontend interview question and the follow-ups after it. AI answers the first question easily. The follow-ups need a real mental model, and that model tells you what to ask, yourself and your agent.

1. [We Deployed an Hour Ago. Why Do Users Still See the Old Version?](https://ihabkhattab.com/blog/deployed-but-users-see-the-old-version/)
2. I Set the Spinner Before the Work. Why Doesn't It Show?

Picture a frontend interview. The interviewer shares a button handler:

```js
button.addEventListener("click", () => {
  spinner.hidden = false;
  processRows(rows); // About 2 seconds of synchronous work.
  spinner.hidden = true;
});
```

> Users click, the page freezes for two seconds, and the spinner never shows. Why?

Here’s that handler, with a second of work instead of two:

Interactive demo: buttons that run the spinner handler (Synchronous) with about a second of real work. [Try it on the page](https://ihabkhattab.com/blog/the-spinner-that-never-shows/).

_This runs real work on your main thread, so the whole page freezes for about a second._

The first answer comes fast: “JavaScript blocks rendering. The browser only paints after the handler returns.” That’s right. By the time the browser gets to paint, the spinner is hidden again, so there’s nothing to show. If tasks, frames, and paint are fuzzy, [How the Browser Draws a Frame](https://ihabkhattab.com/blog/how-the-browser-draws-a-frame/) walks through them first.

You may never have shipped this exact bug. If your spinners wrap a `fetch`, the handler returns at the `await`, and the browser paints while the request is in flight. But you’ve probably seen the same problem in other forms: a `setTimeout(fn, 0)` that made something work and nobody knew why, or a poor [Interaction to Next Paint](https://web.dev/articles/inp) (INP) score traced back to a click handler. They come down to the same question as the spinner: when does the browser get a turn to paint?

The fix is to give the browser that turn before the work starts. Every follow-up below is a different way to try that, and only some of them work.

## Question 1: Force a Reflow?

> Read the spinner’s size right after showing it. That makes the browser update the page.

```js
button.addEventListener("click", () => {
  spinner.hidden = false;
  spinner.offsetHeight; // Forces style and layout now.
  processRows(rows);
  spinner.hidden = true;
});
```

Interactive demo: buttons that run the spinner handler (Forced reflow) with about a second of real work. [Try it on the page](https://ihabkhattab.com/blog/the-spinner-that-never-shows/).

_Style and layout run; paint doesn't._

Still no spinner. To give you a correct number, the browser has to work out the page’s styles and layout right away. This is called a [forced reflow](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing#avoid_forced_synchronous_layouts). But working out where things go isn’t drawing them. Painting is a [later step](https://web.dev/articles/rendering-performance#the_pixel_pipeline), and the browser still won’t do it until your handler returns. The [forced reflow section](https://ihabkhattab.com/blog/how-the-browser-draws-a-frame/#forced-reflow-layout-right-now) of How the Browser Draws a Frame has a diagram of both: the read pulls layout into your task, and paint still waits for the frame.

## Question 2: Just Await Something?

> Put an `await` before the work. Then the handler returns and the browser can paint.

```js
button.addEventListener("click", async () => {
  spinner.hidden = false;
  await Promise.resolve();
  processRows(rows);
  spinner.hidden = true;
});
```

Interactive demo: buttons that run the spinner handler (await Promise.resolve()) with about a second of real work. [Try it on the page](https://ihabkhattab.com/blog/the-spinner-that-never-shows/).

No spinner either. The handler does return at the `await`, but the rest of the function is queued as a microtask, and [microtasks run](https://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint) as soon as the current task’s script finishes, before the event loop moves on. In the HTML spec, [rendering is a task of its own](https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering), so it can only run _after_ that. So the work runs, the spinner hides, and only then does the browser get to paint. The [event loop diagram](https://ihabkhattab.com/blog/how-the-browser-draws-a-frame/#frames-when-the-browser-gets-to-draw) in How the Browser Draws a Frame shows that order: a task, then all its microtasks, and only then can the browser pick rendering.

The problem isn’t `await`. It’s awaiting something that’s already resolved. That’s why `await fetch(...)` works: the response arrives in a later task. `await Promise.resolve()` and `queueMicrotask()` never leave the current task.

_Not to scale. With an already-resolved promise, the rest of the handler runs as a microtask, still inside the click's task, so the work and the hide both happen before the browser can render. With fetch, the click's task ends at the await, the browser paints the spinner, and the rest runs in a later task when the response arrives._

## Question 3: Then a `setTimeout`?

> Fine. What about `setTimeout(fn, 0)`?

```js
button.addEventListener("click", () => {
  spinner.hidden = false;
  setTimeout(() => {
    processRows(rows);
    spinner.hidden = true;
  }, 0);
});
```

Interactive demo: buttons that run the spinner handler (setTimeout(0)) with about a second of real work. [Try it on the page](https://ihabkhattab.com/blog/the-spinner-that-never-shows/).

Click the button. There it is, finally. The handler finishes, the timer callback is a new task, and the browser can render between two tasks.

The interviewer nods, then changes one thing. Not all work starts with a click. A dashboard might also refresh its table on its own, every minute. The code stays; only what starts it changes:

> Put that code in a function. Call it from the click, and also from a `setInterval`. Does the spinner show both times?

```js
function refreshTable() {
  spinner.hidden = false;
  setTimeout(() => {
    processRows(rows);
    spinner.hidden = true;
  }, 0);
}

button.addEventListener("click", refreshTable);
setInterval(refreshTable, 60_000);
```

Interactive demo: buttons that run the spinner handler (setTimeout(0), from a timer) with about a second of real work. [Try it on the page](https://ihabkhattab.com/blog/the-spinner-that-never-shows/).

_The timer version starts half a second after you click, so no click is involved when it runs. Rendering opportunities are the browser's call, so your browser may not match my test._

No. Try the button above: no spinner. In Chrome, the click version painted the spinner every time I tried it. The timer version never did.

_Can_ render isn’t _will_ render. The browser gets a [rendering opportunity](https://html.spec.whatwg.org/multipage/webappapis.html#rendering-opportunity) about once per display frame, and the spec lets it pick which task runs next. It even says a browser “might wish to coalesce timer callbacks together, with no intermediate rendering updates”. A zero timeout is ready long before the next frame, so nothing says the render goes first.

The click is the difference. After a click or a key press, Chrome [holds back other tasks for up to 50 ms](https://chromium.googlesource.com/chromium/src/+/HEAD/third_party/blink/renderer/platform/scheduler/main_thread/user_model.h) so the next frame can paint. A timer isn’t input, so the zero timeout runs right away and the frame waits behind the work.

_The same code, two triggers. After the click, the timer waited for the next refresh and the spinner painted first. From a timer, the zero timeout ran before any refresh came._

So `setTimeout` gives the browser a turn. It doesn’t say what the browser does with that turn. To be sure the spinner paints, you need something tied to frames.

## Question 4: `requestAnimationFrame`, Then?

> `requestAnimationFrame` runs right before a paint. Start the work there.

```js
button.addEventListener("click", () => {
  spinner.hidden = false;
  requestAnimationFrame(() => {
    processRows(rows);
    spinner.hidden = true;
  });
});
```

No spinner again. Right before is the problem: `requestAnimationFrame` callbacks run as part of the frame, [before style, layout, and paint](https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering). The work runs inside that frame, the spinner hides, and then the frame paints the hidden spinner.

That’s why the double `requestAnimationFrame` trick exists:

```js
spinner.hidden = false;
requestAnimationFrame(() => {
  // This frame paints the spinner.
  requestAnimationFrame(() => {
    // We're in the next frame, so the first one has painted.
    processRows(rows);
    spinner.hidden = true;
  });
});
```

Interactive demo: buttons that run the spinner handler (requestAnimationFrame, Double rAF) with about a second of real work. [Try it on the page](https://ihabkhattab.com/blog/the-spinner-that-never-shows/).

The inner callback goes to the _next_ frame, so the first frame paints with the spinner on screen. This one held: it painted the spinner every time, from the click and from the interval. It costs up to two frames before the work starts, and the work still freezes the second frame.

_With one rAF, the frame starts, its callback runs the work, and the paint has to wait until the work is done. By then the spinner is hidden again. With two, the first frame paints the spinner, and the work waits for the next one._

## Question 5: Prove the Spinner Painted

> How do you know the spinner showed? Maybe it painted for one frame and nobody saw it.

Record it. In Chrome’s Performance panel, turn on **Screenshots**, click **Record**, click the button, and click **Stop**. Then zoom in to where the long task starts.

![Chrome DevTools Performance panel zoomed to about 25 ms. The Frames track shows two screenshots: the first has only the Load rows button, the second also shows the spinner. In the Main track, a long task labeled Timer fired, with processRows inside it, starts right after a few thin purple and green marks, and has a red triangle in its corner.](https://ihabkhattab.com/_astro/devtools-performance-light.YMA7orCb_ZffH26.webp)

_The setTimeout version, after a click. The thin purple and green marks are the frame's rendering and paint. The screenshot before them has no spinner; the one after has it, while processRows is still running._

- **Main track:** the long task has a [red corner](https://developer.chrome.com/docs/devtools/performance/reference#main). Right before it, thin purple and green marks are the frame being rendered and painted. In the one-task version, nothing comes between the click and the work.
- **Frames track:** the screenshots show what was actually on screen. The one before the long task has no spinner; the ones after it do.

## The Debrief

The first answer was “JavaScript blocks rendering.” The follow-ups were about _when_ the browser gets to render:

| Fix | Spinner shows? | Why |
| --- | --- | --- |
| Synchronous | No | The browser only paints after the task |
| Forced reflow | No | Style and layout run, but paint still waits |
| Microtask (`await`) | No | Microtasks run before rendering |
| `setTimeout`, after a click | Yes | The browser chose to render first |
| `setTimeout`, from a timer | No | The timer ran before the next frame |
| One rAF | No | rAF runs before the paint of its frame |
| Double rAF | Yes | The work waits for the next frame |

- Painting happens between tasks, never during one. Microtasks don’t count as “between”.
- A new task is a chance to render, not a promise. After input, the browser favors the frame; from a timer, it may not.
- `requestAnimationFrame` is for work that should be in the next frame, not after it.
- The Performance panel shows which frames painted. Check there instead of guessing.

The spinner shows now. But look at the table again: every fix still runs the work as one two-second task. The page can’t scroll, and if you added a Cancel button, its click would wait until the work was done, with nothing left to cancel. That wait is input delay, and Part 4 of this series is about it.

[← All posts](https://ihabkhattab.com/blog/)
