How the Preload Scanner Speculatively Loads Resources
When a synchronous script stalls the HTML parser mid-document, the render-blocking stylesheet three lines below it can still start downloading β because a second, lightweight tokenizer called the preload scanner is racing ahead of the paused main-thread parser during the tokenization phase. This guide is part of Preload Scanner and Resource Loading, within Browser Rendering Pipeline Fundamentals, and it explains exactly which thread runs the scanner, what data structure it feeds, and why resources that only exist after a style resolution or a script execution stay invisible to it.
The symptom that brings engineers here is specific: you profile a load, see the main-thread parser blocked on a <script>, and expect every subsequent fetch to be blocked too β but the waterfall shows CSS and hero images starting during the block. That is the scanner working. The failure mode is the mirror image: a critical resource that starts far too late because it was never in the raw byte stream the scanner reads.
Reproducing the Late Discovery Stall
The scanner only sees tokens it can produce from the raw response bytes. Anything a resourceβs URL depends on β a computed style, a script-built element, an import resolved at runtime β is opaque to it. Here is a document that hides its most important image from the scanner:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/app.css"><!-- scanner sees this, fetches immediately -->
<script src="/analytics.js"></script><!-- sync script: main parser BLOCKS here -->
</head>
<body>
<div id="hero"></div>
<script>
// BAD: the LCP image URL only exists after this script runs,
// so the preload scanner never queued it during the parse block above.
document.getElementById('hero').style.backgroundImage = "url('/hero-2400.jpg')";
</script>
</body>
</html>
The stylesheet at the top is discovered and fetched the instant the scanner tokenizes the <link>, even while the main parser is frozen on analytics.js. But hero-2400.jpg β the largest contentful paint candidate β is a string inside a script body. No tokenizer emits a fetchable token for it, so the request does not begin until the script executes, which is after the parser unblocks, after analytics.js runs, and after the DOM node is reached. On a warm connection that easily costs 400β600ms of dead time before the LCP image even enters the network queue.
The diagram below contrasts the two lanes: the main-thread parser sitting idle on the script, and the scanner independently pulling ahead to fetch what it can see.
How the Preload Scanner Works
The preload scanner is not a separate OS thread in most engines; it is a secondary tokenizer that runs on the main threadβs parser, driven by the same incoming byte buffer but detached from tree construction. When the primary parser suspends β because a synchronous script must be fetched and executed before the next token can be inserted into the DOM β the scanner is free to keep consuming bytes past the suspension point. In Blink this is HTMLPreloadScanner, fed by the HTMLTokenizer output; it walks tokens looking only for elements that carry fetchable URLs and emits PreloadRequest objects. It never builds nodes, never runs script, and never touches the DOM tree, so it cannot be blocked by the things that block real parsing.
Each PreloadRequest the scanner produces is handed to the resource fetcher and enters the same fetch queue that ordinary parser-discovered resources use, subject to the connectionβs priority ordering. The scanner effectively front-runs the network: by the time the primary parser catches up to a <link> or <img>, the response is often already in the memory cache, so the βrealβ request is served instantly. This is why the scanner is described as speculative β it fetches on the bet that the tokens it saw will survive into the real parse, which they almost always do because scripts rarely rewrite the head.
Two properties matter for performance work. First, the scanner reads the response body as it streams in, so it can discover resources long before the closing tag arrives β buffering the whole document is not required. Second, because it shares the tokenizer with the main parser, an inline script that calls document.write() can invalidate the speculative work and force a re-scan; this is one reason document.write() is a hard anti-pattern. The scannerβs whole value is that the byte stream is stable enough to read ahead of.
Reading the Trace
A Chrome DevTools Performance recording exposes the scanner through initiator attribution. Resources the scanner discovered show a βPreload Scannerβ or parser initiator with a start time that predates the main parser reaching that DOM position. The tree below annotates a real capture of the reproduction above β note where app.css and hero-2400.jpg diverge:
Frame 1 Β· Navigation start ........................ 0 ms
ββ Receive HTML bytes (streaming) ................. 12 ms
ββ Parse HTML [main thread] ...................... 14 ms
β ββ <link rel=stylesheet app.css>
β β ββ initiator: parser (preload scanner) ..... 15 ms β fetched during block
β ββ <script src=analytics.js> β PARSER BLOCKED
β β ββ initiator: parser ....................... 15 ms
β β ββ [main thread idle, awaiting script] 15β470 ms
β ββ Preload scanner (parallel)
β ββ app.css download complete ............... 138 ms β ready early
ββ analytics.js executes .......................... 470 ms
ββ Parse resumes β reach inline <script> .......... 474 ms
β ββ style.backgroundImage = url(hero-2400.jpg)
β ββ initiator: script (NOT scanner) ......... 476 ms β discovered late
ββ hero-2400.jpg download complete ................ 690 ms
ββ Largest Contentful Paint ................... 705 ms
The load-bearing detail is the initiator column. app.css reads parser (preload scanner) and lands at 138ms; hero-2400.jpg reads script and does not even start until 476ms. That 461ms gap is pure late-discovery cost, and no amount of connection tuning removes it β the request simply did not exist earlier. This is the same class of critical-path stall covered in Critical Rendering Path Optimization, viewed specifically through the lens of what the scanner can and cannot enqueue.
What the Scanner Cannot See
The scannerβs blind spots all share one trait: the URL is not a literal attribute value in the served markup. Anything computed after the byte stream is parsed is invisible. The decision tree below is the mental model to apply when you audit a waterfall for late resources.
The three most common late-discovery sources are: images referenced only from a background-image (the scanner does not parse CSS, so these wait for CSSOM construction and style resolution to complete); modules pulled in via runtime import() (the specifier is a runtime string); and anything a framework sets with element.src = ... after hydration. For each, the fix is to give the scanner a literal token it can act on.
The Fix
The remedy is to hoist the hidden URL into a real markup token the scanner can tokenize β either a <link rel="preload"> in the head or a plain <img> with an explicit src. Both put a literal, fetchable URL into the byte stream before the blocking script, so the scanner queues the request during the parser stall instead of after it. The <link rel="preload"> approach also lets you set fetchpriority="high" so the LCP image jumps the queue ahead of lower-value resources. Here is the before/after for the reproduction:
<!-- BEFORE: LCP image URL hidden inside a script string -->
<head>
<link rel="stylesheet" href="/app.css">
<script src="/analytics.js"></script>
</head>
<body>
<div id="hero"></div>
<script>
// scanner never saw this URL; fetch starts at ~476ms
document.getElementById('hero').style.backgroundImage = "url('/hero-2400.jpg')";
</script>
</body>
<!-- AFTER: literal URL in the head, queued by the scanner during the parse block -->
<head>
<link rel="stylesheet" href="/app.css">
<!-- scanner tokenizes this immediately; fetch starts at ~15ms -->
<link rel="preload" as="image" href="/hero-2400.jpg" fetchpriority="high">
<script src="/analytics.js"></script>
</head>
<body>
<!-- prefer a real <img> so the resource is both preloaded AND rendered -->
<img id="hero" src="/hero-2400.jpg" alt="" width="2400" height="1200">
</body>
The as="image" attribute is mandatory β without it the browser cannot assign the correct request priority or content-type expectation, and it may warn about an unused preload. If the real element is an <img>, you often do not need the separate preload at all: the <img src> is itself a literal token the scanner reads. Reach for <link rel="preload"> when the element genuinely cannot carry the URL (a CSS background on the LCP element, for instance). This complements the font-side technique in Preventing FOUT and FOIT with font-display, where the same preload mechanism moves a font fetch forward by a full round-trip.
| Pipeline phase | Constraint | Cost if missed |
|---|---|---|
| Tokenization | URL must be a literal attribute | Resource invisible to scanner |
| Speculative fetch | Queue depth + priority | LCP asset starved behind low-value fetches |
| Script execution | Blocks primary parser, not scanner | Late-discovered URLs wait for the block to clear |
| Style resolution | background-image needs CSSOM |
Image fetch deferred past first style recalc |
Verification Checklist
Confirm the scanner is doing its job after each change:
Frequently Asked Questions
Is the preload scanner a separate thread from the main thread?
In most engines it is not a separate OS thread β it is a secondary tokenizer that shares the main threadβs incoming byte buffer but runs detached from tree construction. What makes it independent is not a different thread but that it never builds DOM nodes or executes script, so the things that suspend the primary parser (a synchronous <script>) do not suspend it.
Why does a background-image on my hero element load so late?
Because the scanner does not parse CSS. A background-image: url(...) only becomes a known resource after CSSOM construction and style resolution assign that value to the element, which happens well after the byte stream is tokenized. Give the scanner a literal token instead β a <link rel="preload" as="image"> in the head or a real <img src> element.
Does rel=preload replace what the preload scanner does?
No β they solve different halves of the same problem. The scanner discovers URLs that are already literal in the markup; rel="preload" is how you make an otherwise-hidden URL literal so the scanner can act on it early. If a resource is already a plain <img src> or <link>, the scanner finds it without any preload hint.
Can document.write break speculative loading?
Yes. Because the scanner shares the tokenizer with the primary parser, a script that calls document.write() can rewrite the byte stream ahead of the current parse position, invalidating any speculative requests the scanner already issued for that region and forcing a re-scan. That wasted work is one of the main reasons document.write() is discouraged.
Related Guides
- Preload Scanner and Resource Loading β the parent topic covering speculative fetching and resource priorities end to end.
- Debugging Parser-Blocking Script Stalls During DOM Node Construction β what the primary parser is doing while the scanner races ahead.
- Critical Rendering Path Optimization β where early fetching sits in the larger first-paint budget.
- Why CSS Blocks Rendering Until the CSSOM Is Built β why CSS-referenced resources are invisible to the scanner until style resolves.