Preload Scanner and Resource Loading
A browser that fetched resources strictly in document order would waste most of its bandwidth waiting. The main HTML parser stops dead every time it hits a synchronous <script> β it cannot build more DOM until that script is fetched, compiled, and run β so a naive engine would leave the network idle during exactly the window it most needs to be busy. The fix, shipped in every modern engine, is a second, lightweight tokenizer called the preload scanner (also known as the speculative or lookahead pre-parser). It runs ahead of the main parser, skims raw bytes for fetchable URLs, and kicks off those requests early β often before the main parser has even reached them. When the critical path is slow, it is almost always because that scanner was starved, blinded, or overruled. This topic is part of Browser Rendering Pipeline Fundamentals.
The engineering goal is narrow and measurable: get every render-critical byte β the above-the-fold CSS, the Largest Contentful Paint image, the fonts that shape visible text β requested in the first network round-trip, and push everything else out of the critical path without dropping it entirely. This page covers how the scanner works, why scripts stall it, how defer, async, fetchpriority, and <link rel="preload"> change the fetch order, and exactly which DevTools columns confirm the change landed.
How the Preload Scanner Races Ahead of the Parser
When the first bytes of the HTML response arrive, the browser forks two consumers of the same byte stream. The main parser does the expensive, stateful work: tokenizing, constructing DOM nodes, running inline scripts, and building the tree that HTML Parsing and Tokenization turns into the document. The preload scanner does none of that. It only tokenizes far enough to recognize elements that reference external resources β <img src>, <link href>, <script src>, <video poster> β and hands those URLs straight to the resource fetcher. Because it never blocks on script execution, it sprints past the point where the main parser is stuck.
The scanner has one structural limitation that governs almost every optimization on this page: it can only discover resources that are present as literal markup in the HTML response. A URL that only exists inside a JavaScript bundle, is injected by document.write, is assembled from a template at runtime, or lives behind a CSS @import is invisible to the scanner until the main parser catches up and the responsible code runs. The deeper mechanics β including which token types the scanner recognizes and how it deduplicates against the main parser β are covered in How the Preload Scanner Speculatively Loads Resources. The practical takeaway is that anything you want fetched early must appear as a static tag, or as an explicit hint, in the initial document.
Diagnostic Checklist
Before changing anything, confirm the symptom in the Network panel with Disable cache on and a representative throttle profile (Slow 4G is a reasonable stress test). The problem this topic solves is present when you observe any of the following:
- A render-critical resource β LCP image, hero CSS, or primary font β starts fetching late in the waterfall, well after the HTML finished downloading, rather than in the first cluster of requests.
- The Priority column shows a render-critical request marked
LoworMediumwhile non-critical requests sit atHigh. - A critical resourceβs URL is discovered only after a large JavaScript bundle downloads and executes (its Initiator column names a
.jsfile, not the document). - The Performance panel shows a long gap between the HTML response finishing and the first paint, with the main thread idle or blocked on script evaluation rather than on network.
- Removing a single
<script>from<head>dramatically improves First Contentful Paint in a local test β a sign it was blocking the parser and, transitively, everything the scanner would otherwise have surfaced after it. - An LCP image sits in a lazy-loaded carousel, a CSS
background-image, or a client-rendered component, so it is never in the static HTML the scanner reads.
Any one of these means bytes that gate the visible frame are not moving in the first round-trip. The rest of this page is the cure.
Root Causes of Late Resource Discovery
Late resource loading has a small number of distinct causes, and the fix differs for each. Naming the cause precisely β rather than reaching for preload reflexively β is what keeps you from adding hints that fight the browserβs own heuristics.
Cause 1 β a synchronous script blocks the parser. A classic <script src> with no defer or async stops the main parser and, in older engines, could even stall the scanner behind it. The scanner mitigates the network idle, but the DOM cannot advance, so anything the parser was about to discover β and any layout that depends on it β waits. The remedy is to stop blocking, covered under defer and async below.
Cause 2 β the resource is invisible to the scanner. This is the most common modern failure. An LCP image set through a CSS background-image, a font referenced only inside a stylesheet, a hero image mounted by a client component, or a module dynamically import()-ed β none of these appear as static tags, so the scanner cannot see them. The main parser fetches the CSS, the CSSOM completes per the CSSOM Construction Rules, the render tree matches the element, and only then does the request start β several round-trips late. The fix is an explicit <link rel="preload"> in the static <head>, which manufactures a tag the scanner can act on immediately.
Cause 3 β the priority is wrong. The resource is discovered on time but the browserβs default heuristic ranks it below its true importance. In-viewport images default to Low priority until layout proves they are visible; a critical fetch may sit behind less important ones. fetchpriority="high" corrects the ranking, as detailed in Using fetchpriority and preload for LCP Images.
Cause 4 β a CSS @import chains the fetch. An @import inside a stylesheet cannot be discovered until the parent stylesheet is downloaded and parsed, serializing two round-trips. Replacing it with a top-level <link rel="stylesheet"> in the HTML lets the scanner fetch both in parallel β the same principle explored in Optimizing Critical CSS for Faster First Paint.
Parser-Blocking and Render-Blocking Scripts (defer and async)
Two properties of a script matter for the critical path, and they are independent. Parser-blocking means the parser halts until the script runs; render-blocking means the first paint waits for it. A plain <script src> is both. Adding defer or async changes when the script downloads and executes relative to parsing, which changes how much of the path it holds up.
The rule of thumb: defer for anything that touches the DOM or has ordering dependencies, async for independent third-party tags, and neither only when the script must run before first paint. defer scripts download in parallel via the scanner, execute in document order after the DOM is complete, and never block parsing β this is the safe default for application bundles. async scripts also download in parallel but execute the instant they arrive, in unpredictable order, pausing the parser at that moment; they suit analytics or ad tags that depend on nothing. The full decision matrix, including module scripts and document.write hazards, lives in Deferring Non-Critical Scripts with defer and async.
<!-- BEFORE: parser-blocking. The main parser halts here until app.js is
fetched, compiled, and executed. Every tag below is discovered late,
and the scanner cannot surface the LCP image any sooner than this. -->
<head>
<link rel="stylesheet" href="/app.css">
<script src="/app.js"></script> <!-- blocks parsing of the rest of <head> and <body> -->
</head>
<!-- AFTER: defer moves execution past DOM construction. app.js now
downloads in parallel (surfaced by the preload scanner) and runs at
DOMContentLoaded in document order β parsing never pauses. -->
<head>
<link rel="stylesheet" href="/app.css">
<script src="/app.js" defer></script> <!-- non-blocking; runs after the DOM is built -->
</head>
A subtle trap: an inline <script> (no src) is always parser-blocking and cannot be deferred, and worse, it forces the browser to wait for any preceding stylesheet to finish so the script can query up-to-date styles. Placing an inline config script above your critical CSS link, or between the link and the content, can serialize what should have been parallel work β a first mention of Why CSS Blocks Rendering Until the CSSOM Is Built worth internalizing before you sprinkle inline scripts into <head>.
Steering Priority with fetchpriority and Resource Hints
Discovering a resource early is only half the job; the browser still schedules it against every other in-flight request using an internal priority. Each fetch gets an initial priority from its type and context β blocking CSS and fonts start High, images start Low and are re-prioritized to High once layout confirms they are in the viewport, and async scripts sit at Low. When the default is wrong for your page, fetchpriority and the resource hints override it.
The single highest-leverage change on most content pages is telling the browser which image is the LCP. Left alone, the hero image inherits Low priority and competes with everything else the scanner found; a one-line attribute promotes it without adding a request. Use <link rel="preload"> when the resource is hidden from the scanner and needs to be surfaced; use fetchpriority="high" when it is visible but under-ranked. Reserve preconnect for cross-origin hosts you will fetch from imminently β it warms the DNS, TCP, and TLS handshake so the eventual request skips the connection cost.
<!-- BEFORE: the hero image is in the markup, so the scanner finds it β
but images default to Low priority and are re-prioritized only after
layout, so it queues behind scripts and non-critical images. -->
<img src="/hero.avif" alt="Product" width="1200" height="630">
<!-- AFTER: fetchpriority promotes the LCP fetch to High immediately,
ahead of the layout-triggered re-prioritization. No extra request. -->
<img src="/hero.avif" alt="Product" width="1200" height="630"
fetchpriority="high">
<!-- For an LCP the scanner CANNOT see (CSS background, client component),
surface it with an explicit preload plus priority in the static head: -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<!-- Warm a third-party origin you will fetch fonts/images from soon: -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
Do not over-hint. Marking five resources high is the same as marking none β priority is relative, and a flood of preload tags can delay the genuinely critical byte by crowding the connection. Font preloads compete directly with the LCP image; if both are High, measure which one actually gates the visible frame and demote the other. The interaction with text rendering is handled in Font Loading and Text Rendering, and specifically in Preventing FOUT and FOIT with font-display.
Step-by-Step Fix Procedure
Work the problem in the DevTools panels that expose fetch order and priority, in this sequence. Each step names the exact panel and column to read.
- Network panel β capture a cold load. Toggle Disable cache, set throttling to Slow 4G, and reload. Right-click the column header and enable the Priority column if it is hidden. Sort by the waterfall Start Time.
- Read the Priority column against the waterfall. Identify your render-critical resources and confirm each is
Highand starts in the first request cluster. A critical resource atLow, or starting late, is your target. - Check the Initiator column. Hover it. If a critical resourceβs initiator is a
.jsbundle or a stylesheet rather than the document itself, the scanner never saw it β that is Cause 2 or Cause 4, and the fix is a static<link>tag orpreload. - Apply the one matching hint from the root-cause table β
defer/asyncto unblock,preloadto expose,fetchpriorityto re-rank, or a flattened<link>to break an@importchain. Change one thing. - Performance panel β verify. Record a fresh trace, open the Timings track, and read the LCP marker. Confirm the LCP request now starts earlier in the Network track of the same trace and that first paint moved left. Loop back to step 1 for the next resource.
[Network waterfall β BEFORE, Slow 4G]
Name Priority Initiator Start
ββ document Highest β 0 ms
ββ app.css Highest document 40 ms
ββ app.js High document 40 ms (blocks parser)
ββ analytics.js High document 250 ms
ββ hero.avif Low app.js 1400 ms β LCP image discovered late, ranked Low
[Network waterfall β AFTER: defer app.js, preload + fetchpriority hero]
Name Priority Initiator Start
ββ document Highest β 0 ms
ββ app.css Highest document 40 ms
ββ hero.avif High document 45 ms β surfaced by preload, ranked High
ββ app.js Low document 45 ms (deferred, no longer blocks)
ββ analytics.js Low document 120 ms
Edge Cases and Framework Interactions
Client-rendered frameworks are where the preload scanner most often goes blind, because the LCP element does not exist in the server response the scanner reads.
React (client-side rendering). A hero image rendered by a component only appears in the DOM after the JS bundle downloads, parses, and hydrates β the scanner never sees its src. Emit a static <link rel="preload" as="image" fetchpriority="high"> into the document <head> (via your document template, not a component effect) so the fetch starts during HTML download rather than after hydration. React 19βs <link>, preload(), and preinit() APIs hoist hints into <head> for you; on older versions, add the tag manually. Avoid useEffect-injected preloads β they run after paint, defeating the purpose.
Next.js. The next/image component with the priority prop emits both a preload and fetchpriority="high" for you β use it on the LCP image and nowhere else. The App Router streams HTML, so preload hints in the root layoutβs <head> are flushed early; hints added in a deeply nested Server Component may arrive after the critical markup. next/script with strategy="beforeInteractive" is parser-blocking by design and belongs only around genuinely blocking bootstraps; afterInteractive (the default) and lazyOnload behave like defer/lazy.
Vue / Nuxt. Nuxtβs useHead can inject link preload hints at SSR time so they land in the static response. A purely client-rendered Vue SPA has the same blindness as CSR React β the initial HTML is a near-empty shell, so no LCP resource is discoverable until the bundle runs. Server-render or statically prerender the above-the-fold markup so the scanner has real tags to act on.
SPA route transitions. The preload scanner only runs for full-document loads. Client-side navigations fetch resources through the router, not the scanner, so priority hints in the destination component are the only lever β plan preloading with the frameworkβs own prefetch APIs rather than expecting scanner behavior.
| Pipeline phase | Constraint | Cost if ignored |
|---|---|---|
| HTML download | Scanner sees only static markup | LCP/CSS/font discovered round-trips late |
| Parse (sync script) | Parser halts until script runs | DOM + all later discovery stalls |
| Fetch scheduling | Priority is relative and capped by connections | Critical byte queues behind noise |
| CSSOM build | @import serializes stylesheet fetches |
Extra round-trip before first paint |
| Render tree | CSS-referenced assets fetched only after match | Background/font images start very late |
Metric Targets
Validate every change against field-representative lab conditions (Slow 4G, mid-tier CPU throttle). A fix that only helps on a fast desktop connection has not addressed the audience that feels the jank.
| Metric | Target | Measurement method | Passing signal |
|---|---|---|---|
| LCP | β€ 2.5 s | Performance panel Timings track; field via CrUX | LCP request starts in first request cluster |
| LCP image request start | β€ 300 ms after HTML TTFB | Network panel waterfall Start Time | Begins before the JS bundle finishes |
| FCP | β€ 1.8 s | Performance panel; Lighthouse | No parser-blocking script before it |
| Render-blocking requests | 0 non-critical | Lighthouse βrender-blocking resourcesβ audit | Only critical CSS blocks paint |
| Critical resource priority | High | Network panel Priority column | LCP image, hero CSS, primary font all High |
| TBT | β€ 200 ms | Lighthouse; Performance long-tasks | Deferred scripts run after paint, not during |
Confirm field impact with the techniques in Core Web Vitals Measurement, and wire the render-blocking and LCP audits into CI so a regression β a new @import, a stray synchronous <script>, an LCP image that slipped behind a lazy-loaded component β fails the build before it ships.
In This Topic
- How the Preload Scanner Speculatively Loads Resources β the token types the lookahead pre-parser recognizes and why runtime-injected URLs stay invisible to it.
- Deferring Non-Critical Scripts with defer and async β the full decision matrix for parser-blocking,
defer,async, and module scripts. - Using fetchpriority and preload for LCP Images β surfacing and promoting the largest paint element without crowding the connection.
Frequently Asked Questions
Does the preload scanner run before or during HTML parsing?
It runs concurrently. As soon as the first bytes of the HTML response arrive, the browser feeds them to both the main parser and the speculative preload scanner. The scanner reads ahead of wherever the main parser is currently stalled, so during a parser-blocking script the scanner keeps discovering and fetching downstream resources even though the DOM is not advancing.
Why does my LCP image still load late even though it is in the HTML?
The scanner discovers it, but in-viewport images default to Low priority and are only re-prioritized to High after layout confirms they are visible β which happens well into page processing. Add fetchpriority="high" to the <img> so the fetch is ranked High from the start, ahead of the layout-triggered re-prioritization.
What is the difference between preload and fetchpriority?
rel="preload" makes a resource discoverable β it manufactures a static tag the scanner can act on when the real reference is hidden inside CSS or JavaScript. fetchpriority changes the relative ranking of a resource the browser already knows about. Use preload for hidden resources, fetchpriority for visible-but-under-ranked ones, and both together for a hidden LCP image.
Should I use defer or async for my application bundle?
Use defer. It downloads in parallel via the scanner, never pauses parsing, and executes in document order after the DOM is built β which respects dependencies between your scripts. Reserve async for fully independent third-party tags like analytics, where execution order does not matter and the script touches nothing your code owns.
Why is preloading a resource sometimes making my page slower?
Priority is relative and connection bandwidth is finite. Every high-priority preload competes with the genuinely critical byte, so preloading fonts, several images, and scripts at once can push your LCP resource later in the queue. Preload only what gates the visible frame, and measure after each hint rather than adding them speculatively.
Related Guides
- Browser Rendering Pipeline Fundamentals β the parent overview of every phase from HTML bytes to rasterized pixels.
- Critical Rendering Path Optimization β how render-blocking CSS and scripts gate First Contentful Paint, and how to shorten the path.
- HTML Parsing and Tokenization β the main parser the scanner races ahead of, and how synchronous scripts stall it.
- Font Loading and Text Rendering β preloading and prioritizing fonts without crowding the LCP image off the connection.