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.

Dependency graph of a blind SPA shell showing the hero image four executions deep behind the bundle and route chunk, with the hints that collapse each hop A left-to-right dependency graph. The index.html shell links to main-8f2c1a.js, the only node the scanner can see, one hundred milliseconds in. That bundle links to route-home.js via a dynamic import four hundred and sixty milliseconds later, and the route chunk fans out to the hero image, the brand font and the home API response, all of which are discovered only after JavaScript executes. Three cards below name the hint that removes each hop: modulepreload for the chunk, preload as image with fetchpriority high for the hero, and preload as font with crossorigin for the font. Every hop is a round trip the scanner cannot run ahead of Blind shell, 4G with 80 ms RTT: the hero URL sits three executions deep in the graph +100 ms +460 ms index.html shell, 2 KB main-8f2c1a.js 190 KB, scanner-seen route-home.js dynamic import() hero-1200.avif 140 KB, LCP brand-var.woff2 28 KB, blocks text /api/home.json gates the render What the shell can pre-empt modulepreload route-home.js collapses hop 2: the chunk starts at 40 ms, not 560 ms preload as=image plus fetchpriority=high pulls the LCP fetch back to 45 ms preload as=font crossorigin removes the third-hop wait and a 710 ms swap window

Read the graph as a depth problem rather than a bandwidth problem. Nothing in the right-hand column is large — the font is 28 KB and the API response is a few kilobytes — yet each one waits on the execution of the node to its left. Doubling the connection speed shortens the bars but not the chain; only moving a URL leftward into the shell shortens the chain.

What the Scanner Will and Will Not Evaluate

The scanner is more capable than “regex over the byte stream”, and knowing its exact competence tells you what you can rely on:

  • It resolves srcset and sizes against the initial viewport width, so a responsive <img> in server-rendered markup is discovered as the correct candidate, not the fallback src.
  • It honours media on <link>, so media="(min-width: 900px)" on a preload suppresses the fetch on phones — the cheapest way to keep a desktop hero hint from wasting mobile bandwidth.
  • It honours <base href> once that element has been tokenized, which is why a <base> tag placed below other resource references silently changes what got queued.
  • It sees <link rel=preload> and <link rel=modulepreload> in the shell. Hints in the head are ordinary markup; the scanner queues them exactly as it queues a <script src>. This is what makes the fix work at all.
  • It does not evaluate content inside <template>, because that content is inert until cloned.
  • It never sees CSS background-image URLs. Those require the CSSOM, which means the stylesheet must download and parse first — a hero implemented as a background image is late-discovered even in a fully server-rendered page.
  • It never sees markup produced by innerHTML, createElement, or a framework’s commit phase. By the time those nodes exist the document has finished parsing and the scanner has retired.
  • It can be invalidated by document.write. A synchronous script that rewrites the stream throws away speculative work, which is one more reason tag managers belong nowhere near the shell.

Engine Differences Worth Designing Around

All three engines run a speculative scanner, but the surrounding hint support is not uniform, and a fix tuned only in Chrome can leave half your traffic unimproved. Blink’s scanner runs alongside the background HTML parser and supports the full hint vocabulary: preload, modulepreload, fetchpriority, and 103 Early Hints for navigation responses. Gecko performs speculative parsing with the same effect but arrived at fetchpriority later, so on older Firefox builds the hint is honoured as a plain preload at the default priority for its destination — parallelism is restored, but the ordering nudge is not. WebKit’s scanner has shipped for years and supports preload and, from Safari 17, modulepreload; Early Hints are not consumed at all, so an edge-only strategy leaves Safari on the slow path.

The practical consequence is a layered fix. Static shell hints are the one mechanism every engine honours, so they carry the baseline. modulepreload is safe to emit unconditionally — engines that do not recognise the relation ignore the element rather than mis-fetching it. fetchpriority is a refinement on top, not a substitute: never rely on it to rescue a hint that is itself discovered late. And treat 103 Early Hints as an accelerator for the engines that read them, layered under a shell that already works without them.

Waterfall: Scanner-Visible MPA vs Blind SPA vs Hint-Patched SPA

Network waterfalls comparing an MPA with scanner-visible markup, a blind SPA shell with a serialized chain, and an SPA shell patched with static hints Three request waterfalls on a shared time axis. In the multi-page app the scanner queues CSS, hero image and font at fifty milliseconds and LCP lands near four hundred and thirty milliseconds. In a blind SPA shell the bundle, route chunk, API call and assets run one after another and LCP lands near one thousand two hundred and ninety milliseconds. In an SPA whose shell carries static preload and modulepreload hints the assets download alongside the bundle and LCP lands near five hundred and sixty milliseconds. Same assets, same bandwidth — only the moment of discovery changes MPA — markup visible to the scanner HTML CSS Hero image Web font LCP 430 ms SPA shell — scanner finds only the bundle HTML shell JS bundle Route chunk API data Hero image Web font LCP 1290 ms SPA + static hints in the shell Shell + hints JS bundle Hero (preload) Font (preload) Chunk (modulepreload) LCP 560 ms 0 400 ms 800 ms 1200 ms HTML document Discovered by the scanner in markup Discovered only after JS executes Discovered by a hint in the shell

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.

Two details decide whether this shell survives contact with a real build. First, the filenames are content-hashed, so the hint must be generated from the bundler’s manifest rather than typed by hand — a hint pointing at last week’s hash is a wasted fetch and leaves the real chunk late-discovered, doubling the damage. Wire the manifest into the HTML template step so the emitted href is derived from the same graph the bundler just wrote; the mechanics for Rollup-based tooling are covered in mapping Vite chunk graphs to modulepreload. Second, if the hero is responsive, a bare href will not match what <img srcset> eventually picks. Mirror the candidate list onto the hint with imagesrcset and imagesizes so the preload and the consumer resolve to the same byte-identical URL.

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 .js file — rather than the document or link[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 emitted href always 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 with srcset, 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=image response 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 resource entries and compare each critical asset’s startTime against the document’s responseStart. Post-fix, critical assets should start within 50–100 ms of the shell arriving; anything starting after bundle execution has regressed.

Route Transitions: Hook Order Decides the Whole Win

Steps 1–4 only ever help the first navigation. Everything after it is a soft navigation: the router swaps components inside an already-parsed document, no HTML is tokenized, and no scanner runs. Discovery for the second view is therefore entirely yours to schedule, and the only lever is where in the router’s lifecycle the hint is appended.

The difference between a good and a useless implementation is a few hundred milliseconds of lifecycle. A beforeResolve-style guard runs before the destination component is even constructed, which means the hint is in the document while the framework is still doing its own work; by the time the view renders, the fetch is either finished or well in flight. The same code inside the destination component’s mount effect runs after render, so the hint is appended at the exact moment the <img> element that needs it is already in the DOM — the browser had started the request a tick earlier anyway, and the hint buys nothing.

Sequence diagram of a soft navigation where the router injects preload links in its before-navigation hook, so assets are warm 238 milliseconds later when the view commits Four lifelines run down the diagram: router, hint injector, network and renderer. At zero milliseconds the router calls beforeResolve for the pricing route. At two milliseconds the injector appends three preload links, at four milliseconds it resolves and the router proceeds. The network holds the chunk and hero in flight from six milliseconds. At two hundred and thirty-eight milliseconds the warm cache entries reach the renderer and at two hundred and forty-two milliseconds the view commits with no cold fetch. A note at the bottom records that the same injection placed in a mount effect lands at two hundred and forty-two milliseconds instead and slips the in-view paint from two hundred and sixty-eight to four hundred and sixty-five milliseconds. Soft navigation to /pricing — hints appended before the view is constructed Router Hint injector Network Renderer 0 ms beforeResolve('/pricing') 2 ms append 3 link rel=preload 4 ms resolve() — router proceeds 6 ms chunk + hero in flight at High priority 238 ms warm cache entries 242 ms commit view — no cold fetch Same injection in a mount effect: the link lands at 242 ms hero starts 197 ms late and the in-view paint slips from 268 ms to 465 ms

The guard also owns cleanup. Hints appended for a destination the user then abandons — a fast second click, a back gesture during the transition — stay in the document and keep their fetches alive, which is how a well-meaning injector ends up competing with the route the user actually wanted. Keep the appended nodes in a list, and remove the previous transition’s list at the start of the next one:

let injected = [];
router.beforeResolve((to, from, next) => {
  injected.forEach((el) => el.remove());
  injected = assetsFor(to.name).map(({ href, as, priority }) => {
    const link = document.createElement('link');
    link.rel = as === 'script' ? 'modulepreload' : 'preload';
    if (link.rel === 'preload') link.as = as;
    if (as === 'font') link.crossOrigin = 'anonymous';
    if (priority) link.fetchPriority = priority;
    link.href = href;
    document.head.appendChild(link);
    return link;
  });
  next();
});

assetsFor() should be generated from the same build manifest that produced the shell’s hints, not maintained by hand. Two or three entries per route is the right order of magnitude: the destination chunk, the LCP candidate, and at most one data endpoint.

Edge Cases That Defeat a Static Shell Hint

The shell is a single cached artefact, which is exactly why some assets cannot be hinted from it:

  • Personalised or experiment-driven heroes. If the LCP image depends on a cookie, a bucket assignment, or geography, no static shell can name it. Move the decision to the edge and emit the hint as a Link header on the (already personalised) HTML response instead.
  • Client-hint-dependent variants. A hero served at 1× or 2× via DPR/Sec-CH-Viewport-Width negotiation will not match a fixed href; either preload the negotiated URL from the edge, or hint the imagesrcset list and let the browser pick.
  • Service worker interception. Preloads travel through the worker’s fetch handler like any other request. A worker that responds from a cache under a different key, or one that is not yet activated on the very first load, can turn a hint into a duplicate transfer — the service-worker hint injection approach exists partly to keep those two layers in agreement.
  • Hint lists that outgrow the connection. Ten simultaneous High-priority preloads do not make ten assets fast; they make the LCP candidate share a congestion window with nine others. Keep the shell to the assets that block the first paint and demote the rest.
  • Key mismatches that silently double-fetch. A hint whose as, crossorigin, or resolved URL differs from the consuming request produces two transfers plus a console warning; the trace-and-fix routine lives in debugging unused-preload warnings.

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.

Yes. The computed priority is derived from the as destination and any fetchpriority attribute, not from how the element entered the document. What differs is the clock: a shell hint is queued by the scanner within a few milliseconds of the first HTML bytes, while an injected hint cannot start before the script that appends it has downloaded, parsed, and run. Same tier, several hundred milliseconds apart — which is why injection is the tool for subsequent navigations and the shell is the tool for the first one.

Why does my hero image appear twice in the Network panel after adding a preload?

The hint and the consuming request are keyed on four things — resolved URL, destination (from as), request mode, and credentials mode — and they differ on one of them. For a responsive hero the usual culprit is a bare href on the hint while the <img> resolves a different candidate from srcset; mirroring imagesrcset and imagesizes onto the link fixes it. For fonts it is almost always a missing crossorigin, since the CSS-triggered fetch is unconditionally a CORS request.

Can I make route chunks scanner-visible by emitting them as plain script tags?

You can, and the scanner will happily find them, but doing so throws away the reason you split in the first place: a static <script> per route ships every route’s code on every navigation. modulepreload is the right tool — the chunk becomes scanner-visible and is fetched and compiled off the critical path, while execution stays under the router’s control. Emit it for the entry route only, and inject the rest at transition time.


Related