In Defense of XMLHttpRequest and Blocking Render

All content is loaded.

Most people would call this an antipattern.

const element = document.querySelector("#text");

const request = new XMLHttpRequest();
request.open("GET", "https://jsonplaceholder.typicode.com/todos", false);
request.send(null);
const json = JSON.parse(request.response);

json.products.forEach((item, index) => {
  const div = document.createElement("div");
  div.innerHTML = item.title;
  element.append(div);
});

Why? Because it's synchronous and doesn't use fetch. But they wouldn't call this an antipattern.

const element = document.querySelector("#text");

const response = await fetch("GET", "https://jsonplaceholder.typicode.com/todos");
const json = await response.json();

json.products.forEach((item, index) => {
  const div = document.createElement("div");
  div.innerHTML = item.title;
  element.append(div);
});

And this doesn't seem quite right to me. Because the awaits here are using fetch in a near synchronous manner. Except they're not quite synchronous, the entire fetch process is pushed onto another thread. And this is a good thing for most cases. It doesn't block the main thread. However, there are still plenty of fun ways to block the main thread and crash a browser. So why are we picking on fetch?

But more importantly, why not just use await? Why would you ever want to block the main thread with a fetch request when the interface for not doing so is much cleaner and easier?

Let's expand out a bit. I recently read this article on the render="block" attribute: Render-blocking on purpose. What it talks about is the classic issue of style jank caused by JS-driven UI. JavaScript is loaded after HTML/CSS, and there's never been a good way to change this. For example, a really good dark mode turns out to be really hard. The prior article on render blocking introduces a very interesting piece of code.

<script type="module" async blocking="render">
    // vital JavaScript code...
</script>

This got my mind racing. Especially because I've recently rebuilt this site with vanilla HTML/JS/CSS. There's no server, only a custom build step. And while this is great for static content, and I can do code highlighting/templating, there's still a missing piece. Dynamic data.

I'm struggling to use a term that isn't "SSR." What I'm really getting at here is the idea of pre-document load rendering.

This requires a server:

export const getServerSideProps = (async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos')
  const json = await res.json()

  return { props: { json } }
})

export default function Page({ json }) {
  return (
    <main>
      <p>{json}</p>
    </main>
  )
}

We could make this fetch client side, but then we have to handle a loading state. Where the user sees an initial view before the data is loaded, and a second view after the data is loaded. There are an increasing (and increasingly complex) number of strategies to deal with this. Parallel routing, streaming HTML, server side rendering, server components, client side stale-while-revalidate caching. It seems half the JS ecosystem is focused on inventing new ways to solve this UX. And the surrounding tools are getting more and more complex.

What if we were to just use the synchronous request with XMLHTTPRequest above? We can block the render to wait on external resources to make sure users only see what we intend them to see. We can load this data from typicode, and you'll never see a loading state, or a state where this data doesn't exist. You can try reloading the page to see if you can catch a moment when it isn't loaded, but you're not going to see it. This page does not load until this data is ready.

DevTools showing that the todos network call is made and finished before the document content load

This is driven by the following code:

<script async type="module" blocking="render">
  const element = document.querySelector("#todos");
  const contentLoadedElement = document.querySelector("#contentLoaded");
  const request = new XMLHttpRequest();
  request.open("GET", "https://jsonplaceholder.typicode.com/todos", false);
  request.send(null);
  const json = JSON.parse(request.responseText);

  json.forEach((item) => {
    const div = document.createElement("div");
    div.innerText = item.title;
    element.append(div);
  });
  contentLoadedElement.style.backgroundColor = "green";
</script>

We're simply using blocking="render" for what it's meant for. To block the browser from painting the page until everything is done. No jank, without the SSR. I think this has a lot of interesting use cases. It's still important to be careful with this pattern. Things like timeouts, errors, and caching still need to be handled.

So let's solve them.

Timeout

While is may seem primitive, it is easy to abort an XMLHTTPRequest. It very conveniently comes with its own abort method. And once handled it's just a matter of letting the user know.

const request = new XMLHttpRequest();

let time = 0;
let complete = false;
setInterval(() => {
    if (time === 30 && status === complete) {
      request.abort();
    }

    time++;
}, 1000);
// Fetch logic

Errors

Nothing tricky here either, check the status or the response.

request.send(null);

if (request.status === 200) {
    // do stuff
}

This keeps the benefits of knowing whether there is an error before rendering. And we can load the error state as if it were server rendered. Without loading spinners.

Caching

This is the tricky part. As caching always is. A service worker won't pick this up because it never goes to that onfetch event. It stays on the main thread. Which means we have to have a separate mechanism if we want to cache this request.

Unfortunately, XMLHTTPRequest doesn't use the standard Request/Response objects used by Cache API. But we can still turn a response into a Response.

const testCache = await caches.open("test");
const response = new Response(request.responseText);
testCache.put("https://jsonplaceholder.typicode.com/todos", response);

Other Implications

The use cases for render blocking are endless. This is in a lot of the ways familiar to Remix client loaders. Although in the case of Remix this seems to be handled by the router. But here's some data you might want to get before document load.