Core Browser Loading Mechanics & Priority Queues

Modern browsers are concurrent network schedulers. From the moment a navigation begins, the networking layer opens connections, speculatively scans markup, and assigns every fetch request a dynamic priority tier — all before a single pixel renders. This page maps that entire system: how the request lifecycle unfolds, which resources block rendering and why, how fetchpriority and resource hints shift queue positions, how scripts and viewport-driven fetches interact with the queue, and where to look in DevTools when something goes wrong. It is aimed at frontend engineers and performance specialists who need precise, actionable control over how their pages load.

Everything below is engine behaviour, not folklore: where Chromium, WebKit and Gecko diverge, the divergence is named. Where a number is quoted, it comes from a measurable threshold you can reproduce in your own trace.


How the Browser’s Network Architecture Works End-to-End

The Request Lifecycle from Navigation to Paint

When the browser receives the first bytes of an HTML document, two concurrent systems activate. The main HTML parser begins building the DOM tree node by node. In parallel, the preload scanner (sometimes called the speculative parser) reads ahead in the raw byte stream, extracting resource URLs from <link>, <img>, <source> and script elements before the main parser reaches them. This lookahead is the single most impactful latency-reduction mechanism in the browser, because it initiates DNS resolution, TCP connection setup, and TLS negotiation while the DOM is still being constructed.

Once the transport layer is ready, the network dispatcher manages which requests consume available bandwidth. Under HTTP/1.1, the browser opens up to 6 TCP connections per origin and queues surplus requests. Under HTTP/2 and HTTP/3, a single multiplexed connection carries all streams simultaneously — but the browser still must decide which streams get bandwidth when the link is congested. That decision is made by the priority scheduler, which assigns every request a priority level and re-evaluates continuously as the page renders.

The diagram below traces the full path from navigation trigger to first paint, showing where the preload scanner, priority queue, and cache layer each intervene:

Browser request lifecycle from navigation to first paint across five subsystem lanes A five-lane swimlane timeline. Navigation, transport, preload scanner, priority queue and cache/network each occupy a lane, with the work each performs placed against a shared round-trip time axis. Arrows show the handoff order: navigation triggers the handshake, the first HTML bytes wake the preload scanner, the scanner feeds the priority queue, the queue dispatches to cache or network, and the returned bytes produce the first paint. Navigation to first paint: where each subsystem intervenes Navigation Transport Preload scan Priority queue Cache / network User navigates First paint / LCP 1.4 s wall clock on a mid-tier phone DNS, TCP and TLS handshake TTFB — first bytes one h2 connection, many streams Speculative scan, 8 URLs misses JS-injected URLs Tiers assigned: Highest to Idle 2 Highest, 3 High, 3 Low re-ranked as layout lands Cache hit skips the queue Miss goes on the wire dispatched in tier order t = 0 +1 RTT +2 RTT first paint

The important structural detail in that diagram is that the five lanes are not sequential stages of one pipeline — they are five systems running at once. The transport lane is still finishing a TLS handshake to a third-party origin while the preload scanner is already queueing first-party images. A stall in any one lane shows up as idle time in the lanes below it, which is exactly what a waterfall visualises.

Where the Preload Scanner Fires — and Where It Misses

The preload scanner operates on raw bytes, not on a completed DOM. It recognises static <link rel="preload">, script src, <img src>, and <link rel="stylesheet"> declarations reliably. It cannot see resources that are injected by JavaScript at runtime, referenced through CSS url(), or loaded inside <template> elements. Those resources enter the priority queue late, after the main parser reaches the responsible code — a common root cause of late-loading LCP images.

The blind spots are worth enumerating precisely, because each one has a different fix:

  • document.write() and DOM-injected tags. The scanner never sees them. The URL becomes known only when the injecting script runs, which on a blocking script means after the network fetch and compile of that script.
  • CSS url() references. Background images, @font-face sources and mask-image URLs are discovered when the CSSOM is built and the element that uses them is styled. For a background-image LCP candidate this typically lands 300–800 ms after navigation.
  • @import inside a stylesheet. The scanner reads the <link> but not the imported file’s contents, so each import level costs a full round trip in sequence.
  • srcset and sizes with viewport-relative units. The scanner does evaluate srcset, but it must guess the layout viewport before layout has happened. A sizes value that depends on a container width the scanner cannot know can cause it to speculatively fetch the wrong candidate.
  • <link> inside <body>. Legal, and the scanner does find it, but it is discovered later in the byte stream than a <head> declaration, so it starts behind everything above it.
  • Elements after a very large inline script or <style> block. The scanner buffers a limited window of markup. A 200 kB inline script early in the document pushes everything after it out of the immediate lookahead.

Chromium’s implementation lives in HTMLPreloadScanner, WebKit’s in HTMLPreloadScanner as well (a separate codebase with similar behaviour), and Gecko’s in its speculative parser. All three share the same fundamental limitation: they are token-level scanners with no layout, no style resolution, and no script execution. Anything that requires one of those three to produce a URL is invisible to them.

Discovery, Scheduling and Transport Are Three Separate Queues

It is tempting to speak of “the queue”, but a request passes through three distinct bottlenecks and a stall in each looks different in a waterfall.

  1. Discovery. Until a URL is known, no amount of priority tuning helps. Discovery latency is the gap between navigation start and the request appearing in the network log at all. It shows up as a bar that simply starts late, with no visible queueing or stalled segment.
  2. Scheduling. Once discovered, the request sits in the browser’s own priority queue waiting for a socket (HTTP/1.1) or waiting for the scheduler to decide it deserves bytes (HTTP/2 and HTTP/3). This is the Queueing and Stalled segments in DevTools.
  3. Transport. Finally the bytes move, subject to the congestion window, the server’s own scheduling, and any head-of-line blocking on the connection. This is Waiting (TTFB) plus Content Download.

Reading the three apart is the whole skill of waterfall analysis: a late start is a markup problem, a long stall is a scheduling problem, and a long download at full priority is a bytes problem. The network waterfall anatomy and timing metrics guide breaks each segment down, and diagnosing request queueing and stalled time covers the specific case where the stall dominates.

Connection Limits and the Socket Pool

Chromium maintains a socket pool per origin and a global cap. On HTTP/1.1 the per-origin limit is 6 and the global limit is 256; Firefox uses 6 per origin with a 900-connection global ceiling; Safari uses 6 per host. When the seventh request to an origin appears, it waits in the pool and DevTools reports the wait as Stalled.

Under HTTP/2 those limits mostly disappear — one connection, up to the server’s SETTINGS_MAX_CONCURRENT_STREAMS (commonly 100 or 128) — but a new constraint replaces them: every stream shares one congestion window. Dispatching 120 requests at once does not make them arrive sooner; it makes all of them arrive at roughly the same, later, time. That is why request-count budgets remain useful even on modern protocols, and why the HTTP/2 and HTTP/3 multiplexing behaviour of your edge matters as much as your markup.


Priority Tier Reference Table

The browser’s network scheduler assigns every request one of five named priority levels. Chromium’s implementation maps these to internal net::RequestPriority values; WebKit and Gecko use equivalent internal enumerations with slightly different mapping rules.

Priority Level Chromium Label Typical Resources Blocks Render?
Highest HIGHEST Main document, synchronous script, CSS in <head> Yes
High MEDIUM (internal) fetchpriority="high" images, fonts with preload, XHR in render path Conditional
Medium LOW (internal) Images in viewport (no hint), async scripts that modify layout No
Low LOWEST Images below the fold, prefetched resources, deferred scripts No
Idle IDLE <link rel="prefetch">, background sync requests No

Browser differences. Chromium (Chrome, Edge) exposes priority in the Network panel’s Priority column. Safari’s Web Inspector labels them Very High / High / Medium / Low / Very Low. Firefox DevTools shows Highest / High / Normal / Low / Lowest. All three engines implement the fetchpriority attribute (Chrome 101+, Safari 17.2+, Firefox 132+), but the exact internal weight each engine assigns differs — particularly for images outside the initial viewport.

The matrix below takes six resource declarations that appear on almost every page and shows what each engine actually does with them. The rows that disagree are the ones that will bite you when a fix validated in Chrome fails to reproduce in Safari:

Comparison matrix of the priority tier Chrome, Safari and Firefox assign to six common resource declarations A six-row comparison matrix. Each row names a resource declaration and three cells give the priority tier Chrome 126, Safari 17.5 and Firefox 132 assign to it. Colour encodes the tier from Highest down to Idle. Four of the six rows disagree across engines; only the explicitly hinted image and the below-the-fold image behave consistently. Six declarations, three schedulers — where the labels disagree Declaration in the markup Chrome 126 Safari 17.5 Firefox 132 async script, no hint Low Medium Normal in-viewport img, no hint Medium then High Medium Normal below-the-fold img Low Low Lowest preloaded WOFF2 font High High Highest img fetchpriority=high High High High link rel=prefetch Idle Low Lowest Four of six rows disagree. Only the explicitly hinted image lands on the same tier in all three engines.

The pattern in that matrix is the practical argument for explicit hints: the moment you state your intent, the three engines converge. Left to heuristics they diverge, and they diverge most on exactly the resources — async scripts, prefetches, fonts — where the cost of being wrong is highest. The engine-by-engine detail behind each cell is covered in Chrome vs Safari vs Firefox priority differences.

fetchpriority value reference:

Value Effect When to Use
high Elevates request toward Highest queue Hero images, above-fold fonts, critical JSON
low Demotes request below default tier Below-fold images, analytics, non-critical preloads
auto (default) Browser heuristic Everything not explicitly hinted

fetchpriority is a hint, not a directive, and it shifts the computed tier rather than replacing it. Applied to a resource whose baseline is already Highest it does nothing; applied to a prefetch capped at Idle it also does nothing. The complete semantics, including the interaction with <link rel="preload"> and with fetch() options, are covered in the fetchpriority attribute and priority hints.


Critical Path Analysis

What Blocks Rendering and Why

The browser’s rendering pipeline requires two inputs before it can produce pixels: a complete CSSOM (all render-blocking stylesheets parsed) and a complete DOM up to the point of any synchronous script. This is why CSS <link> tags in <head> and inline script tags without async or defer stall the parser — the rendering engine needs both to avoid a flash of unstyled or broken layout.

Render-blocking resources by type:

Resource Type Default Behaviour Safe Mitigation
<link rel="stylesheet"> in <head> Blocks render until loaded + parsed Inline critical CSS; load rest async
script, no attribute Blocks parser + render Add defer or async
script type="module" Deferred by default Safe; explicit async if no ordering needed
@import inside CSS Serialises CSS fetches Replace with <link> tags
Web fonts (undeclared) Blocks text paint (FOIT) Add <link rel="preload"> + font-display: swap
<link rel="stylesheet" media="print"> Not render-blocking Use as an async-CSS pattern with onload

A systematic render-blocking resource identification workflow maps each blocking chain to its root cause before you start removing attributes blindly. The most common error is applying async to a script that another script depends on, swapping one scheduler stall for a runtime error.

A subtlety worth internalising: render-blocking and parser-blocking are not the same thing. A stylesheet is render-blocking but not parser-blocking — the parser keeps building the DOM behind it, so the preload scanner keeps discovering resources. A synchronous script is both, which is strictly worse: nothing after it is even seen until it has been fetched, compiled and executed. That asymmetry is why one 30 kB blocking script frequently costs more than three 30 kB stylesheets.

Concrete Thresholds

  • Any render-blocking chain longer than one network round-trip (> ~100 ms on an average connection) will push LCP above the 2.5 s threshold on median hardware.
  • CSS loaded via @import adds one full RTT per import level — three nested imports on a 150 ms RTT connection = 450 ms of avoidable latency.
  • Fonts without preload are discovered only after the CSSOM is built, typically 300–800 ms after navigation, causing FOIT on every first load.
  • A resource discovered by the main parser rather than the preload scanner starts, on average, one parse-and-execute cycle later — 200 ms for a small script on a mid-tier phone, far more if that script itself triggers a network fetch.
  • Above roughly 10 concurrent streams competing for one congestion window, adding priority hints stops helping and reducing request count starts helping.

The browser assigns a fetch priority tier to each resource at discovery time and promotes or demotes it as layout information accumulates. An image that enters the viewport during scroll can be promoted from Low to High mid-flight, but the initial bytes are already partially transferred under the lower priority — which is why hinting matters at declaration time, not lazily.

A Worked Example: Turning a 4.08 s LCP into 2.18 s

Consider a content page with a single hero image. In the “before” state the image is referenced by a CSS background-image rule, so the preload scanner never sees it; the URL becomes known only once main.css has been fetched, parsed and applied to the element. On a 4G profile with 150 ms RTT that discovery lands at 2.35 s, and the 240 kB image then transfers at Low priority behind a deferred application bundle.

Moving the image into an <img> element with fetchpriority="high" changes exactly one thing — when the URL becomes known — and the entire critical path collapses:

Before and after waterfall pair showing LCP dropping from 4.08 seconds to 2.18 seconds after moving the hero image out of CSS Two stacked four-row waterfalls sharing one time axis from zero to five seconds. In the before case the hero image is a CSS background, discovered at 2.35 seconds and transferred at Low priority, giving an LCP of 4.08 seconds. In the after case the same image is an img element with fetchpriority high, discovered by the preload scanner at 0.56 seconds, giving an LCP of 2.18 seconds for the same bytes. Same hero image, two declarations — 4G profile, 150 ms RTT Before — CSS background, discovered at 2.35 s LCP 4.08 s document.html 520 ms main.css 640 ms app.js (defer) 1 040 ms hero.avif 1 730 ms at Low After — img with fetchpriority=high, scanned at 0.56 s LCP 2.18 s document.html 520 ms main.css 640 ms hero.avif 1 620 ms at High app.js (defer) 1 080 ms 1.90 s faster, same bytes 0 1 s 2 s 3 s 4 s 5 s

Two details in the “after” trace are worth pointing out because they show the trade being made honestly. First, the image transfer itself only got 110 ms faster (1 730 → 1 620 ms) — the priority change bought a little bandwidth, but the overwhelming majority of the 1.90 s saving came from starting 1.79 s earlier. Second, app.js got 40 ms slower, because the hero image now outranks it. That is the scheduler working correctly: a deferred bundle that runs after DOMContentLoaded has no business competing with the LCP element.

Scripts Are the Other Half of the Critical Path

Priority tiers describe when bytes arrive. Scripts add a second dimension — when they run, and in what order relative to each other. A defer script fetched at Low still executes before DOMContentLoaded, in document order; an async script fetched at exactly the same priority executes the instant it lands, in whatever order the network happens to deliver. Two scripts with identical network profiles can therefore produce completely different page behaviour.

That interaction is subtle enough to deserve its own treatment: script loading with async, defer and execution order covers the full ordering model, including module scripts, document.currentScript pitfalls, and the specific case where two async scripts race for a shared global. If you have ever fixed a blocking script by adding async and then watched analytics events disappear intermittently, that page explains why.

Viewport-Driven Fetching Redefines “Critical”

The other major modifier on the priority queue is whether a resource is fetched at all. loading="lazy", IntersectionObserver and content-visibility all defer work based on where the viewport is, which is an enormous win for long pages and an easy way to destroy LCP if the deferred element happens to be the largest one.

Chromium’s lazy-loading threshold is not “when the image is visible” — it uses a load-in distance that varies with effective connection type, from roughly 1 250 px ahead of the viewport on slow 2G down to 1 250 px on 4G in current builds (earlier versions used up to 3 000 px on slow connections). The practical consequence is that “below the fold” in your design tool and “below the fold” in the scheduler are different lines, and only measurement tells you which side an element is on. Lazy loading and viewport-driven fetching works through the thresholds, the IntersectionObserver alternative, and the regression pattern where a lazy attribute applied site-wide silently deprioritises every hero image.


Implementation Patterns

Pattern 1 — Correct fetchpriority on the LCP Image

The LCP candidate almost always receives Medium priority by default because the browser doesn’t know it is the largest element until layout completes. Declare the hint at the <img> tag and the preload scanner picks it up immediately:

<!-- Correct: preload scanner sees fetchpriority="high" before layout -->
<img
  src="/assets/hero.webp"
  alt="Dashboard screenshot showing real-time network waterfall"
  fetchpriority="high"
  loading="eager"
  decoding="async"
  width="1200"
  height="630"
>

Do not combine fetchpriority="high" with loading="lazy" — they conflict. The lazy attribute suppresses the request until the image is near the viewport; high priority is meaningless if the request hasn’t been issued.

Pattern 2 — Cache-Control Headers That Protect the Critical Path

Cache hits bypass the network scheduler entirely. Routing as many critical-path resources as possible through the cache is the highest-leverage scheduling optimisation available. Use immutable for fingerprinted assets; use stale-while-revalidate for documents and frequently updated assets to decouple revalidation from the render path:

# Fingerprinted JS/CSS bundles — never re-request the same hash
Cache-Control: max-age=31536000, immutable

# HTML documents and API responses — serve immediately, revalidate in background
Cache-Control: max-age=0, stale-while-revalidate=86400, stale-if-error=604800

The cache interaction and stale-while-revalidate patterns determine whether background revalidation requests compete with foreground critical fetches. Without stale-while-revalidate, a document whose max-age has expired blocks its own render while it re-fetches.

Pattern 3 — PerformanceResourceTiming for Field Priority Monitoring

Synthetic tests in DevTools show scheduled priority, not the priority under real-world concurrency. The PerformanceResourceTiming API exposes nextHopProtocol and transfer sizes for every resource in the field:

// Collect resource timing from real users; report priority mismatches to analytics
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType !== 'resource') continue;

    // Flag resources that took > 500 ms and are likely on the critical path
    if (entry.duration > 500 && entry.initiatorType === 'img') {
      console.warn('Slow image resource — consider fetchpriority="high":', {
        url: entry.name,
        protocol: entry.nextHopProtocol,   // h2, h3, http/1.1
        transferSize: entry.transferSize,   // 0 = cache hit
        ttfb: entry.responseStart - entry.requestStart,
        duration: entry.duration
      });
    }
  }
});

observer.observe({ type: 'resource', buffered: true });

A transferSize of 0 means the resource was served from cache — no network request was made. Non-zero transferSize on a render-blocking resource during a repeat visit indicates a cache miss caused by a missing or too-short max-age.

Pattern 4 — Ordering Deferred Work Without Blocking

When a page has both a critical inline bootstrap and several third-party tags, the correct shape is: nothing synchronous in <head>, ordered work on defer, and genuinely independent work on async with an explicit low hint.

<head>
  <!-- Ordered, DOM-dependent, guaranteed before DOMContentLoaded -->
  <script src="/js/polyfills.js" defer></script>
  <script src="/js/app.js" defer></script>

  <!-- Independent of everything, and explicitly out of the way -->
  <script src="/js/analytics.js" async fetchpriority="low"></script>
</head>

Two defer scripts always execute in source order, so polyfills.js cannot lose the race to app.js. The async tag makes no ordering promise at all, which is correct for analytics and wrong for anything the application reads. Adding fetchpriority="low" pushes the analytics fetch below the stylesheet and the hero image in the queue rather than letting it compete with them — a one-attribute fix for a very common form of priority inversion.

Pattern 5 — Lazy Loading That Cannot Eat Your LCP

The safe rule is positional, not global: eager and hinted for anything that can be the LCP element, lazy for everything past it.

<!-- First viewport: never lazy, always hinted -->
<img src="/img/hero.avif" alt="" fetchpriority="high" width="1600" height="900">

<!-- Everything below: lazy, and explicitly demoted -->
<img src="/img/figure-3.avif" alt="" loading="lazy" fetchpriority="low"
     width="800" height="450" decoding="async">

Always keep width and height (or an aspect-ratio) on lazy images. Without intrinsic dimensions the browser reserves no space, the page reflows as each image lands, and you trade an LCP improvement for a CLS regression. If your image component applies loading="lazy" by default, add an explicit opt-out prop and audit which templates use it — a global default is how hero images end up lazy in the first place.


Diagnostics & Tooling

Chrome DevTools — Network Panel

  1. Open DevTools (F12) → Network tab.
  2. Check Disable cache and select Fast 3G throttling (simulates median mobile conditions).
  3. Reload. Look at the Priority column — if it is hidden, right-click any column header and enable it.
  4. Sort by Start Time. The first dozen requests define your critical path.
  5. Hover any timing bar to see its breakdown: Queueing (scheduler delay), Stalled (connection limit), Waiting (TTFB) (server processing), Content Download.
  6. A long Queueing time on a CSS or font resource means something else with higher priority is consuming the connection. Check what the browser loaded immediately before it.

The network waterfall anatomy and timing metrics guide explains how to read each timing phase and what server-side vs. client-side changes address each bottleneck.

When the Network Panel Is Not Enough: chrome://net-export

The Priority column shows the final priority. When a request is promoted or demoted mid-flight — an image scrolling into view, a script re-prioritised once the parser reaches it — the panel hides the history. chrome://net-export records the full netlog:

  1. Open chrome://net-export, choose Include raw bytes only if you need payloads, and start the capture.
  2. Load the page in another tab, then stop the capture.
  3. Open the resulting JSON in the netlog viewer and filter on the URL.
  4. Look for HTTP_STREAM_JOB and priority-change events. Each records the old and new tier and the timestamp.

This is the only reliable way to answer “when exactly did this become High, and what was ahead of it at that moment?” It is also the right tool for confirming whether the browser reused a connection or opened a new one, which matters for connection coalescing.

Lighthouse Audits That Surface Scheduling Problems

Audit What it catches Fix target
render-blocking-resources CSS/JS delaying first paint Move to defer/async; inline critical CSS
uses-rel-preload Missed preload opportunities for late-discovered resources Add <link rel="preload"> in <head>
prioritize-lcp-image LCP image not hinted with fetchpriority="high" Add attribute to <img> or <link rel="preload">
uses-long-cache-ttl Resources with short or no Cache-Control Set max-age for static assets
network-requests Total request count budget Merge, defer, or eliminate non-critical fetches
lcp-lazy-loaded LCP element carries loading="lazy" Remove the attribute on first-viewport imagery

Lighthouse runs against a simulated throttled connection, so its absolute numbers should be treated as a ranking signal rather than a measurement. Use it to find the problem and a real trace to confirm the fix.

WebPageTest Priority Validation

In WebPageTest, run a test with Chrome and Cable connection. Under the Request Details tab, the Priority column reflects actual Chromium scheduler assignments. Compare against what DevTools shows locally — discrepancies indicate CDN behaviour or HTTP/2 server push interference. The Waterfall view colour-codes requests by content type; look for any render-blocking resources (red/orange) loading after the 2 s mark.

The Connection View is the underused half of the same report. It shows requests grouped by the socket that carried them, which makes socket-pool exhaustion on HTTP/1.1 and failed coalescing on HTTP/2 immediately visible: six full rows with everything else waiting is a connection-limit problem, not a priority problem, and no amount of fetchpriority will fix it.


Common Failure Modes

Priority Inversion

Priority inversion occurs when a high-priority resource is blocked waiting for a lower-priority resource to finish. The most common case: a large, low-priority analytics script shares an HTTP/2 stream with a critical CSS file. The browser’s stream scheduler — implemented differently across CDNs — may give both streams equal bandwidth, slowing the CSS fetch.

Detection: In DevTools Network, sort by Priority descending. If any Highest or High resource has a Start Time later than a Low or Idle resource, inversion is occurring.

Fix: Move non-critical third-party scripts to defer or load them from a separate origin so they don’t share the HTTP/2 connection used for first-party critical assets. See fixing HTTP/2 priority inversion issues for a full remediation protocol.

Scheduler Stall Cascades

A stall cascade starts when one render-blocking resource delays the discovery of the next. If a blocking script at the top of <body> triggers a dynamic import() of a CSS file, the preload scanner never saw the CSS — it enters the queue 500–1000 ms late, after the script has executed. The cascade: script blocks parser → parser cannot reach <link> → CSS discovered late → render delayed a second time.

Fix: Declare all critical resources statically in <head> so the preload scanner can find them. Use <link rel="preload"> for anything the scanner might miss (e.g., CSS loaded through JavaScript, fonts referenced in dynamically injected stylesheets).

Preload Bandwidth Waste

Preloading too aggressively wastes bandwidth and can delay the actual critical path. A <link rel="preload"> for every font variant, every hero image breakpoint, and every prefetched navigation target floods the Highest/High priority queue with resources that are not all immediately needed.

Heuristic: Preload only what the browser needs within the first rendered viewport. For LCP images, preload the exact src or the srcset candidate for the most common viewport width. For fonts, preload the weight/style combination used in above-the-fold text only.

The three failure shapes above look different in a trace, and telling them apart is most of the diagnosis:

Three annotated panels showing priority inversion, stall cascade and preload waste as distinct scheduler failures Three side-by-side annotated panels. The first shows a High-priority stylesheet stalled for 420 ms while a Low-priority 180 kB analytics script consumes the same HTTP/2 connection. The second shows a blocking script triggering a dynamic import that reveals a stylesheet 540 ms late. The third shows a preload for a 2x image that the srcset never selects, costing 240 kB of duplicate download. Each panel ends with the corrective action. Three ways the scheduler goes wrong Priority inversion app.css · High stalled 420 ms analytics.js · Low · 180 kB Both streams get an equal share, so 14 kB of CSS lands after 180 kB of script. Fix: defer the script, or serve it from an origin that does not share the first-party HTTP/2 connection. Stall cascade blocking <script> in body dynamic import of theme.css CSS discovered 540 ms late The scanner never saw the URL, so layout blocks a second time. Fix: declare the stylesheet statically in the document head. Preload waste preload hero@2x.avif 240 kB srcset picks hero@1x.avif 240 kB downloaded twice The preloaded URL never matches the candidate layout selects, so both copies cross the network. Fix: mirror imagesrcset and imagesizes on the link tag.

fetchpriority on Preload Mismatches

A <link rel="preload" as="image" fetchpriority="high"> that does not match the src used in the <img> tag — because of a srcset mismatch or incorrect imagesrcset on the <link> — causes the browser to fetch the resource twice: once at high priority (preload) and once at normal priority when the image renders. The preloaded fetch is wasted.

Detection: In DevTools, filter by the image filename. Two requests for the same resource = mismatch. Set initiatorType to link for the preload and img for the duplicate.

The Lazy-Loaded LCP Element

A component library that sets loading="lazy" on every image is the most common regression in this whole area, and it is invisible in a design review. The browser suppresses the hero image request until layout places the element, then issues it at Low. LCP typically degrades by 800–1 500 ms, and Lighthouse flags it as lcp-lazy-loaded.

Detection: In the Performance panel, find the LCP marker, then look at the initiating request’s start time. If it starts after the first layout rather than in the first 200 ms, the element was lazy. Fixing lazy-loaded LCP image regressions covers the audit and the component-level fix.

async Execution Races

Two async scripts that both write to a shared global will execute in network-completion order, which varies with cache state, connection quality and CDN edge. The bug reproduces on a cold cache and disappears on a warm one, which is why it is so often filed as “flaky”. The fix is not a retry loop: it is defer for anything with an ordering requirement, or an explicit promise the dependent script awaits. Fixing async script race conditions works through both approaches.

Third-Party Scripts Monopolising the Pool

A tag manager that injects a dozen vendor scripts turns one request into thirteen, all at whatever priority the injected tags declare — frequently the browser default rather than Low. On HTTP/1.1 they occupy sockets; on HTTP/2 they occupy the congestion window. Because they are injected at runtime, none of them appear in the preload scanner’s view, so they arrive as a burst exactly when the page is trying to paint. Third-party resource impact mapping covers attribution and containment.


FAQ

Does fetchpriority change the order requests are sent, or only their bandwidth share?

Both, but through different mechanisms. On HTTP/1.1 the priority determines the order in which queued requests claim one of the six sockets, so it changes dispatch order outright. On HTTP/2 and HTTP/3 nearly every request is dispatched immediately as a new stream, and priority becomes a bandwidth-allocation signal the server is asked to honour. A high-priority stream on a congested HTTP/2 connection still starts at the same moment as a low-priority one; it simply receives more of the available bytes per round trip.

Why does DevTools show a priority different from the one I requested?

The Priority column shows the priority Chromium computed after applying its own heuristics, not the value you wrote. fetchpriority shifts the computed tier by one step in most cases; it does not override the resource-type baseline. A prefetched document capped at Idle stays low no matter what the attribute says, and an image marked loading="lazy" is not scheduled at all until it approaches the viewport.

How late can the preload scanner discover a resource before it hurts LCP?

Treat one round trip as the budget. On a 150 ms RTT connection, a resource discovered after the CSSOM is built typically starts 300–800 ms behind one declared statically in <head>. If that resource is the LCP candidate, the whole of that delay lands on the LCP timestamp, because the image cannot finish before it starts.

Does an image with loading="lazy" ever get a high priority?

Not while it is lazy. Chromium suppresses the request entirely until the element is within the load-in distance threshold, and when the request is finally issued it enters at Low. Combining loading="lazy" with fetchpriority="high" is contradictory: the hint applies to a request the browser has deliberately not made yet.

Do async and defer scripts get the same network priority?

No. Chromium places async scripts at Low because their execution time is unconstrained, while defer scripts discovered in <head> are fetched at Low but are guaranteed to execute in document order before DOMContentLoaded. A blocking script in <head> is fetched at Highest. The practical consequence is that async is the wrong attribute for anything the page needs early.

Can a CDN override the priorities my page asks for?

Yes. HTTP/2 priority signals are advisory, and several edge platforms historically ignored the client priority tree in favour of round-robin or first-come scheduling. HTTP/3 replaces the tree with the Priority header and the equivalent PRIORITY_UPDATE frame, which are simpler to honour, but support still varies by edge. Always validate the delivered order against a real edge, not just a local server.

Why did adding a preload make my page slower?

A preload enters the queue at High and competes with the resources that genuinely block the first paint. Preloading six font files or every responsive image candidate pushes the stylesheet and the LCP image down the queue on a bandwidth-limited connection. Preload only the resources the first viewport needs, and check the console for the preloaded-but-not-used warning after every change.

Is request count still relevant on HTTP/2 and HTTP/3?

It matters less for connection setup and more for scheduler contention. Multiplexing removes the six-socket ceiling, but a hundred concurrent streams still split one congestion window. A page with 30 requests in the first viewport and one with 130 reach the same total bytes, yet the second delivers the critical bytes far later because the scheduler is dividing bandwidth across a much wider fan-out.