By Harry Roberts
Harry Roberts is an independent consultant web performance engineer. He helps companies of all shapes and sizes find and fix site speed issues.
Written by Harry Roberts on CSS Wizardry.
N.B. All code can now be licensed under the permissive MIT license. Read more about licensing CSS Wizardry code samples…
If you’ve ever run a Lighthouse test before, there’s a high chance you’ve seen
the audit Avoid
:document.write()
You may have also seen that there’s very little explanation as to why
document.write()
is so harmful. Well, the short answer is:
From a purely performance-facing point of view, document.write()
itself
isn’t that special or unique. In fact, all it does is surfaces potential
behaviours already present in any synchronous script—the only main difference is
that document.write()
guarantees that these negative behaviours will manifest
themselves, whereas other synchronous scripts can make use of alternate
optimisations to sidestep them.
N.B. This audit and, accordingly, this article, only deals with
script injection using document.write()
—not its usage in general. The MDN
entry for
document.write()
does a good job of discouraging its use.
There are a number of things that can make regular, synchronous scripts1 slow:
synchronous JS blocks DOM constructionis only true in certain scenarios.
The worst case scenario is a script that falls into both (1) and (2), which is
more likely to affect scripts defined earlier in your HTML. document.write()
,
however, forces scripts into both (1) and (2) regardless of when they’re
defined.
The reason scripts never block subsequent downloads is because of something
called the Preload Scanner. The Preload Scanner is a secondary, inert,
download-only parser that’s responsible for running down the HTML and
asynchronously requesting any available subresources it might find, chiefly
anything contained in src
or href
attributes, including images, scripts,
stylesheets, etc. As a result, files fetched via the Preload Scanner are
parallelised, and can be downloaded asynchronously alongside other (potentially
synchronous) resources.
The Preload Scanner is decoupled from the primary parser, which is responsible for constructing the DOM, the CSSOM, running scripts, etc. This means that a large majority of files we fetch are done so asynchronously and in a non-blocking manner, including some synchronous scripts. This is why not all blocking scripts block during their download phase—they may have been fetched by the Preload Scanner before they were actually needed, thus in a non-blocking manner.
The Preload Scanner and the primary parser begin processing the HTML at
more-or-less the same time, so the Preload Scanner doesn’t really get much of
a head start. This is why early scripts are more likely to block DOM
construction during their download phase than late scripts: the primary parser
is more likely to encounter the relevant <script src>
element while the file
is downloading if the <script src>
element is early in the HTML. Late (e.g.
at-</body>
) synchronous <script src>
s are more likely to be fetched by the
Preload Scanner while the primary parser is still hung up doing work earlier in
the page.
Put simply, scripts defined earlier in the page are more likely to block on their download than later ones; later scripts are more likely to have been fetched preemptively and asynchronously by the Preload Scanner.
document.write()
Hides Files From the Preload ScannerBecause the Preload Scanner deals with tokeniseable src
and href
attributes,
anything buried in JavaScript is invisible to it:
<script>
document.write('<script src=file.js><\/script>')
</script>
This is not a reference to a script; this is a string in JS. This means that the
browser can’t request this file until it’s actually run the <script>
block
that inserts it, which is very much just-in-time (and too late).
document.write()
forces scripts to block DOM construction during their
download by hiding them from the Preload Scanner.
Async snippets such as the one below suffer the same fate:
<script>
var script = document.createElement('script');
script.src = 'file.js';
document.head.appendChild(script);
</script>
Again, file.js
is not a filepath—it’s a string! It’s not until the browser has
run this script that it puts a src
attribute into the DOM and can then request
it. The primary difference here, though, is that scripts injected this way are
asynchronous by default. Despite being hidden from the Preload Scanner, the
impact is negligible because the file is implicitly asynchronous anyway.
That said, async snippets are still an anti-pattern—don’t use them.
document.write()
Executes Synchronouslydocument.write()
is almost exclusively used to conditionally load
a synchronous script. If you just need a blocking script, you’d use a simple
<script src>
element:
<script src=file.js></script>
If you needed to conditionally load an asynchronous script, you’d add
some if
/else
logic to your async snippet.
<script>
if (condition) {
var script = document.createElement('script');
script.src = 'file.js';
document.head.appendChild(script);
}
</script>
If you need to conditionally load a synchronous script, you’re kinda stuck…
Scripts injected with, for example, appendChild
are, per the spec,
asynchronous. If you need to inject a synchronous file, one of the only
straightforward options is document.write()
:
<script>
if (condition) {
document.write('<script src=file.js><\/script>')
}
</script>
This guarantees a synchronous execution, which is what we want, but it also guarantees a synchronous fetch, because this is hidden from the Preload Scanner, which is what we don’t want.
document.write()
forces scripts to block DOM construction during their
execution by being synchronous by default.
The location of the document.write()
in question makes a huge difference.
Because the Preload Scanner works most effectively when it’s dealing with
subresources later in the page, document.write()
earlier in the HTML is less
harmful.
document.write()
<head>
...
<script>
document.write('<script src=https://slowfil.es/file?type=js&delay=1000><\/script>')
</script>
<link rel=stylesheet href=https://slowfil.es/file?type=css&delay=1000>
...
</head>
If you put a document.write()
as the very first thing in your <head>
, it’s
going to behave the exact same as a regular <script src>
—the Preload Scanner
wouldn’t have had much of a head start anyway, so we’ve already missed out on
the chance of an asynchronous fetch:
Above, we see that the browser has managed to parallelise the requests: the
primary parser ran and injected the document.write()
, while the Preload
Scanner fetched the CSS.
Owing to CSS’ Highest priority, it will always be requested before High priority JS, regardless of where each is defined.
If we replace the document.write()
with a simple <script src>
, we’d see the
exact same behaviour, meaning in this specific instance, document.write()
is
no more harmful than a regular, synchronous script:
<head>
...
<script src=https://slowfil.es/file?type=js&delay=1000></script>
<link rel=stylesheet href=https://slowfil.es/file?type=css&delay=1000>
...
</head>
This yields an identical waterfall:
Because the Preload Scanner was unlikely to find either variant, we don’t notice any real degradation.
document.write()
<head>
...
<link rel=stylesheet href=https://slowfil.es/file?type=css&delay=1000>
<script>
document.write('<script src=https://slowfil.es/file?type=js&delay=1000><\/script>')
</script>
...
</head>
Because JS can write/read to/from the CSSOM, all browsers will halt execution of
any synchronous JS if there is any preceding, pending CSS. In effect, CSS
blocks
JS,
and in this example, serves to hide the document.write()
from the Preload
Scanner.
Thus, document.write()
later in the page does become more severe. Hiding
a file from the Preload Scanner—and only surfacing it to the browser the exact
moment we need it—is going to make its entire fetch a blocking action. And,
because the document.write()
file is now being fetched by the primary parser
(i.e. the main thread), the browser can’t complete any other work while the file
is on its way. Blocking on top of blocking.
As soon as we hide the script file from the Preload Scanner, we notice
drastically different behaviour. By simply swapping the document.write()
and
the rel=stylesheet
around, we get a much, much slower experience:
Now that we’ve hidden the script from the Preload Scanner, we lose all parallelisation and incur a much larger penalty.
The whole reason I’m writing this post is that I have a client at the moment who
is using document.write()
late in the <head>
. As we now know, this pushes
both the fetch and the execution on the main thread. Because browsers are
single-threaded, this means that not only are we incurring network delays
(thanks to a synchronous fetch), we’re also leaving the browser unable to work
on anything else for the entire duration of the script’s download!
document.write()
As well as exhibiting unpredictable and buggy behaviour as keenly stressed in
the MDN and
Google articles,
document.write()
is slow. It guarantees both a blocking fetch and a blocking
execution, which holds up the parser for far longer than necessary. While it
doesn’t introduce any new or unique performance issues per se, it just forces
the worst of all worlds.
Avoid document.write()
(but at least now you know why).
<script src=></script>
↩
N.B. All code can now be licensed under the permissive MIT license. Read more about licensing CSS Wizardry code samples…
Harry Roberts is an independent consultant web performance engineer. He helps companies of all shapes and sizes find and fix site speed issues.
Hi there, I’m Harry Roberts. I am an award-winning Consultant Web Performance Engineer, designer, developer, writer, and speaker from the UK. I write, Tweet, speak, and share code about measuring and improving site-speed. You should hire me.
You can now find me on Mastodon.
I help teams achieve class-leading web performance, providing consultancy, guidance, and hands-on expertise.
I specialise in tackling complex, large-scale projects where speed, scalability, and reliability are critical to success.