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.

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.

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.

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

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

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.

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

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.