Fixing Preload Scanner Misses in Single-Page Apps
In a client-rendered SPA the browser’s speculative preload scanner discovers almost nothing — the HTML shell contains no image, font, or chunk URLs, so every critical asset is requested only after JavaScript downloads, parses, and executes, serializing a waterfall the browser was designed to parallelize.
Root Cause: The Scanner Reads Bytes, Not Render Output
The speculative preload scanner is a second, lightweight tokenizer that every major engine runs ahead of the main HTML parser. While the main parser is stalled on a render-blocking stylesheet or a synchronous script, the scanner keeps consuming the raw HTML byte stream, extracting fetchable URLs from src, href, and srcset attributes and handing them to the network stack immediately. This is the mechanism that makes a well-built multi-page site load its hero image, fonts, and stylesheets in parallel with parsing — discovery costs effectively zero milliseconds because the URLs are literal text in the document.
Client-side rendering removes exactly the input the scanner depends on. A typical SPA shell is a near-empty document: a <div id="root"></div>, one or two <script> tags, and little else. The scanner dutifully discovers the bundle — it is real markup — and then runs out of document. Every other URL the page will need exists only as string data inside the JavaScript: component templates, imported asset paths rewritten by the bundler, virtual-DOM trees. None of that is visible to a tokenizer that never executes code. The browser cannot request the hero image until the framework has downloaded, compiled, executed, rendered, and committed an actual <img> element to the DOM — at which point the scanner’s head start is long gone and the request enters the queue with cold-discovery timing that directly delays LCP.
Route-level code splitting deepens the hole by adding a second (and often third) discovery hop. The entry bundle executes, the router resolves the current URL, and only then does a dynamic import() request the route chunk; the chunk executes and only then are its own static imports and rendered assets discoverable. Each hop is a full network round trip plus parse-and-execute time, chained end to end. On a 4G connection with 80 ms RTT, three serialized hops routinely push first meaningful render past two seconds before a single pixel-critical byte has arrived. The fix is not to abandon splitting — it is to make the shell carry the discovery information the scanner and scheduler need, and to inject hints for the next hop before render, as covered in the dynamic hint injection reference.
Waterfall: Scanner-Visible MPA vs Blind SPA vs Hint-Patched SPA
The middle waterfall is the signature of a scanner-blind shell: five requests that could overlap instead stack diagonally, each one gated on the execution of the one above it. The bottom waterfall restores the MPA’s parallelism without abandoning client rendering — the shell simply tells the scanner what the JavaScript will eventually ask for.
Minimal Reproduction
The broken shell below gives the scanner nothing to work with; the fixed shell emits the discovery information as static markup that costs a few hundred bytes.
<!-- BROKEN: the scanner sees one script tag, then runs out of document.
Hero, font, and route chunk are all discovered after JS executes. -->
<div id="root"></div>
<script type="module" src="/assets/main-8f2c1a.js"></script>
<!-- FIXED: the shell carries the entry route's critical URLs so the
scanner queues them in parallel with the bundle download. -->
<head>
<!-- modulepreload fetches AND compiles the chunk off the critical path,
removing the second discovery hop entirely -->
<link rel="modulepreload" href="/assets/route-home-4b9d7e.js">
<!-- fetchpriority="high" marks this as the LCP candidate so it is not
queued behind the bundle at default image priority -->
<link rel="preload" as="image" href="/img/hero-1200.avif" fetchpriority="high">
<!-- crossorigin must match the CSS-triggered font fetch or the
browser double-fetches into two separate cache slots -->
<link rel="preload" as="font" type="font/woff2"
href="/fonts/brand-var.woff2" crossorigin="anonymous">
</head>
<div id="root"></div>
<script type="module" src="/assets/main-8f2c1a.js"></script>
When the framework later renders <img src="/img/hero-1200.avif">, the response is already in (or streaming into) the cache; render commits against a warm resource instead of starting a cold fetch.
Deterministic Fix Protocol
Work top to bottom; each step is verifiable in DevTools before moving on.
- [ ] 1. Inventory scanner-blind critical requests. Open the Network panel, reload with cache disabled, and sort by start time. Every request whose Initiator column reads a
.jsfile — rather than the document orlink[rel=preload]— was invisible to the scanner. Flag the ones that paint above the fold (hero image, primary font, entry route chunk). - [ ] 2. Emit static preloads for shell-level criticals at build time. Add
<link rel="preload">tags for the flagged assets to the HTML shell template. Hashed filenames must be resolved at build time — wire your bundler’s manifest into the HTML plugin so the emittedhrefalways matches the current build. Verify the Initiator column now shows the document for each. - [ ] 3. modulepreload the entry route’s chunk graph. Emit
<link rel="modulepreload">for the entry route chunk and its static imports so the module graph resolves in parallel with the bundle instead of hop by hop. Confirm the chunk’s request now starts within ~50 ms of navigation. - [ ] 4. Server-render or statically generate the first viewport. If the framework supports SSR/SSG, ship the entry route’s above-the-fold markup — real
<img>tags withsrcset, real text — in the HTML response. This makes the scanner itself do the discovery and removes the dependency on hints for the first navigation. - [ ] 5. Inject hints at route-transition time, before render. In the router’s before-navigation hook (not the component’s mount hook), append preload links for the destination route’s known assets. By the time the new view renders, its fetches hit warm cache entries. Cap injection at the destination’s critical assets and clean up stale links on the following transition.
- [ ] 6. Emit hint headers for the shell response. Add a
Link: </img/hero-1200.avif>; rel=preload; as=imageresponse header at the CDN or origin so discovery starts before the first HTML byte is parsed — headers arrive ahead of markup and pair naturally with a 103 interim response where the edge supports it. - [ ] 7. Verify the discovery gap collapsed. Run a PerformanceObserver over
resourceentries and compare each critical asset’sstartTimeagainst the document’sresponseStart. Post-fix, critical assets should start within 50–100 ms of the shell arriving; anything starting after bundle execution has regressed.
Before/After Metrics
Measured on a throttled 4G profile (20 Mbps down, 80 ms RTT, 4× CPU slowdown) against a React SPA with route splitting, a 190 KB entry bundle, and a 140 KB AVIF hero. “After” applies steps 1–6.
| Metric | Blind shell | Hint-patched shell | Change |
|---|---|---|---|
| Hero image discovery time | 1010 ms | 45 ms | −965 ms |
| Serialized waterfall depth (critical chain) | 5 hops | 2 hops | −3 hops |
| LCP (p75, lab) | 2480 ms | 1190 ms | −52% |
| Route chunk start time | 620 ms | 40 ms | −94% |
| Font swap window | 710 ms | 90 ms | −87% |
| Lighthouse “Preload key requests” savings | 1180 ms flagged | 0 ms | resolved |
The LCP gain comes almost entirely from discovery, not transfer: the hero’s download duration is identical in both runs, but it starts one second earlier because the shell — not the executed bundle — announced it.
FAQ
Why does the preload scanner find my JS bundle but none of my images?
The scanner reads only markup in the raw HTML byte stream. Your <script src> tag is literal markup in the shell, so it is discovered instantly. Image, font, and chunk URLs exist only as strings inside the bundle and virtual-DOM output, which the scanner never executes or inspects — they become network requests only when the framework commits real DOM nodes referencing them.
Should I emit a preload hint for every route chunk at build time?
No. Preload only the entry route’s chunk graph and critical assets in the static shell. A preload for a route the user never visits is pure wasted bandwidth that competes with critical requests, and Chrome logs an unused-preload console warning when a preloaded resource goes unconsumed for ~3 seconds. Chunks for other routes belong behind route-transition or hover-triggered injection, or a low-priority prefetch that yields to critical traffic.
Does server-side rendering make dynamic hint injection unnecessary?
Only for the first navigation. SSR puts real markup in the byte stream, so the scanner does its normal job on initial load. Every subsequent in-app route change is client-rendered against an already-parsed document — no scanner runs on DOM mutations — so assets for the next view still need runtime injection at transition time. The two techniques are complements: SSR for arrival, injection for everything after.
Related
- Dynamic Hint Injection via JavaScript — parent section: injection timing, concurrency guards, and framework lifecycle patterns
- How Browser Fetch Priority Affects LCP — why late-discovered LCP candidates enter the queue at the wrong tier
- Resource Hint Implementation & Preloading Strategies — up to the section root