--- layout: post title: "Why Not document.write()?" date: 2023-01-10 16:17:11 categories: Web Development main: "https://csswizardry.com/wp-content/uploads/2023/01/lighthouse.png" meta: "We’re often told not to use document.write(), but… why?!" faq: - question: "Why is document.write() bad for performance?" answer: "document.write() forces scripts to block DOM construction during their download by hiding them from the Preload Scanner. This means that scripts injected with document.write() are always fully synchronous." last_modified_at: 2025-06-01 --- If you’ve ever run a Lighthouse test before, there’s a high chance you’ve seen the audit [Avoid `document.write()`](https://developer.chrome.com/docs/lighthouse/best-practices/no-document-write/):
For users on slow connections, external scripts dynamically injected via document.write() can delay page load by tens of seconds.
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. Happily, nothing about document.write() specifically will impact Interaction to Next Paint. Only if the JavaScript itself is particularly problematic or expensive will you suffer poor INP scores. If you are struggling with INP, let’s organise a workshop! **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()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/write) does a good job of discouraging its use. ## What Makes Scripts Slow? There are a number of things that can make regular, synchronous scripts[^1] slow: 1. Synchronous JS **can** block DOM construction while the file is downloading. * The belief that synchronous JS blocks DOM construction is only true in certain scenarios. 2. Synchronous JS **always** blocks DOM construction while the file is executing. * It runs in-situ at the exact point it’s defined, so anything defined after the script has to wait. 3. Synchronous JS **never** blocks downloads of subsequent files. * This has been true for [almost 15 years](https://www.stevesouders.com/blog/2008/03/10/ie8-speeds-things-up/) at the time of writing, yet still remains a common misconception among developers. This is closely related to the first point. 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 Preload Scanner 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 ` ``` 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 ` ``` 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](/2022/10/speeding-up-async-snippets/)—don’t use them. ## `document.write()` Executes Synchronously `document.write()` is almost exclusively used to conditionally load a synchronous script. If you just need **a blocking script**, you’d use a simple ` ``` If you needed to **conditionally load an asynchronous script**, you’d add some `if`/`else` logic to your async snippet. ```html ``` 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()`: ```html ``` 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.** ## Is It All Bad? 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. ### Early `document.write()` ```html ... ... ``` If you put a `document.write()` as the very first thing in your ``, it’s going to behave the exact same as a regular ` ... ``` This yields an identical waterfall:
Using a syncrhonous <script src> instead of document.write(). FCP is at 2.797s.
Because the Preload Scanner was unlikely to find either variant, we don’t notice any real degradation. ### Late `document.write()` ```html ... ... ``` 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](/2018/11/css-and-network-performance/#dont-place-link-relstylesheet--before-async-snippets), 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:
document.write() late in the <head>. FCP is at 4.073s.
Now that we’ve hidden the script from the Preload Scanner, we lose all parallelisation and incur a much larger penalty. ## It Gets Worse… 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 ``. 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!
The main thread goes completely silent during the injected file’s fetch. This doesn’t happen when files are fetched from the Preload Scanner.
## Avoid `document.write()` As well as exhibiting unpredictable and buggy behaviour as keenly stressed in the [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/write) and [Google articles](https://developer.chrome.com/blog/removing-document-write/), `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).** - - - [^1]: ``