Sam Thorogood

Control Loading Spinner in HTML

If your web experience involves real HTML pages and actual <a href="..."> links, navigation will happen as a normal browser action: your users will see some kind of loading indicator or spinner that's built-in to the platform.

However, if you've adopted the single-page application idiom, this won't happen for free. You'll typically be loading new data with a fetch() and then hydrating your page with the content. This is pretty common for app-like experiences, and lots of even pure content sites do it too— to name one.

It's time to show you how to fake this experience. I don't endorse this for production, but it's a cool demo.

The Demo

an early version of this demo for users in RSS readers

⚠️ This is broken on iOS, but I'm looking into a workaround.

Neat, huh? Let's dissect how it works.

How It Works

Firstly: I'll admit it. I am actually loading a new page that does have a real URL. But instead of your natural instinct to reply to HTTP requests as fast as possible, we instead delay its completion until a point in the future that we control.

When the request finally resolves, reply with a HTTP 204. Importantly, and to quote MDN:

The HTTP 204 No Content success status response code indicates that the request has succeeded, but that the client doesn't need to go away from its current page.

With these two parts holding hands together—like a knife 🔪 and a wrench 🔧—we can create the illusion of loading, while not actually doing anything at all.

Using a Service Worker

The easiest–but not the only—way to integrate this with your existing site is to rely on a Service Worker. We can add a fetch handler that accepts a request to e.g., "/204_delay?key=...", and lets you finish the request later. Something like:

self.addEventListener('fetch', (e) => {
  const u = new URL(e.request.url);
  if (u.pathname === '/204_delay') {
    const key = u.searchParams.get('key');
    const handler = async () => {
      await new Promise((r) => activeLoads[key] = r);
      return new Response(null, {status: 204});

We'll also need to define activeLoads and a way to invoke them—perhaps via a message:

const activeLoads = {};

self.addEventListener('message', (e) => {
  if ( === 'done') {
    const r = activeLoads[];
    delete activeLoads[];
    r && r();

Now, on our foreground page, if we've got a valid service worker, your async loading code can be wrapped like:

async function loadPage(url) {
  const {controller} = navigator.serviceWorker.controller;
  const key = String(Math.random());
  if (controller) {
    // cause a real but fake navigation
    window.location.href = `/204_delay?key=${key}`;

  await loadDataAndUpdatePage();  // ¯‍\‍_‍(‍ツ‍)‍_‍/‍¯

  if (controller) {
    // tell the SW to finish the navigation
    controller.postMessage({command: 'done', key});

And, as I mentioned: your browser will actually load a new page, with a real URL. But as it's a 204, nothing will change.

Using a Custom Server

The demo in this blog post talks to a real webserver which acts in exactly the same way as the Service Worker, above. One request "hangs" until another request causes the server to complete the first.

It's much more dangerous: if there's a network error or your client is offline, the request to "/204_delay" will actually fail, showing an error message and closing your site. And by the time a navigation has occured—which includes if a user clicks on a real link, we as JS developers can't actually stop that intent.


The loadPage() example above shouldn't be run in parallel, as your browser can only handle one navigation at once.

Some browsers—and of the big three, that's currently just Safari—will treat navigation as a signal to stop active network requests. Navigation does actually cause beforeunload to be fired, and that means fetch() will fail. You can work around this by fetching data you need inside your Service Worker, as postMessage is still allowed. But this isn't a very natural way to fetch content.

Safari will also stop DOM changes from being rendered, even in the same frame as navigation starts. In practice, this means that for the demo above, I create the "Loading..." text and then only cause our faux-navigation to start inside a requestAnimationFrame handler.

Finally, in the example above, our Service Worker might end up leaking memory if say, navigation is cancelled by the user closing their page. This is solvable but is tricky because it seems like clientId isn't available everywhere. Chrome only provides resultingClientId, which will be the client if the navigation completes—except we can never navigate to a 204. In practice, you should probably just implement a timeout.

Parting Thoughts

You could also pretend to load by rapidly animating your favicon. But that wouldn't be nearly as satisfying as triggering a native-like experience, especially as the favicon is not the only source of loading UX.

And as I mentioned all the way at the top, I don't condone this approach. Don't @-me.