Javascript is a requirement for modern websites and applications. Part of a great user experience is a fast page load i.e presenting the user with a fully ready and interactive page as quickly as possible. Correct use of the async and defer attributes when loading external scripts will make a difference to that initial user experience. Let's take a look at how they work and how they can affect the web performance of your page.

TL;DR

Bottom of the page is not always the best place (nor is it always easy to do in larger projects), though it might seem to be. The async and defer attributes bring a cleaner solution that works for all scenarios.

Browser metrics

For this article we'll discuss how built-in browser events are affected. While there is good reason to look at how the Core Web Vitals (CWV) are affected, we'll start with the built-in events because, ultimately, these are what cause the effect on CWV.

A quick note on the test setup

We wanted a single server to serve all static files, including index.html. It also needed to have an optional forced slow response. While there are isolated solutions out there, I also wanted to avoid CORS issues. A super simple express server worked just fine

const fs = require('fs');
const express = require('express')
const app = express()

app.use(express.static('public'))

const sleep = async ms => new Promise((resolve) => setTimeout(() => resolve(), ms));

app.get('*', async (req, res) => {
  const path = req.originalUrl || 'index.html';
  const delay = req.query.delay ? parseInt(req.query.delay) : 0;
  if (delay) {
    await sleep(delay);
  }
  
  const fullPath = `${__dirname}/${path.split('?')[0]}`;
  if (fs.existsSync(fullPath)) {
    return res.sendFile(fullPath);
  } else {
    return res.sendStatus(404);
  } 
});

app.listen(3000)

Basically, we have a single catchall route serving files from the directory. If there is a query parameter "delay", the server waits for that number of milliseconds. That means we can alter the src in our script tags for testing purposes e.g.:

<script src="/some-file.js?delay=3000"></script>

Measurements

In order to know if we're improving, we have to be measuring something! We'll use the PerformanceNavigationTiming API. Specifically:

Here's our helper script to log timings

<script>
    window.addEventListener('load', async (event) => {
      const { domContentLoadedEventStart, loadEventStart } = performance.getEntriesByType('navigation')[0];
      console.log({ domContentLoadedEventStart, loadEventStart });
    });
  </script>

Start with the basic page

Right, now we have all the context we need. Let's start at the beginning. The code below represents a simple web page with:

  • a single external script (in the head for demonstration purposes) - a.js.
  • a visual element, in this case a logo - logo.js
  • a section to show average measurement times over (we'll use) 20 loads
<html>

<head>
  <title>JS async defer tutorial</title>

  <script src="/a.js"></script>
</head>

<body>
  <img src="./logo.png" />
  <div id="results">
    <div>
      Count <span id="count">0</span>
    </div>
    <div>
      DOM Ready <span id="dom-ready">0</span>
    </div>
    <div>
      Load <span id="load">0</span>
    </div>
  </div>
  <button id="reset">Reset</button>
</body>

</html>

And the rendered page:

Simple Page

Taking the average of 20 loads, we can get a baseline:

MetricValue
domContentLoadedEventStart272
loadEventStart441

Adding a delay

Let's add a delay of 1 second to the script load.

...
  <script src="/a.js?delay=1000"></script>
...

Now we have metrics as follows:

MetricValue
domContentLoadedEventStart1050
loadEventStart1220

Both metrics have been delayed by ~1second. You may have expected the DOM load to go unaffected. However this is clearly not the case. Because the script is blocking, the browser knows the script could still make changes to the DOM and so won't finish loading the DOM.

The big visual difference

Blank Page showing while the script is being downloaded

Now, metrics are just metrics. But for the user experience, the delay we introduced has caused a big problem. The image does not render until the script has been downloaded (and processed). That is a very poor user experience, made worse when you consider how simple it is to fix.

We used to solve it incorrectly. Kind of.

Before the async and defer attributes were introduced, we'd generally solve the problem by moving the script tag to the bottom of the page. Doing this does allow (most) of the page to be rendered, which is great because we get to present the user with something meaningful.

However, this does delay the START of the download of the script, which is especially noticeable on very slow connections. See, when the HTML is downloaded in chunks, the browsers lookahead parser cannot find the script tag until the very end (worse on large HTML docs), which means it cannot start optimistically downloading it. Obviously you may not notice this with your fancy connection, but your users on the opposite side of the world will!

defer and async attributes similarities and differences

In order to solve the problem, the smart browser working group people needed to introduce a way to stop script tags from blocking rendering, while still allowing for performance optimisations. The defer and async attributes to just that. They look like this:

...
  <script src="/a.js" defer></script>
... or ...
  <script src="/a.js" async></script>

Both have the effect of unblocking render. Both also allow the script to be downloaded in parallel to DOM parsing. Both will still technically block the domContentLoadedEventStart but will allow the DOM to be processed inbetween.

Scripts with the defer attribute will be processed at the very end of DOM parsing, just before that event. They are deferred until the end. If there are multiple defer attributes, they will be processed in the same order they were found in the HTML.

async scripts will execute as soon as they are downloaded and ready.

So, defer is a little more deterministic than async. For example, if the async script in question is trying to manipulate a DOM element, it may or may not find that element depending on how far the DOM parsing has got to before the script was ready. *Note that this behaviour differs across browser implementations!

This means that async is probably better for "detached" tasks, like tracking, and defer is probably better for functionality related to the user experience i.e the app or site functionality.

A modern solution

The async and defer attributes give us a solution that works for all situations. The browsers lookahead parser can still optimise delivery of resources. We don't have to worry about positioning scripts in the right places in HTML (especially difficulty in some larger multi-team project that use some kind of modular approach to adding components to the HTML). They are a clever and well thought through solution to an admittedly simple, but very important problem.

Thanks for reading.

This is part of a series, gently introducing web performance concepts.

Tags: #frontend