Go Back

How to optimize resource downloads to improve DOM performance

Author: Ben Bozzay

As we learned in the DOM Construction guide, the HTML Parser constructs the Document Object incrementally. The parser pauses DOM construction while waiting for certain resources to finish downloading and parsing.

In general, these types of resources can block the HTML Parser:

  • JavaScript
  • CSS
  • Fonts

Reducing Download Time with Text Compression

Text compression is a common way to reduce the file size and, as a result, the overall download time of string-based resources (HTML, CSS, and JS). This process involves compressing a string (our code) by removing whitespace, linebreaks, and modifying code to be harder for humans to understand while still being machine-readable. Minifiers attempt to eliminate redundancy.

For this specific module, we'll use inline code snippets generated by online compression tools to illustrate the types of compression. However, build-tools like Webpack or Gulp are used to generate these assets automatically while developing a website. We'll use these in the final course project.

JavaScript

Source JS:

//  Returns an array with only orange values
function countFruits(arrayOfFruits, targetFruit) {
  var count = 0;
  for (let i = 0; i < arrayOfFruits.length; i++) {
    // Increment the counter by 1 if a match is found
    arrayOfFruits[i] == targetFruit ? count++ : false;
  }
  return count;
}

// List of fruits
var fruits = ["apple", "orange", "pear", "orange"];
countFruits(fruits, "orange")

Output from UglifyJS:

function countFruits(arrayOfFruits,targetFruit){var count=0;for(let i=0;i<arrayOfFruits.length;i++){arrayOfFruits[i]==targetFruit?count++:false}return count}var fruits=["apple","orange","pear","orange"];countFruits(fruits,"orange");

Some JavaScript minifiers also rename code. Example:

var countFruits;

Might have the name shortened to:

var cf;

HTML Minification

Minification applies to HTML as well, but might create some confusing output that appears to be invalid.

Source:

<html>
  <head>
    <!-- Browser Tab Title -->
    <title>This title appears in the browser's tab</title>
  </head>
  <body>
    <!-- Page Title -->
    <h1>Heading that gets rendered</h1>
    <div class="box">A box</div>
  </body>
</html>

Using an HTML minification playground:

<title>This title appears in the browser's tab</title><h1>Heading that gets rendered</h1><div class=box>A box</div>

Note that the HTML minifier removed the <head>, <body>, and <html> tags.

Will this code actually work? Remember how the HTML Parser "fills in the blanks"? We can use the DOMParser API within our own JavaScript to test how the HTML parser will actually read our newly minified HTML:

(function() {
  let inlineParser = new DOMParser();
  let minifiedString = `<title>This title appears in the browser's tab</title><h1>Heading that gets rendered</h1><div class=box>A box</div>`;
  let parserOutput = inlineParser.parseFromString(minifiedString, "text/html");
  console.log(parserOutput);
}()))

The HTML Parser still forms a valid Document by adding in the missing tags.

CSS

CSS minification is similar to HTML and JS minification.

Input:

.prose {
  color: #000000;
}
.prose h1 {
  font-weight: 700;
}

Output:

.prose{color:#000}.prose h1{font-weight:700}

Notice that the original hex code #000000 was modified to #000.

Server-side compression (GZIP)

Although we limit our discussion of server-side optimizations, you should know that GZIP compression is a great server-side method for reducing the file size of content.

GZIP, in a nutshell, significantly reduces file size by replacing duplicated data fragments with references that a machine understands.

GZIP is enabled on the server that hosts your website or through a content delivery network like Cloudflare. This should be used in combination with file minification.

Reduce Latency through bundling

As we discussed in the first module, each resource download is represented by a network request. Network request timing includes lookup and download time. The lookup time is the latency we are optimizing for. We want to reduce the time spent communicating with the server (latency).

Outside of server-side optimizations, we can reduce cumulative latency by bundling certain files.

Bundling involves combining files:

// INPUT
src/navigation.js
src/hero.js

src/page1_stylesheet.css
src/page2_stylesheet.css
src/page3_stylesheet.css

// OUTPUT
dist/app.min.js

dist/main.min.css

Using a build tool like Webpack, you can specify source files and then the resulting output location.

Non-blocking Downloads

Normally when the DOM Parser encounters a CSS or JS file, the file must be downloaded and parsed before DOM Construction continues. The parser pauses during this entire process. However, if we load resources in an asynchrounous way, the download time won't block the parser.

The async tag can be added to <script> nodes to inform the browser to continue HTML Parsing while the download is in progress.

// Async reference
<script src="./main.js" async></script>

Once the download finishes, the main thread will be blocked by the associated JavaScript parsing process.

Async can offer some noticeable improvements on slow connections if the JS resource is large and the resource is referenced in the <head> or towards the top of the <body>. It's also useful to reference the script later in the DOM parsing process.

Preloading Resources

We can use preload to start the resource download of high priority resources sooner in the process. These downloads do not block the HTML Parser.

Preloaded resources cause the browser to download a resource before it is discovered by the parser.

...
  <link rel="preload" href="/dist/js/delay_start.js" as="script">
</head>
<body>
  ...
  <script src="/dist/js/delay_start.js"></script>
</body>

For example, we could include a preload link to a JavaScript resource in the <head>. Then, we could include the actual script node before the closing </body> tag.

The download starts early in the DOM construction process since it's referenced early in the document. Once the script node is discovered, the Parse JavaScript task starts immediately since the resource is already downloaded.

The link rel="preload" is always specified in the <head> and it should reference the same filename as the resource type your are preloading for. The as attribute specifies the resource type.

These are the available as types:

audio: Audio file, as typically used in <audio>.
document: An HTML document intended to be embedded by a <frame> or <iframe>.
embed: A resource to be embedded inside an <embed> element.
fetch: Resource to be accessed by a fetch or XHR request, such as an ArrayBuffer or JSON file.
font: Font file.
image: Image file.
object: A resource to be embedded inside an <object> element.
script: JavaScript file.
style: CSS stylesheet.
track: WebVTT file.
worker: A JavaScript web worker or shared worker.
video: Video file, as typically used in <video>. 

Preloading a resource is similar to an async resource, but it has additional use cases.

Preloading is particularly interesting for fonts. If a font download is referenced in the stylesheet, that download doesn't start until after the stylesheet is downloaded and parsed.

Preloading the specific font means that the stylesheet parsing time won't delay the discovery of the required font download.

...
  <link rel="preload" href="/dist/fonts/some_font.wof" as="font">
  // Stylesheet containing @import font rule
  <link rel="stylesheet" href="/dist/css/style.css">
</head>
<body>
  ...
</body>

Defer Download and Parsing of a file

Deferred resources are non-blocking until after the DOMContentLoaded event. The download and parsing tasks don't occur until after the DOM loads. This is in contrast with async where the parsing task could potentially occur at any point during DOM construction (depending on where the resource is included and how long the download takes).

You can defer a resource by creating the element with JavaScript.

Let's dynamically create a JavaScript reference to a file containing a dynamically_created_script_node() function.

...
<head>
...
<script>
  let newScript = document.createElement("script");
  newScript.src = "/dist/js/delay_start.js";
  document.head.appendChild(newScript);
</script>
</head>
...

In this case, we're using document.createElement method to create a new HTML node. Then, we assign the src attribute referencing the relative file path to our JS file. Finally, we attach the newly created node to the <head> of the site.

Notice that the function from the dynamically injected script executes after DCL even though it was added to the <head> of the site. Additionally, it executes before the load event.

The associated network request doesn't start until after DCL. In this case, we've delayed both the download and parsing of the JavaScript file.

You can do the same thing with a stylesheet:

...
<head>
...
<script>
  let newStyle = document.createElement("link");
  newStyle.rel = "stylesheet";
  newStyle.href = "/dist/css/styles.min.css";
  document.head.appendChild(newStyle);
</script>
</head>
...

This is useful when combined with an event or IntersectionObserver so that resources can be lazyloaded.

Browser Deferred

Similar to async, we can add the defer attribute to JavaScript resources.

// Defer reference
<script src="./main.js" defer></script>

This delays execution of the script until after the DOM Parser finishes with the page.

Note that JavaScript execution occurs before the DOMContentLoaded event.

LazyLoading with FetchInject

fetchInject is a small, less than 1kb script that provides a method for lazyloading text resources like JS and CSS. It's similar to preloading a script and then deferring the execution of the code.

...
<head>
  <script src="https://cdn.jsdelivr.net/npm/fetch-inject@1.9.1/dist/fetch-inject.umd.min.js"></script>
  <script>
    fetchInject(["/dist/js/debug/delay_start.js"]);
  </script>
...

We pass an array containing our script as an argument to the fetchInject function. The associated code execution doesn't occur until after the DCL and load events. Basically, fetchInject completely frees up the main thread while the document is parsed and rendered.

The network request starts early in the DOM construction process (around the time we included the fetchInject reference).

Ordering Async Dependencies

Async or deferred scripts can provide performance improvements, but also create inconsistent behavior if we're not careful.

For example, if with dependencies like jQuery, we want to make sure that it's always loaded before the JS that depends on it. If we load our JS asynchronously, we can't guarantee the execution order.

Fetch inject is similar to deferring a script and we can use it for styles as well. We can specify the execution order of these scripts. This means that if we have a dependency like jQuery, we can make sure that jQuery loads before our other JavaScript file and maintain the execution order.

// Load jQuery dependency first
fetchInject([
  '/js/jquery.js'
// Then load JS that depends on jQuery
], fetchInject([
  '/css/slider.css',
  '/js/slider.js'
]).then(() => {
   // execute code after all JS loads
   let sliderSection = document.getElementById("slider");
   sliderSection.classList.add("loaded");
})

Passing fetchInject as an argument to itself enables complete control over the execution order. Furthermore, we can use a then() block to run any code that depends on those scripts.

Differing Non-critical CSS

CSS can also be differed, but we have to be more selective since we have to coordinate with the rendering process that occurs when we load the page. If we move all the CSS out of the head to below the body tag, we'll see a flash of un-styled content, which obviously isn't ideal.

However, non-critical CSS that isn't needed to render the initial view of the page can be loaded asynchronously or differed.

<html>
  <head>
    ...
    // CRITICAL CSS
    <style></style>
  </head>
  <body>
    ...
    // NON-CRITICAL CSS
    <link rel="stylesheet" href="/dist/css/style.css" /> 
  </body>
</html>
  • Critical CSS can be loaded inline, allowing us to eliminate the need for a network request. The inline styles are handled by the DOM parser instead of needing to download and parse an external stylesheet.
  • The non-critical CSS can be loaded in a non-blocking way so that the download time doesn't block the DOM Parser during critical points in the rendering process. This allows the user to interact with the page more quickly.

Async CSS

In the previous example, we referenced the stylesheet before the closing </body> tag. Despite deferring this resource, the overall DOMContentLoaded time isn't reduced because the stylesheet blocks the DOM Parser (even if it's the last HTML node!).

If we want to avoid blocking the DOM parser so the DCL event occurs before the stylesheet is downloaded and parsed, we can load the stylesheet asynchronously.

<html>
  <head>
    ...
    // CRITICAL CSS
    <style>
      ...
    </style>
    // Required for fetchInject function
    <script type="text/javascript" src="/dist/js/fetchinject.js" />
  </head>
  <body>
    ...
    // Option 1: Defer stylesheet parsing until after the resource finishes downloading
    <link rel="preload" href="/dist/css/style.css" as="style" onload="this.rel='stylesheet'">

      //// Option 2: use fetch inject
      <script>
        fetchInject("/dist/css/style.css")
      </script>

      //// Option 3: Dynamically create the link with JS
      <script>
        let newStylesheet = document.createElement("link");
        newScript.rel = "stylesheet";
        newScript.src = "/dist/css/style.css";
        document.body.appendChild(newScript);
      </script>

    // Fallback for all methods with browsers that don't use JS
    <noscript><link rel="stylesheet" href="/dist/css/style.css"></noscript>
  </body>
</html>
  1. With Option 1, we can use HTML markup to trigger actions in response to JavaScript events. The JS onload event will change the value of the rel attribute to use "stylesheet" as the value. This causes the download to be non-blocking and also delays parsing until after DOMContentLoaded + all resources (images and files) have finished downloading.
  2. Option 2 involves using fetchInject to download the stylesheet in a non-blocking way. This will cause the stylesheet parsing process to occur after DOMContentLoaded, but likely before the load event.
  3. Creating the stylesheet dynamically triggers the stylesheet download early, but the DOM Parser doesn't discover the dynamically created <link> until it's almost finished loading. This is similar to using fetchInject.