Lazy Loading, Preloading & Fetch Priorities

Unscheduled media requests are the most reliable predictor of poor Largest Contentful Paint scores. When a browser parses your HTML and queues every image and video at equal priority — or defers them naively — the critical rendering path stalls on off-screen assets while above-the-fold images sit waiting behind low-value prefetches. Getting this right means understanding three interlocking mechanisms: deferral (lazy loading), acceleration (preload/prefetch), and arbitration (fetchpriority). Together they reduce initial-payload size by 40–70% on media-heavy pages, cut LCP by 0.5–1.5 seconds on real mobile connections, and let your CDN cache-hit ratio reflect actual user navigation patterns rather than speculative browser behaviour.

What This Section Covers

This reference covers four topic areas that map directly to browser resource-scheduling decisions:

Native Lazy Loading for Images and Iframes covers the loading="lazy" attribute — how the browser calculates the lazy-load distance threshold (1,250 px on fast connections, 2,500 px on slow), the implicit rootMargin equivalent, and the accessibility contracts loading="lazy" does not relax. The sub-page How to Implement Lazy Loading for WebM Backgrounds goes further into CSS background-video patterns where the loading attribute does not apply.

Advanced IntersectionObserver Patterns for Media covers the JavaScript API that native lazy loading is built on — rootMargin tuning for scroll velocity compensation, threshold arrays for progressive decode, and integration with the Network Information API to downgrade quality on 2G/3G connections.

Preload vs Prefetch for Video and Image Assets breaks down the <link rel="preload"> / <link rel="prefetch"> distinction, including the imagesrcset + imagesizes attributes on preload links that make responsive image preloading possible. The sub-page When to Use rel=preconnect for CDN Media Origins explains how to warm the TLS handshake to your image CDN before the browser discovers the first <img> tag.

Using fetchpriority to Optimize Critical Media covers the Priority Hints API (fetchpriority="high|low|auto") — how Chromium translates the hint into an internal net::RequestPriority, the starvation risk when you annotate multiple elements high, and debugging via the Priority column in Chrome DevTools Network panel. The sub-page Debugging fetchpriority Conflicts in Chrome DevTools walks through reading the waterfall to identify priority inversions.


Resource-Scheduling Architecture

Browser Resource Scheduling Pipeline Flow diagram showing how the browser moves from HTML parsing through the preload scanner, network queue, and three scheduling mechanisms (lazy loading, preload/prefetch, fetchpriority) to the render tree and LCP event. HTML Parse + preload scan Network Queue priority arbitration Lazy Loading loading="lazy" IntersectionObserver deferred until near-viewport Preload / Prefetch rel=preload (current page) rel=prefetch (next nav) rel=preconnect (TLS) fetchpriority high → HIGHEST net priority low → IDLE / LOW auto → browser default Render Tree decode → composite → LCP CDN Edge Cache-Control + Vary format negotiation Service Worker CacheFirst / SWR Workbox routing Build Pipeline Sharp / FFmpeg format matrix generation

The browser’s preload scanner discovers <link rel="preload"> tags before the main parser has finished the <head>, giving preloaded assets a head start of 50–200 ms depending on HTML size. Native loading="lazy" operates post-parse — the browser suppresses the fetch until the element is within the platform-defined distance threshold from the viewport. The fetchpriority attribute modifies the queue position of any already-discovered resource; it does not change when the browser discovers the resource.

Core Theory: How Browser Resource Scheduling Works

Fetch Priority Tiers

Chromium’s network stack maps resources to five internal priority levels: HIGHEST, MEDIUM, LOW, LOWEST, and IDLE. The mapping from resource type to default priority is:

  • Parser-blocking scripts: HIGHEST
  • <link rel="stylesheet">: HIGHEST
  • LCP candidate images (heuristically detected): HIGHHIGHEST (Chrome 102+)
  • Normal images: LOW or MEDIUM depending on viewport position
  • Fonts: HIGH
  • XHR/fetch API: HIGH
  • Preloaded resources inherit the priority of their as type
  • fetchpriority="high" bumps a resource one tier up; fetchpriority="low" drops it one tier

The browser can only maintain 6 simultaneous HTTP/1.1 connections per origin. On HTTP/2 (the CDN standard), connection multiplexing removes this constraint but the internal priority queue still controls byte allocation. Warning: marking more than two images fetchpriority="high" on a single page causes them to compete at the same queue position, potentially starving CSS or script responses and increasing Time to Interactive.

Two Chromium behaviours make the picture more subtle than a static table suggests. The first is tight mode: until the document has finished loading its render-blocking resources, Chromium holds back everything below MEDIUM and permits at most two in-flight MEDIUM requests. An image that would eventually be fetched at MEDIUM therefore does not simply queue behind CSS — it is not dispatched at all until the body starts parsing. fetchpriority="high" is the only element-level way to exempt an image from tight mode, which is why the attribute frequently produces a larger LCP improvement than its one-tier description implies.

The second is image reprioritization. Chromium initially assigns every <img> a low priority because it cannot know layout position at parse time. Once the first layout pass runs, the renderer walks the images it has queued and boosts any that intersect the initial viewport to MEDIUM, and boosts the heuristically-detected LCP candidate to HIGH. This happens after layout, which is after CSS has been fetched and parsed — usually 300–800 ms into the load on a mobile connection. A preload link short-circuits that entire delay, which is why the preload plus fetchpriority pairing is worth more than either mechanism alone.

Discovery Order: The Preload Scanner in Detail

Priority determines the order of requests the browser already knows about. Discovery determines when it learns about them at all, and discovery is the larger of the two effects on most real pages.

The preload scanner is a second, lightweight HTML tokenizer that runs ahead of the main parser. When the main parser blocks — on a synchronous <script>, on a document.write, on a stylesheet that must be applied before the next element can be constructed — the preload scanner keeps tokenizing the raw byte stream, extracting URLs from src, srcset, href, poster, and imagesrcset attributes, and issuing speculative fetches for them. It does not build DOM nodes and it does not run script.

This has three consequences that dictate how you author media markup:

  • Anything the scanner cannot see, it cannot fetch early. A hero image injected by JavaScript, set as a CSS background-image, or hidden behind a client-side framework’s hydration pass is invisible to the scanner. It is discovered only after script execution, which on a mid-tier phone can be 1.5–3 s into the load. This is the single most common cause of a slow LCP on otherwise well-optimised sites, and it is why the <picture> element with real srcset markup outperforms an equivalent JavaScript image loader even when the loader is smaller.
  • The scanner evaluates sizes against a provisional viewport. It does not have layout, so it resolves sizes using the viewport width and the default font size only. A sizes value expressed in container-relative units, or one that depends on a CSS variable, resolves differently in the scanner than in the final layout — producing a speculative fetch of the wrong candidate followed by a second fetch of the right one. Calculate concrete sizes values that hold at scanner time.
  • <link rel="preload"> is the escape hatch. A preload in the <head> is the earliest possible discovery point for any resource, including ones the scanner could never find. It is the correct fix for a CSS background hero, a font-driven icon, or a video poster referenced from a stylesheet.

The waterfall below contrasts the same page loaded two ways — identical bytes, identical CDN, identical network — differing only in when the hero image is discovered and at what priority it is dispatched:

Waterfall before and after preload plus fetchpriority Two stacked request waterfalls on a shared three-second timeline. In the first, the hero image is discovered by the main parser after an analytics script and finishes at 2.4 seconds, when LCP fires. In the second, a preload link and fetchpriority equal high move the hero ahead of analytics so it finishes at 1.3 seconds and LCP fires at 1.35 seconds. Before — hero found by the parser, dispatched LOW LCP 2.40 s index.html styles.css analytics.js hero.avif 0 1.0 s 2.0 s 3.0 s After — preload in head, fetchpriority="high" LCP 1.35 s index.html styles.css hero.avif analytics.js 0 1.0 s 2.0 s 3.0 s Same bytes, same CDN: 1.05 s of LCP recovered from ordering alone. Bars are network time; the dashed marker is when the LCP paint is recorded.

Note that analytics.js in the second waterfall finishes later than it did in the first. That is the trade being made: bandwidth is finite, and moving the hero forward necessarily moves something else back. The mechanism is only a win when the thing you demote genuinely does not affect the user’s first impression — which is why fetchpriority="low" on third-party scripts and below-fold media is as important as high on the hero.

The Lazy-Load Distance Threshold

The loading="lazy" specification delegates threshold selection to the browser. In Chrome, the threshold is controlled by a Finch experiment flag (LazyImageLoadingDistanceThreshold) with platform-specific defaults:

Connection type Distance threshold (approx.)
4g / WiFi 1,250 px from viewport edge
3g 2,500 px
2g / slow-2g 2,500 px

This means on slow connections the browser prefetches images 2,500 px away — counterintuitive for a “lazy” mechanism, but intentional: the browser compensates for higher latency by fetching earlier. When you implement IntersectionObserver manually, you control rootMargin precisely and can match it to your content’s scroll velocity.

The threshold is measured from the scrolling viewport edge, and it is evaluated in CSS pixels, not device pixels — so a 3× DPR phone uses the same 1,250 px budget as a 1× laptop even though it represents a third as much physical screen. Three further behaviours of the native implementation are worth knowing before you decide whether it is sufficient:

  • The deferral is not free. Chromium still constructs the LayoutImage box and reserves space for it, which is why an image with loading="lazy" and no width/height attributes still causes layout shift when it arrives. Intrinsic dimensions must be declared, or an aspect-ratio supplied in CSS, for the deferral to be shift-neutral.
  • Print and find-in-page force a synchronous load. Invoking the print dialog, or a text search that scrolls a lazy image into view, triggers an immediate fetch of every deferred image in the document. On a page with 300 lazy thumbnails this is a several-megabyte burst. If that matters, gate the images behind an observer you control rather than the native attribute.
  • loading="lazy" is ignored on elements whose fetch has already started. Setting the attribute from JavaScript after the parser has created the element has no effect — the request is already queued. Server-render the attribute or set it before the src.

Safari’s implementation is a separate codebase with a smaller effective threshold and no connection-type adaptation, and Firefox historically applied the attribute to <img> well before <iframe>. Where cross-browser behaviour must be identical — a metered-data mode, an editor preview, an infinite feed with a strict request budget — replace the attribute with an observer and accept the extra code. The loading attribute is a good default, not a contract.

preload is a mandatory directive: the browser must fetch the resource for the current page, at the priority determined by the as attribute. Omitting as causes the browser to treat the resource as an XHR, fetching it at HIGH priority but not matching it to the subsequent <img> element’s cache key — resulting in a double fetch.

prefetch is a hint: the browser may fetch the resource for likely future navigations, at IDLE priority, during network idle time. Prefetched resources are stored in the HTTP cache with the standard Cache-Control max-age — configure Cache-Control headers for image and video assets correctly or the prefetch will expire before the user navigates.

preconnect does not fetch any resource — it pre-establishes the DNS lookup, TCP connection, and TLS handshake to a third-party origin. On a cold connection to a CDN origin, this saves 150–400 ms of connection overhead before the first byte of an image arrives.

IntersectionObserver API Internals

IntersectionObserver calculates intersection on the compositor thread, avoiding main-thread blocking during scroll. The entry object exposes intersectionRatio (fraction of the target visible) and isIntersecting (boolean threshold cross). Key configuration options:

  • root: the scrolling ancestor, or null for viewport
  • rootMargin: CSS-style expansion of the root boundary — use positive values (e.g. "200px 0px") to load assets before they enter the viewport
  • threshold: one value or array of ratios at which callbacks fire — [0, 0.25, 0.5, 0.75, 1] enables progressive quality loading

The callback fires asynchronously after layout, paint, and compositing — it is not synchronous with scroll events, so there is an inherent 1–2 frame delay before a fetch starts. Compensate with a rootMargin of 200–400px depending on expected scroll speed.

Two constraints on rootMargin catch people out. First, when root is null (the implicit viewport), percentage margins resolve against the viewport box, but pixel margins are the only form that behaves identically across the document and inside a scroller — prefer pixels. Second, rootMargin is silently ignored when the observing document is cross-origin to the root: an observer created inside an iframe cannot expand its margin past the iframe’s own bounds. For third-party embeds the only reliable deferral is on the containing element.

Priority Over the Wire: HTTP/2 Trees and HTTP/3 Extensible Priorities

Everything above happens inside the browser. The moment a request leaves the socket, a second, entirely separate priority system takes over — and the two do not always agree.

HTTP/2 defined priority as a dependency tree: each stream declared a parent stream and a weight between 1 and 256, and the server was expected to allocate bandwidth by walking that tree. In practice almost no server implemented it faithfully, several CDNs discarded the PRIORITY frames entirely, and reprioritisation mid-response was unreliable. RFC 9218 replaced the whole design with Extensible Priorities, a far simpler scheme that applies to both HTTP/2 and HTTP/3: a Priority request header (or a PRIORITY_UPDATE frame) carrying two structured-field parameters.

Parameter Range Default Meaning
u (urgency) 0–7, lower is more urgent 3 The band the response competes in; band 0 is served before band 1, and so on
i (incremental) boolean ?0 Whether a partial response is useful — progressive images and video segments set ?1

Chromium maps its internal tiers onto these directly: a fetchpriority="high" image is sent as u=1, i=?1, a normal in-viewport image as u=3, i=?1, and a fetchpriority="low" or lazy image as u=5 or u=6. The i flag matters more than it looks — with i=?0 a server that receives ten equal-urgency requests should serve them sequentially, so each completes as early as possible; with i=?1 it should interleave them, so all ten progress together. Interleaving is right for progressive JPEG and for video segments a player can start decoding; sequential is right for a set of AVIF stills, none of which is useful until complete.

Warning: origins and CDNs can override the client’s urgency. Cloudflare, Fastly and CloudFront each expose their own response-side prioritisation, and a Priority response header from the origin takes precedence over the client’s request header. If a fetchpriority="high" image is still arriving late in the waterfall despite a correct DevTools priority, inspect the response headers for a server-set priority before assuming the browser is at fault — the same class of misconfiguration covered under CloudFront cache behaviors for media.

One further asymmetry: HTTP/3 runs over QUIC, where each stream is independently flow-controlled and a lost packet blocks only its own stream. Under packet loss, an HTTP/2 connection head-of-line blocks every multiplexed response — including the high-priority hero image — behind whichever stream lost a packet. On a lossy mobile network the practical benefit of moving from HTTP/2 to HTTP/3 for media delivery often exceeds any priority tuning you can do in markup.

Early Hints and Server-Driven Preload

103 Early Hints is an informational HTTP response the server can emit before the final 200, carrying Link headers the browser may act on immediately. It targets the dead time between request and first byte — server think time, database queries, template rendering — which on a dynamic page is frequently 200–600 ms of a completely idle network.

HTTP/2 103 Early Hints
Link: <https://cdn.example.com>; rel=preconnect; crossorigin
Link: </hero-1200.avif>; rel=preload; as=image; fetchpriority=high
Link: </critical.css>; rel=preload; as=style

HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Link: </hero-1200.avif>; rel=preload; as=image; fetchpriority=high

The Link headers repeat on the final response deliberately: a client or intermediary that ignored the 103 must still receive the hint. Three rules govern what belongs in a 103:

  1. Only resources needed on every variant of the response. A 103 is emitted before the server has decided what to render, so a hint for a resource that turns out to be unused is a wasted request — and on a cellular connection a wasted 200 KB image is worse than the delay you saved.
  2. Preconnect first, preload second. Connection setup is the larger and more universally applicable win, and it costs a few hundred bytes rather than a full asset.
  3. Never emit a 103 on a response that may become a redirect. The client will have preloaded assets for a page it is about to leave.

Implementation is CDN-specific: Cloudflare generates Early Hints automatically from Link headers on cached responses, Vercel emits them for Next.js routes, and Fastly requires explicit VCL. CloudFront does not support 103 at all, so on that stack the equivalent play is a Link header injected by Lambda@Edge on the 200, which still beats parser discovery but not server think time.

# nginx 1.25+ — emit a 103 before proxying to a slow application origin.
# early_hints must be enabled per-location; the hints go out immediately,
# while proxy_pass is still waiting on the upstream.
location / {
    early_hints on;
    add_header Link "</hero-1200.avif>; rel=preload; as=image; fetchpriority=high" always;
    add_header Link "<https://cdn.example.com>; rel=preconnect; crossorigin" always;
    proxy_pass http://app_upstream;   # 300-500 ms of think time the hints now overlap
}

Tradeoff: Early Hints only helps when server think time is meaningful. On a statically-generated page served from a CDN edge cache, time-to-first-byte is already 20–40 ms and there is nothing to overlap — the 103 adds a round trip’s worth of complexity for no measurable gain. Measure TTFB first; adopt Early Hints only where it exceeds roughly 150 ms.

Reference Data Table

Browser Support by Feature

Feature Chrome 85+ Firefox 93+ Safari 14 Safari 16 Edge 18+
loading="lazy" on <img> Yes Yes Yes Yes Yes (79+)
loading="lazy" on <iframe> Yes Yes No No Yes
fetchpriority attribute Yes (101+) No (103 partial) No No (17.2+) Yes (101+)
<link rel="preload" as="image"> Yes Yes (85+) Yes (15.4+) Yes Yes
imagesrcset on preload link Yes (73+) Yes (78+) Yes (15.4+) Yes Yes
IntersectionObserver v1 Yes Yes Yes (12.1+) Yes Yes
IntersectionObserver v2 (isVisible) Yes (74+) No No No Yes
rel="prefetch" Yes Yes Yes (13.1+) Yes Yes
rel="preconnect" Yes Yes Yes Yes Yes
fetchpriority on fetch() init Yes (101+) No No Partial (17.2+) Yes (101+)
decoding="async" on <img> Yes Yes (92+) Yes (14+) Yes Yes
Priority request header (RFC 9218) Yes (110+) No No No Yes (110+)
103 Early Hints client handling Yes (103+) No No No Yes (103+)
content-visibility: auto Yes (85+) Yes (125+) No Yes (18+) Yes

CDN Priority-Hint Pass-Through

CDN Forwards fetchpriority to origin? Early-Hints (103) support Notes
Cloudflare No — client-only hint Yes (2023+) Use Rules to inject <link> preload headers
AWS CloudFront No No Must use Lambda@Edge for Link header injection
Fastly No Partial (VCL required) Use beresp.http.Link in vcl_fetch
Vercel Edge No Yes (automatic for Next.js) Configured via next.config.js headers
Akamai No Yes (Adaptive Acceleration) Generates hints from RUM data automatically
nginx (self-hosted) N/A Yes (1.25.1+, early_hints on) Per-location directive; must sit before proxy_pass
Netlify Edge No No Emit Link headers from _headers on the 200 instead

Choosing the Mechanism per Asset

The three mechanisms are not alternatives to weigh against each other — each one owns a distinct region of the page, and almost every asset has exactly one correct answer. Working through two questions per asset resolves it: is it in the initial viewport, and is it the element the user will perceive as “the page has loaded”?

Which scheduling mechanism each media asset needs A two-level decision tree. Assets outside the initial viewport split into rel=prefetch when they are needed for the next navigation, and loading=lazy otherwise. Assets inside the initial viewport split into preload with fetchpriority high when they are the LCP candidate, and plain eager loading with automatic priority otherwise. A footnote adds that any asset on a third-party origin also warrants rel=preconnect. a media asset on the page in the initial viewport? no yes needed on the next navigation? is it the LCP candidate? yes no yes no rel="prefetch" IDLE priority, idle time only needs a long max-age loading="lazy" always with width + height leave fetchpriority at auto preload as="image" + fetchpriority="high" + loading="eager" exactly one per page loading="eager" fetchpriority="auto" let reprioritisation work Orthogonal to every branch: if the asset lives on a third-party CDN origin, add rel="preconnect" so DNS, TCP and TLS are already warm when it is requested.

The rightmost leaf is the one teams get wrong most often. An in-viewport image that is not the LCP element does not need any annotation at all — Chromium’s post-layout reprioritisation will find it and promote it to MEDIUM, and adding fetchpriority="high" there only dilutes the signal on the image that actually matters. Explicit hints are a scarce resource; spend them on the single element the metric is measuring.

Canonical Production Pattern

The following pattern covers the three most common above-the-fold media scenarios: an LCP hero image, a below-fold gallery, and a background video. Every attribute is annotated.

<!-- 1. LCP hero image: preloaded + fetchpriority=high -->
<!-- Preload with imagesrcset so the preload scanner matches the correct candidate -->
<link rel="preload" as="image"
      href="/hero-1200.avif"
      imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2000.avif 2000w"
      imagesizes="(max-width: 768px) 100vw, 1200px"
      fetchpriority="high" />
<!-- preconnect to the image CDN origin — runs before HTML body is parsed -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />

<!-- In <body>: the actual <img> must match the preloaded URL exactly
     (same href as the 1200w candidate to hit the preload cache) -->
<picture>
  <source type="image/avif"
          srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2000.avif 2000w"
          sizes="(max-width: 768px) 100vw, 1200px" />
  <!-- AVIF fallback to WebP for browsers without AVIF — see avif-vs-webp benchmarks -->
  <source type="image/webp"
          srcset="/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-2000.webp 2000w"
          sizes="(max-width: 768px) 100vw, 1200px" />
  <img src="/hero-1200.jpg"
       alt="Aerial view of the shipping port at sunrise"
       width="1200" height="630"
       loading="eager"          <!-- never lazy on LCP candidate -->
       fetchpriority="high"     <!-- reinforces preload priority hint -->
       decoding="async" />      <!-- decode off-main-thread; safe for eager loads -->
</picture>

<!-- 2. Below-fold gallery: lazy loading with explicit dimensions -->
<!-- Dimensions prevent cumulative layout shift (CLS) when the image loads -->
<img src="/gallery-1.avif"
     alt="Interior detail — polished concrete floor"
     width="600" height="400"
     loading="lazy"             <!-- browser defers fetch until within threshold -->
     fetchpriority="auto"       <!-- default; do not set low on gallery items or
                                     they may load even later than lazy threshold -->
     decoding="async" />

<!-- 3. Background video: preload=none below fold, metadata above fold -->
<video width="1280" height="720"
       autoplay muted loop playsinline
       preload="none"           <!-- suppress initial network request entirely -->
       poster="/video-poster.webp">  <!-- displayed until JS triggers load -->
  <!-- Offer WebM (VP9/AV1) first — smaller than H.264 MP4 at equal quality -->
  <source src="/background.webm" type="video/webm" />
  <source src="/background.mp4"  type="video/mp4" />  <!-- H.264 Safari fallback -->
</video>

Tradeoff: The imagesrcset attribute on <link rel="preload"> was added in Chrome 73 and Firefox 78. Safari 14 supports <link rel="preload" as="image"> but ignores imagesrcset — it always preloads the href candidate. On Safari 14 the preloaded href must therefore match the image the browser will select at the default (mobile) viewport width.

Pipeline Integration

Automated image generation at build time ensures the srcset candidates referenced in preload links actually exist. The Sharp library (Node.js) processes originals in parallel across worker threads.

// sharp-pipeline.mjs — generate responsive AVIF + WebP + JPEG for each source
import sharp from 'sharp';           // Sharp 0.33+ uses libvips 8.15
import { readdir } from 'fs/promises';
import path from 'path';

const BREAKPOINTS = [400, 800, 1200, 2000];  // px widths — match srcset candidates
const FORMATS = [
  { ext: 'avif', options: { quality: 60, effort: 4 } },
  // effort: 4 balances encode speed vs size; 6–9 is diminishing returns
  { ext: 'webp', options: { quality: 80, effort: 4 } },
  { ext: 'jpeg', options: { quality: 85, progressive: true } }
];

async function processImage(inputPath, outputDir) {
  const name = path.basename(inputPath, path.extname(inputPath));
  const img = sharp(inputPath);

  for (const width of BREAKPOINTS) {
    const resized = img.clone().resize(width, null, {
      withoutEnlargement: true,   // never upscale source — avoids artefacts
      fit: 'inside'
    });

    for (const { ext, options } of FORMATS) {
      const outPath = path.join(outputDir, `${name}-${width}.${ext}`);
      await resized[ext](options).toFile(outPath);
    }
  }
}

// Run during CI — keep encode time under 15% of total build duration
const sources = await readdir('./src/images');
await Promise.all(sources.map(f =>
  processImage(`./src/images/${f}`, './dist/images')
));

Tradeoff: Sharp’s AVIF encoder (libvipslibaom) is significantly slower than its WebP encoder — expect 3–8× longer encode times per image at equivalent quality. For CI pipelines with >200 source images, run AVIF encodes in a dedicated worker pool or use a content CDN’s on-the-fly transformation (Cloudflare Image Resizing, Imgix) to shift encoding cost to request time with CDN caching.

For video, pair loading="lazy" polyfill patterns with an IntersectionObserver that calls videoElement.load() on entry. Do not use preload="auto" on below-fold video — it triggers a full download of the first 2–3 MB of video before the user scrolls to it.

Media Element Scheduling: Video, Range Requests and Poster Frames

<video> does not participate in the scheduling model described above. There is no loading attribute, fetchpriority on the element is ignored by every shipping browser, and the only lever the platform gives you is the preload attribute with three values whose semantics are advisory rather than binding.

preload value Bytes fetched before play Correct use
none Zero — no request is issued at all Any below-fold video; any decorative loop
metadata Container header only: moov atom, duration, dimensions, track list — typically 30–300 KB A single above-fold player where duration must render in the UI before play
auto Browser’s discretion, in practice the first several seconds — 1–4 MB Almost never; only a hero video that autoplays and is the LCP element

The metadata figure is a range, and where it lands is a property of how the file was muxed rather than of the browser. In an MP4 the moov atom carries the sample tables for every track, and its size grows with duration and keyframe density; muxed at the end of the file (the default for most encoders) it forces the browser to issue a second range request for the tail of the file before it can read anything at all. Running -movflags +faststart moves it to the front and collapses two round trips into one — the same flag applied when transcoding AV1 and VP9 renditions.

Playback itself proceeds through HTTP range requests, which interacts with your edge configuration in two ways worth checking. First, the origin must return Accept-Ranges: bytes and honour 206 Partial Content; an origin that ignores Range forces the browser to download the entire file before the first frame paints. Second, range requests must be cacheable at the edge independently — a CDN that caches only complete objects will miss on every seek. Both are covered by a correct Cache-Control configuration for media assets.

The poster frame deserves its own treatment. poster is preload-scanner-discoverable and is a valid LCP candidate, so on a hero video the poster — not the video — is what the metric measures. That makes the optimal hero-video pattern counterintuitive: preload the poster at high priority, set preload="none" on the video itself, and start the video load from a canplay-driven swap or an observer. LCP is decided by a 40 KB AVIF still rather than by a multi-megabyte stream, and the video arrives when it arrives. The WebM background-loop pattern applies the same idea to decorative loops.

Warning: autoplay overrides preload="none". A muted autoplaying video begins buffering as soon as it is inserted into the document regardless of the attribute, because the browser must fetch data to satisfy the autoplay it was told to perform. If a background loop must not compete with the hero image, keep it out of the DOM (or keep its src unset) until after the load event.

Measuring and Validating Scheduling Changes

Every change described on this page is invisible in a synthetic score and obvious in a waterfall. Validate in this order.

1. Confirm the browser agrees about priority. Open the Network panel, right-click the column headers, enable Priority, and reload. The column shows the initial priority and, after a hyphen, the final priority if reprioritisation occurred — Low - High on your hero image is the signal that the post-layout boost fired but arrived late, which is exactly the case a preload fixes. Reading priority conflicts in the DevTools waterfall covers the pathological cases.

2. Confirm the preload actually matched. A preload that does not match its consuming element produces a console warning — “The resource … was preloaded using link preload but not used within a few seconds” — and a duplicate request. The match is on the full request, not just the URL: crossorigin, referrerpolicy, and the as type must all agree between the <link> and the element.

# Count requests per URL from a HAR export to catch double fetches.
# A preloaded asset appearing twice means the preload did not match the element.
jq -r '.log.entries[].request.url' capture.har \
  | sed 's/?.*//' \
  | sort | uniq -c | sort -rn | awk '$1 > 1'

3. Attribute LCP to a specific element and phase. The largest-contentful-paint entry names the element; pairing it with the asset’s Resource Timing entry splits the total into the four phases that can each be optimised differently — time to first byte, resource load delay (discovery), resource load duration (transfer), and element render delay (decode plus paint).

// Break LCP into its four sub-parts so you know which mechanism to reach for.
new PerformanceObserver((list) => {
  const lcp = list.getEntries().at(-1);      // last entry wins; LCP can be revised
  const nav = performance.getEntriesByType('navigation')[0];
  // The resource timing entry for the LCP image, matched by URL:
  const res = performance.getEntriesByName(lcp.url)[0];

  console.table({
    ttfb:          Math.round(nav.responseStart),
    // Discovery gap — shrink this with preload / Early Hints:
    loadDelay:     Math.round((res?.requestStart ?? lcp.startTime) - nav.responseStart),
    // Transfer time — shrink this with a smaller format or a closer edge:
    loadDuration:  Math.round((res?.responseEnd ?? 0) - (res?.requestStart ?? 0)),
    // Decode + paint — shrink this with decoding=async and smaller dimensions:
    renderDelay:   Math.round(lcp.startTime - (res?.responseEnd ?? lcp.startTime)),
    element:       lcp.element?.tagName,
  });
}).observe({ type: 'largest-contentful-paint', buffered: true });

A large loadDelay means a discovery problem and is fixed by preload or Early Hints. A large loadDuration is a payload or edge problem and is fixed by format selection and CDN placement. A large renderDelay with a fast download usually means the image is being decoded on the main thread behind long tasks — decoding="async" and smaller intrinsic dimensions help, priority hints do not.

4. Watch it in the field, not just on your laptop. Lab measurement systematically understates the effect of these changes, because the connections that benefit most are the slow ones you are not testing on. Track the p75 distribution with CrUX field data, and guard against regressions in CI with an image-weight budget so a future contributor cannot quietly reintroduce the 2 MB hero.

Tradeoff: Throttling profiles in DevTools model bandwidth and latency but not packet loss, and packet loss is what determines whether HTTP/2 head-of-line blocking eats your priority ordering. A change that measures as a clear win on “Slow 4G” can be neutral on a real congested cell. Confirm anything load-bearing against field data before declaring victory.

Tradeoffs & Failure Modes

Issue Cause Fix
fetchpriority="high" on 3+ images Priority queue saturation — all compete at HIGHEST, starving CSS and scripts Mark only one (the LCP element); use auto on all others
Missing as on <link rel="preload"> Browser fetches at HIGH as XHR, then fetches again when parser reaches <img> Always include as="image", as="video", or as="font"
loading="lazy" on LCP candidate Browser defers the most important image until near-viewport scroll — guaranteed LCP regression Use loading="eager" + fetchpriority="high" on any above-fold image
imagesrcset mismatch between preload and <img> Preloaded URL is a different candidate than the one the browser selects — double fetch Ensure imagesizes is identical in both places; test at the exact viewport widths you serve
rel="prefetch" with short max-age Prefetched resource expires before user navigates to the next page Set Cache-Control: public, max-age=31536000, immutable on versioned media assets
IntersectionObserver root margin too small Images appear blank for 100–300 ms after entering viewport on fast scroll Set rootMargin: "400px 0px" for full-page scroll; reduce to 200px for carousels
preconnect to same origin as page Wastes a connection slot — same-origin is already connected Only preconnect to third-party CDN or API origins
video preload="metadata" on battery-constrained devices Fetches ~256 KB header data per video even if user never plays Use preload="none" below fold; set preload="metadata" only for autoplay hero
Service Worker CacheFirst on editorial images Stale images served indefinitely after CMS update Use StaleWhileRevalidate with broadcastUpdate plugin for content that changes
Preload <link> missing crossorigin on a CORS asset The preload and the element produce different cache keys — the asset is fetched twice, at double the bytes Mirror crossorigin and referrerpolicy exactly between the <link> and the consuming element
Hero image injected by client-side JavaScript Invisible to the preload scanner; discovery slips until after hydration, adding 1–3 s to LCP Server-render the <img>, or add an explicit <link rel="preload"> for the URL
103 Early Hints on a route that may redirect The client preloads assets for a page it immediately leaves; the bytes are pure waste on metered connections Only emit 103 once the response is known to be a 200 for that URL
autoplay on a video marked preload="none" Autoplay forces buffering regardless of the attribute, so the loop competes with the hero image Keep the src unset until after the load event, or drop autoplay and start playback from an observer
MP4 with the moov atom at the end of the file preload="metadata" needs a second range request for the file tail before it can report duration Remux with -movflags +faststart so the header is the first thing on the wire

Browser & CDN Compatibility Matrix

Feature Chrome 85 Chrome 101+ Firefox 93 Safari 14 Safari 16 Safari 17.2+ Edge 18 Edge 101+
loading="lazy" <img> Yes Yes Yes Yes Yes Yes Partial Yes
loading="lazy" <iframe> Yes Yes Yes No No No No Yes
fetchpriority on <img> No Yes No No No Partial No Yes
fetchpriority on <link> No Yes No No No Partial No Yes
Preload imagesrcset Yes Yes Yes No Yes Yes No Yes
IntersectionObserver v1 Yes Yes Yes Yes Yes Yes Yes Yes
IntersectionObserver v2 Yes Yes No No No No No Yes
rel="preconnect" Yes Yes Yes Yes Yes Yes No Yes
rel="prefetch" Yes Yes Yes Partial Yes Yes No Yes
HTTP Early-Hints (103) Yes (103+) Yes No No No No No Yes

Safari 14 notes: fetchpriority is silently ignored — images load at the default browser priority. Ensure your LCP image is discoverable by the preload scanner via <link rel="preload" as="image" href="..."> without relying on the priority hint. Safari 14 also does not support loading="lazy" on <iframe> — use an IntersectionObserver wrapper.

Firefox notes: fetchpriority was partially shipped in Firefox 101 behind a flag; it shipped without a flag in Firefox 132 (late 2024). For the Firefox 93–131 window, fetchpriority is parsed but ignored. Test LCP performance on Firefox separately from Chrome.

Edge 18 (EdgeHTML): Legacy engine — preconnect, prefetch, and IntersectionObserver are absent or broken. Edge 79+ (Chromium) matches Chrome behaviour.