Web Performance Calendar

The speed geek's favorite time of year
2022 Edition
ABOUT THE AUTHOR

Stoyan (@stoyanstefanov) is a former Facebook and Yahoo! engineer, writer ("JavaScript Patterns", "React: Up and Running"), speaker (JSConf, Velocity, Fronteers), toolmaker (Smush.it, YSlow 2.0) and a guitar hero wannabe.

Problem

As the previous post puts it:

A slow CSS prevents the JavaScript following it from executing.

And in addition, when the JS following the CSS is inline, it’s naturally synchronous, which compounds the undesirable effect.

Example

As a demonstration let’s take a look at a sample page that has:

  1. Slow CSS1 that takes 5 seconds
  2. External async JS1
  3. Inline JS in the HEAD
  4. Slow CSS2 that takes 10 seconds
  5. External async JS2
  6. Inline JS in the body

Here’s the relevant code:

<head>
  <script>
  const log = {};
  const start = +new Date;
  </script>
  <title>Baseline: before</title>
  
  <link rel="stylesheet" href="css1.css.php" type="text/css" />
  <script src="js.js" async></script>
  
  <script>
    log['inline HEAD script'] = +new Date - start;
  </script>
  
  <link rel="stylesheet" href="css2.css.php" type="text/css" />
  <script src="js2.js" async></script>
  
</head>
<body>
  <script>
    log['inline BODY script'] = +new Date - start;
  </script>
  <!-- -->
</body>

The live page to play with yourself.

And the waterfall:
waterfall view showing two slow-loading CSS files

The waterfall looks as expected: 5s artificially delayed CSS takes 5s, the second one 10s, all resources are loaded in parallel and the whole thing is done in 10s.

The problem is not the loading but the execution of JavaScript. A sample page load gives us times like:
Execution times table

The first external JS is quick, but the first inline and second external are delayed by 5s, exactly the time the first CSS takes. The execution of the second inline script is delayed by 10s, waiting for the second CSS.

Rounding the results to the nearest second would give us something like:

  • 0s external script
  • 5s inline HEAD script
  • 5s external script 2
  • 10s inline BODY script
  • 10s DOMContentLoaded
  • 10s onload 10280

Solution with data URIs

The solution to this behavior where CSS blocks execution offered in the previous post was to use data URIs to externalize the inline scripts so they can benefit from the async attribute, which otherwise doesn’t apply. As a result, the execution times look like:

  • 0s external script
  • 0s inline HEAD script
  • 0s external script 2
  • 0s inline BODY script
  • 0s DOMContentLoaded
  • 10s onload 10280

So much better!

But turns out there’s a simpler solution…

type=”module”

How about using inline scripts and making them as modules? Like so:

<head>
  <script>
  const log = {};
  const start = +new Date;
  </script>
  <title>Baseline: before</title>
  
  <link rel="stylesheet" href="css1.css.php" type="text/css" />
  <script src="js.js" async></script>
  
  <script type="module">
    log['inline HEAD script'] = +new Date - start;
  </script>
  
  <link rel="stylesheet" href="css2.css.php" type="text/css" />
  <script src="js2.js" async></script>
  
</head>
<body>
  <script type="module">
    log['inline BODY script'] = +new Date - start;
  </script>
  <!-- -->
</body>

Test page.

Results:

  • 0s external script
  • 0s inline HEAD script
  • 10s external script 2
  • 10s inline BODY script
  • 10s DOMContentLoaded
  • 10s onload 10280

Interesting. The first inline script is fast! However the second external JS went from 5 to 10s and the rest is all the same.

Let’s call this option A.

Option B: async type="module"

How about adding async to the script tag? Like so:

<head>
  <script>
  const log = {};
  const start = +new Date;
  </script>
  <title>Baseline: before</title>
  
  <link rel="stylesheet" href="css1.css.php" type="text/css" />
  <script src="js.js" async></script>
  
  <script async type="module">
    log['inline HEAD script'] = +new Date - start;
  </script>
  
  <link rel="stylesheet" href="css2.css.php" type="text/css" />
  <script src="js2.js" async></script>
  
</head>
<body>
  <script async type="module">
    log['inline BODY script'] = +new Date - start;
  </script>
  <!-- -->
</body>

In non-modules, async didn’t help (see previous post), but now?

Test page.

Results:

  • 0s external script
  • 0s inline HEAD script
  • 0s external script 2
  • 0s inline BODY script
  • 0s DOMContentLoaded
  • 10s onload 10280

Oh yes! Option B wins! No more blocking JavaScript execution. And no more data URI hacks.

Safari

This works consistently in Firefox, Brave (and I assume Chrome and Edge) and Safari. In fact, in Safari option A = option B. Looks like modules are already async by default.

Another note on Safari: the top level (global) const log and const start were not defined in the inline module. That feels like the correct behavior to me, but anyway, it happened only in Safari. Making these window.log and window.start fixed the problem. Something to be aware to test if you have variables shared between scripts.

Happy unblocking

…and modularly async-ifying!