Streaming HTML inside the DOM

One of the advantages of HTML is that it can be streamed. When you're viewing a large HTML document, your browser only needs the first few kilobytes or so to start displaying content. For a simple demonstration, open this page - if you'll find that some content appears quickly, even though the entire page takes a few seconds longer to load.

This works by default when loading a page, but you might want to load an HTML fragment from a server and insert it into an existing page. One way to achieve that is with an iframe, which also has built-in streaming. Interestingly, Safari does not render HTML chunks below around 1kB when navigating to a page, but it does when loading an iframe.

However, an iframe creates its own window, with its own document, style context, and so on (you'll notice that the iframe doesn't use the styles from the parent page, such as the sans-serif font). If the iframe is loaded from a different origin, you can't interact with it via JavaScript, and even if it comes from the same origin, headers like X-Frame-Options or Content-Security-Policy can prevent a site from being embedded.

Sometimes you want to insert HTML from the server directly into your existing document. The straightforward way to do this is to use the fetch API to get the HTML from a server, and then set the innerHTML of an element to the response text. This works, but it does not stream the content. The entire response must be received before the browser can start rendering:

const response = await fetch("https://example.net/slow-html");
const text = await response.text();
targetElement.innerHTML = text;
Click to try it out:

You'll notice that nothing happened for a few seconds, then the entire HTML loaded at once.

We can stream directly into the DOM by creating a new HTML document and piping the response body into it:

const response = await fetch("https://example.net/slow-html");
await response.body
  .pipeThrough(new TextDecoderStream())
  .pipeTo(appendToDOM(targetElement));

function appendToDOM(targetElement) {
  const newDocument = document.implementation.createHTMLDocument();
  newDocument.write("<div>");
  targetElement.appendChild(newDocument.body.firstChild);
  return new WritableStream({
    write(chunk) {
      newDocument.write(chunk);
    },
    close() {
      newDocument.close();
    },
    abort(reason) {
      newDocument.close();
      // Display the reason in an error message or log it or whatever
    }
  });
}
Click to try it out:

Now we're streaming again.