How document.write Blocks the HTML Parser
A synchronous document.write called from a parser-blocking script freezes tokenization mid-stream, splices new bytes into the input the parser is actively consuming, and forces the tree-construction stage to re-enter itself β the visible symptom is a long Evaluate Script slice followed by a second Parse HTML slice for markup the preload scanner never saw. This guide is part of HTML Parsing and Tokenization, itself one stage of Browser Rendering Pipeline Fundamentals, and it focuses narrowly on the parser re-entrancy that document.write triggers β not on generic script deferral, which the sibling guide How browsers parse HTML into DOM nodes already covers.
A Minimal Reproduction
The smallest page that exhibits the stall injects a stylesheet through a document-written tag. Third-party tag managers, A/B-test snippets, and ad loaders still ship this pattern because it guarantees the written markup lands before the rest of the body parses.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>Product page</h1>
<script>
// BAD LINE: synchronous document.write during active parse
document.write('<link rel="stylesheet" href="/experiment.css">');
</script>
<p>The paragraph below the script cannot tokenize until write() returns.</p>
</body>
</html>
The inline <script> is parser-blocking by definition, so the tokenizer has already stopped to run it. The document.write call then re-opens the byte stream the parser was reading and pushes <link rel="stylesheet"> into it. Because a stylesheet is now render-blocking and was written by a script, the browser cannot resume tokenizing the <p> until the JavaScript engine returns control β and if that written tag is itself another <script>, the whole cycle nests one level deeper.
How the Tokenizer Re-Enters Itself
The HTML parser is a single-threaded state machine driven by one shared data structure: the input stream, an in-memory buffer of characters with a single read cursor called the insertion point. The tokenizer reads characters at the insertion point, emits tokens, and hands each token to tree construction. Under normal parsing the insertion point only advances forward through bytes the network delivered.
document.write violates that one-directional contract. The spec models it by inserting the written string into the input stream at the insertion point β physically ahead of the cursor, so the tokenizer will read the injected characters next, before it reaches the rest of the original document. To do this safely the browser first sets a script nesting level and an insertion point marker, tokenizes the newly written text in a re-entrant call, and only then lets the outer parse continue. Three consequences follow, and each one costs you frame budget:
| Pipeline phase | Constraint | Cost |
|---|---|---|
| Tokenization | Insertion point rewound to splice written bytes | Characters after the write are re-scanned in a nested tokenizer pass |
| Tree construction | Re-entrant call runs before outer parse resumes | Speculative parse tree past the script is discarded |
| Preload scanning | Written subresources are invisible until write executes | Fetches for written CSS/JS start late, serialized behind script execution |
The most expensive of these is the discarded speculative work. Modern engines run a preload scanner β a lightweight, look-ahead pass that races ahead of the main tokenizer to discover <link>, <img>, and <script src> so their fetches start early. That scanner reads only the bytes the network already delivered; it has no way to know a script will later document.write more markup. When the write lands, everything the scanner speculated past the injection point may no longer be valid, and the fetches it would have kicked off for the written resources never happened. You pay for that as a late, serialized network request stacked on top of the re-tokenization.
Reading the Re-Tokenization in a Trace
In a Chrome DevTools Performance recording the pattern is unmistakable once you know its shape: a Parse HTML slice that stops early, an Evaluate Script task nested under the script token, and then a second Parse HTML slice covering markup that logically belonged to the first. The give-away that this is document.write and not an ordinary blocking script is that the second Parse HTML slice re-processes content the trace already attributed to the first, and any resource fetched inside it starts at the scriptβs end time rather than at navigation start.
[Main Thread] β navigation @ 0ms
ββ ParseHTML (Tokenize) 0.0ms β 9.6ms
β ββ token: <script> (inline) β tokenizer yields here
ββ EvaluateScript 9.6ms β 41.2ms β 31.6ms, ~2 frame budgets
β ββ document.write("<link ...>") β splices into input stream
β ββ (re-entrant) Parse written markup
ββ ParseHTML (resume / re-tokenize) 41.2ms β 47.8ms β re-scans spliced tail
β ββ token: <link rel=stylesheet> β preload scanner never saw this
ββ Resource: /experiment.css 41.3ms β 88.5ms β fetch starts AFTER script, serialized
ββ UpdateLayoutTree / Layout 88.9ms β 96.1ms β blocked on late stylesheet
Two numbers matter here. The gap from 9.6ms to 41.2ms is the parser sitting idle while the engine runs write(). The experiment.css fetch beginning at 41.3ms β not at navigation β is the preload-scanner miss made visible; on a real connection that request adds a full round-trip that renders the whole main thread idle while nothing paints. Because the written stylesheet is render-blocking, it now also gates CSSOM construction and every downstream stage, so render tree generation waits on a resource that was discovered as late as physically possible.
Chrome adds one more signal worth searching for. Since Chrome 55, a document.write that injects a cross-origin, render-blocking script over a slow (effective 2G) connection is silently ignored and logged to the console as βA parser-blocking, cross site β¦ script β¦ is invoked via document.write. The network request for this script MAY be blocked by the browser.β If you see that intervention warning, the browser has already decided the pattern is too costly to honor β treat it as a hard bug, not a deprecation notice.
Replacing Injected Scripts
The fix is to stop feeding the parser through its input stream and instead mutate the DOM through the normal node-insertion API after the current parse task, or to declare the resource in static markup so the preload scanner can find it. Which replacement you pick depends on whether the written content is a subresource link or executable script.
The before/after below is a complete, runnable pair. The βbeforeβ is the legacy injected loader; the βafterβ creates the element imperatively and appends it, which never touches the input stream and therefore never re-enters the tokenizer.
<!-- BEFORE: parser-blocking, re-tokenizes, invisible to preload scanner -->
<script>
document.write(
'<script src="https://cdn.example.com/widget.js"><\/script>'
); // splices a <script> into the input stream at the insertion point
</script>
<!-- AFTER, option A: static tag β preload scanner discovers it, no re-entry -->
<script src="https://cdn.example.com/widget.js" async></script>
<!-- AFTER, option B: imperative insertion when the URL is computed at runtime -->
<script>
const s = document.createElement('script');
s.src = 'https://cdn.example.com/widget.js';
s.async = true; // fetch + execute off the critical parse path
document.head.appendChild(s); // node insertion, never re-enters the tokenizer
</script>
For a written stylesheet, replace document.write('<link ...>') with a static <link rel="stylesheet"> in the head so it is render-blocking on purpose and discovered immediately, or append it with document.createElement('link') if the href is only known at runtime. If the legacy code came from a third-party tag whose source you cannot edit, load that tag itself with async and require the vendorβs non-document.write snippet β most ad and analytics vendors now ship one. The broader trade-offs of what to load render-blocking versus deferred belong to Critical Rendering Path Optimization, and the long-task accounting you use to confirm the win is covered in Observing long tasks with PerformanceObserver.
Verification Checklist
After removing every document.write from the critical path, re-record a trace and confirm each item:
Frequently Asked Questions
Is document.write always blocking, or only in some cases?
Any document.write executed while the parser is still open blocks, because it splices bytes into the input stream the tokenizer is actively reading and forces a re-entrant parse. The far worse case is calling it after parsing finishes β from an async script, a timer, or an event handler β because then write() implicitly calls document.open(), which wipes the entire existing document before writing. Both are bugs; the post-load form is catastrophic.
Why does the preload scanner miss resources written by document.write?
The preload scanner reads only the bytes already delivered by the network, scanning ahead of the main tokenizer to start fetches early. Markup produced by document.write does not exist in that byte stream β it only materializes when the script runs β so the scanner cannot discover it. The written resource is fetched serially after script execution instead of in parallel, which is the single largest cost of the pattern.
Does adding async or defer to the script fix document.write?
No, and it makes things worse. defer and async scripts run after the parser has closed the document, so a document.write inside them triggers document.open() and erases the page. Attribute changes fix parser-blocking script execution but never make document.write safe. You must replace the write() call itself with DOM insertion or a static tag.
How do I confirm in DevTools that document.write caused a stall?
Record a Performance trace and look for an EvaluateScript task wedged between two Parse HTML slices covering the same document region, plus a subresource whose fetch begins at that scriptβs end time rather than at navigation. Under 2G throttling, also watch the console for Chromeβs parser-blocking intervention warning, which fires specifically on cross-origin scripts injected via document.write.
What replaces document.write for a third-party tag I cannot edit?
Load the vendor tag itself with async and request their non-document.write snippet β most analytics and ad vendors now publish one that uses createElement plus appendChild. If none exists, wrap the loader so it appends elements imperatively rather than writing them, or sandbox it in an iframe so its writes cannot re-enter your main documentβs parser.
Related Guides
- How browsers parse HTML into DOM nodes β the general parser-blocking stall this page specializes.
- HTML Parsing and Tokenization β the tokenizer and preload-scanner model these re-entries disturb.
- Critical Rendering Path Optimization β deciding what to load render-blocking versus deferred once the writes are gone.
- Observing long tasks with PerformanceObserver β measuring the main-thread cost you just removed.