cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

Loading strategies

Deciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.

The first screen is a race

Picture a product page for a shoe shop. The HTML is 40 KB. It links one stylesheet, three scripts, two web fonts, a big hero photo of the shoe, twenty smaller photos further down, a reviews widget, a chat button, and an analytics tag. Counting everything, the page asks for about 70 files.

A shopper opens it on a mid-range phone over 4G, with a round trip of about 100 ms. Bandwidth is limited, the CPU is slow, and some files stop the page from showing anything until they arrive. So the browser has to pick an order.

Without any help, it often picks a poor one. On this page the hero photo is set from JavaScript after a data request, so the browser only learns it exists about 1.8 seconds in. Meanwhile it has spent bandwidth on the chat script and on photos at the bottom of the page that nobody has scrolled to yet.

The shopper stares at a white box where the shoe should be. LCP, the Largest Contentful Paint (the moment the biggest element on the first screen is painted), lands at 3.9 seconds. That is rated "needs improvement", a hair from the 4-second line where "poor" begins.

A request waterfall for the shoe page over 4 seconds, before any loading strategy. index.html loads from 0 to 0.4 seconds. app.css and app.js start at 0.4 seconds; app.js ends at 1.3 seconds. chat.js and twenty grid photos below the fold also load early and take bandwidth. Only after app.js runs does /api/product load, from 1.3 to 1.8 seconds, and only then is hero.avif requested, at 1.8 seconds. It finishes at 3.7 seconds and LCP lands at 3.9 seconds.index.htmlapp.cssapp.jschat.jsgrid photos/api/producthero.avifLCP 3.9 shero found only here0 s1 s2 s3 s4 s
The hero waits behind app.js and a data request, while the chat script and photos nobody can see yet take its bandwidth. Nothing here is slow on its own: the order is the problem.

What a loading strategy is

A loading strategy is your plan for that order. It answers three questions for every file the page could load: does the first screen need it, will the user probably need it soon, or can it wait until they ask for it?

The rule fits in one sentence: load what the first screen needs as early as possible, load everything else after it, and load some things only when the user is likely to need them. The rest of this lesson applies that rule with real tools: where tags go in the HTML, a few attributes, and resource hints.

The browser is already guessing

Browsers already sort requests by type and position: CSS in the head goes first, fonts are high, images start low. Their guesses are good when they can see what is coming, and wrong when the important file is hidden inside JavaScript, inside a CSS file, or behind a data request.

So most of the work is not clever code. It is giving the browser better information, early. Fixing the shoe page took four small changes: put the hero in the HTML as an img, mark it fetchpriority="high", mark the photos below the fold loading="lazy", and load the chat script after the page is idle. LCP fell from 3.9 to about 1.9 seconds, with no new server and no new framework.

Where this lesson fits

This lesson builds on the critical rendering path lesson (what blocks the first paint) and the Core Web Vitals lesson (how LCP, INP, and CLS are measured). Here we focus on ordering the network: what to fetch first, what to hint, what to delay, and what to show while waiting.

Code splitting, images, web fonts, and prefetching each get their own lesson later. We touch them here only as far as the loading order needs.

The mental model: now, soon, later

Keep one picture in your head: every file on the page goes into one of three lanes. The lane decides when it loads and how loudly you ask for it.

  • Now. What the first screen cannot paint without: the HTML, the CSS for the visible layout, the font for the headline, the LCP image, and any script the first screen truly needs to work. Make them discoverable in the HTML, fetch them at high priority, and keep them few.
  • Soon. What the user will probably see or use in the next few seconds: photos just below the fold, the code for the menu, the reviews section. Fetch these after the first screen, at normal or low priority, often triggered by scrolling near them.
  • Later. What the user may never need: the share dialog, the video player in a closed tab, the next page they might click. Fetch these on intent (hover, focus, tap) or when the browser is idle; some will never load at all.
Three stacked lanes. Now, the first screen: HTML and critical CSS, the hero image, and the headline font, loaded from the HTML at high priority. Soon, just below the fold: grid photos, the reviews widget, and the menu code, loaded when the user scrolls near, at normal or low priority. Later, maybe never: the share dialog, the chat, and the next page, loaded on intent or when idle, at the lowest priority.Now: the first screenHTML + CSShero imageheadline fontin the HTMLhigh prioritySoon: just below the foldgrid photosreviewsmenu codeon scroll nearnormal or lowLater: maybe nevershare dialogchatnext pageon intent or idlelowest
Every file goes in one lane. The now lane should be short and found early; the later lane is allowed to never load at all.

An everyday comparison: unloading a moving van

On moving day, the bed and the kettle come off the van first, so you can sleep and make tea on the first night. Boxes of books get unpacked over the week, and winter coats stay in storage until it gets cold. If the movers carry everything in at random, you are sitting on the floor at midnight looking for a pillow.

The browser is the mover. It is fast and willing, but it does not know which box holds the pillow unless the box is labelled. Your markup and hints are the labels.

Two levers: discovery and priority

Every technique in this lesson pulls one of two levers. Discovery is when the browser first learns a file exists. Priority is how the browser ranks that file against others once it knows about it. A file found late cannot be saved by high priority, and a file found early can still lose if ten other files claim to be just as urgent.

Each technique moves discovery, priority, or both. None of them makes bytes smaller.
TechniqueDiscoveryPriorityLane it serves
Put the resource in the HTMLEarlierNo changeNow
preloadEarlierSet by asNow
preconnectConnection earlierNo changeNow
fetchpriorityNo changeUp or downNow and soon
loading="lazy"Later on purposeStarts only near viewportSoon
prefetchEarlier, for the next pageLowestLater
Dynamic import() on intentLater on purposeNormal when askedLater

Loading strategies reorder work; they do not remove it. If the page ships 2 MB of JavaScript, a perfect order still sends 2 MB. Code splitting, image optimization, and compression make the bytes themselves smaller.

Check your understanding · Exercise 1

On a news article page, which of these belong in the "now" lane? Select all that apply.

Critical path first

The critical rendering path is the work the browser must finish before it can paint: download the HTML, build the DOM, download and parse the CSS it links, and run any scripts that block parsing. The critical rendering path lesson covers each step. Here we care about one question: which network requests sit on that path, and how early do they start?

The preload scanner: the browser's lookahead

When the main HTML parser meets a blocking script, it must stop: the script might call document.write and change everything after it. Stopping would waste the network, so browsers run a second, lighter parser called the preload scanner. It reads ahead in the raw HTML and starts downloads for the URLs it finds in img (including srcset), link rel="stylesheet", script src, and link hints.

Anything the preload scanner can see starts downloading within a few milliseconds of the HTML arriving. Anything it cannot see waits for the main parser, for CSS to be parsed, or for JavaScript to run.

A sequence diagram between the main parser, the preload scanner, and the network. The HTML arrives. The main parser requests app.css, then meets the blocking script ads.js and stops. While it waits, the preload scanner reads ahead in the raw HTML and requests hero.avif and app.js. When ads.js arrives and runs, the main parser resumes. Images set in CSS backgrounds, fonts, and images added by JavaScript are not seen by the scanner.Main parserPreload scannerNetworkHTML bytes arriveget app.cssget ads.js: parser stopsread aheadget hero.avifget app.js (defer)ads.js runs, parsing resumesnot seen by the scanner:CSS backgrounds, fonts, images added by JavaScript
The scanner turns blocked time into download time, but only for URLs written in the HTML. A hero set in CSS or rendered by JavaScript misses this head start.

Here is what the preload scanner cannot see, and so what tends to be discovered late:

  • Images set in CSS, like background-image: url(hero.jpg). The browser learns about them only after it downloads and parses the stylesheet, and fetches them only once an element uses that rule.
  • Fonts. They are named inside @font-face in CSS, and fetched only when text on the page needs that font. That is two steps late.
  • Elements added by JavaScript, such as a hero rendered by a client-side framework after a data request.
  • Lazy-loading libraries that use data-src. The real URL hides in a data attribute until a script copies it into src, so the scanner never sees it.
  • Imports inside imports. A module that imports another module is found only after the first one downloads and parses.

CSS blocks rendering, so keep the critical part small

A stylesheet in the head is render-blocking: the browser will not paint until it has it, so the page does not flash unstyled. That is the right default, but every kilobyte of CSS in the head is then on the critical path, including rules for pages that are nowhere on this screen.

Two common fixes. First, split CSS by route so the product page does not wait for the checkout page's styles. Second, for very latency-sensitive pages, inline the critical CSS (the rules for the first screen) in a style tag and load the rest without blocking. The critical rendering path lesson explains why a target of about 14 KB compressed for the HTML plus inlined CSS fits in the first round trip.

Scripts: blocking, defer, async, and module

A plain script src in the head blocks the parser twice over: it waits to download, and it also waits for any stylesheet before it, because the script might read styles. Three attributes change that.

defer and module scripts are the safe defaults. async is for scripts nothing else depends on.
AttributeDownloadsRunsOrder kept?Use it for
none (classic)Blocks the parserImmediatelyYesAlmost nothing in the head
deferIn parallelAfter parsing, before DOMContentLoadedYesYour app code
asyncIn parallelAs soon as it arrives, pausing the parserNoIndependent scripts like analytics
type="module"In parallel, with its importsLike defer by defaultYesModern bundles
A timeline from 0 to 1.2 seconds for four ways to load the same script, found at 0.2 seconds, in a page that takes 0.7 seconds to parse. Classic: parsing stops at 0.2 seconds while the script downloads until 0.5 and runs until 0.6, then parsing resumes and ends at 1.1. Defer: parsing runs without a break until 0.7 while the script downloads in parallel, then the script runs from 0.7 to 0.8. Async: the script downloads in parallel and runs at 0.5 as soon as it arrives, pausing parsing until 0.6; parsing ends at 0.8. Module: like defer, downloading the script and its imports in parallel until 0.55, then running after parsing at 0.7.classicdeferasyncmodule0 s0.3 s0.6 s0.9 s1.2 sparse HTMLdownloadrun
Only the classic script stops parsing to download. defer and modules wait for parsing to finish and keep their order; async runs the moment it lands.

Moving scripts to defer takes them off the parsing path, but not off the network path: they still compete for bandwidth with your hero image. That is where priorities come in, and why third-party scripts the first screen does not need belong in the later lane, loaded after the load event or when idle.

Server-render the first screen when you can

The most common LCP bug in client-rendered apps is a hero image that only exists after JavaScript runs. Such a page has a long critical path by design: HTML, then the JavaScript bundle, then the data request, then the image the data names. Rendering the first screen on the server (SSR, static generation, or streaming) puts the hero URL straight into the HTML, where the preload scanner finds it at once. For loading, SSR is first of all a discovery fix.

Check your understanding · Exercise 2

Which of these resources will the preload scanner find in the initial HTML? Select all that apply.

How the browser ranks requests, and how to correct it

Once the browser knows about a file, it gives the request a priority. Chrome's DevTools shows five levels in the Network panel's Priority column: Highest, High, Medium, Low, and Lowest. Other browsers use their own scales, but the idea is the same: on a busy connection, the top of the queue gets the bandwidth.

The default ranking

The defaults come from the file type and where the tag sits. They are sensible guesses made before layout, so they know what a file is but not whether it is visible.

A matrix of Chrome's default request priorities, from Highest to Lowest. The HTML document, CSS in the head, and fonts are Highest, though fonts are found late. Blocking scripts are High. Async and defer scripts are Low. Images start Low before layout and are boosted to High only once layout shows they are in the viewport. A hero image with fetchpriority high starts at High. Prefetch is Lowest.HighestHighMediumLowLowestHTML documentCSS in the headfont (found late)blocking scriptasync or defer scriptimage, before layouthero + fetchpriority=highprefetchafter layout
Images start low because the browser cannot know which one is the hero until layout. fetchpriority="high" moves the hero to the top from the start.
  • The HTML document and stylesheets in the head start at Highest: nothing paints without them.
  • Fonts named in CSS are Highest too, but they are found late, so their rank often comes too late to help. A font fetched by preload is High.
  • Blocking scripts in the head are High; async and defer scripts are Low.
  • Images start Low. Chrome raises an image to High once layout shows it is in the viewport, and it gives the first five large images Medium from the start. Layout can happen hundreds of milliseconds after the request was queued.
  • prefetch requests are Lowest: they are for a future page.

The weak spot is images: the browser cannot know which one is the hero until it has the CSS and runs layout. So the LCP image often waits at Low while scripts at High download first.

Priority hints: the fetchpriority attribute

Priority hints let you correct a guess. The fetchpriority attribute takes high, low, or auto (the default) and works on img, link, and script. The fetch() function takes the same idea as a priority option. It is a hint, not an order: it nudges the request up or down relative to others of its kind.

Support is broad. Chrome shipped it in version 101 in 2022, Safari in 17.2 at the end of 2023, and Firefox in version 132 in October 2024, which made it Baseline: safe to use in all major browsers. Older browsers ignore the attribute, so it never breaks anything.

html
<!-- The hero is the LCP element: found early, fetched first -->
<img src="/shoe-1200.avif" width="1200" height="800"
     fetchpriority="high" alt="Red trail running shoe, side view">

<!-- Slides 2 and 3 of the carousel are hidden at first -->
<img src="/slide-2.avif" width="1200" height="800"
     fetchpriority="low" alt="Sole of the shoe">

<!-- An async script the first screen does need -->
<script src="/size-picker.js" async fetchpriority="high"></script>

<script>
  // Data the first screen does not need
  fetch("/api/recently-viewed", { priority: "low" });
</script>
Priority hints add no requests; they reorder the ones the page already makes.

The common uses, in order of how often they matter:

  1. fetchpriority="high" on the LCP image. This is the big one. In a test described in web.dev's Fetch Priority article, raising the priority of the Google Flights hero image cut LCP from 2.6 to 1.9 seconds.
  2. fetchpriority="low" on images that are in the viewport by position but not visible, like hidden carousel slides or images behind a closed tab.
  3. fetchpriority="high" on an async script that drives the first screen, or on a preload for a late-found critical file.
  4. priority: "low" on fetch() calls for analytics, prefetches, and anything the user is not waiting for.

The server has to cooperate

With HTTP/2 and HTTP/3, many requests share one connection, and the browser sends its priorities along with them. The server (often a CDN) decides what order to send bytes in. A server that ignores priorities sends a 300 KB script and your hero image interleaved at equal speed, and your carefully set hints lose much of their effect. If your waterfall looks right in DevTools but the hero still arrives late, check that your CDN supports HTTP/2 or HTTP/3 prioritization.

Decision How should the shoe page get its LCP image requested early and fast?

Plain img in the HTML with fetchpriority="high"

Render the hero img in the server HTML

Add fetchpriority="high", keep loading at its default (eager)

Pros

  • Found by the preload scanner at once
  • One attribute, no duplicate URL to keep in sync
  • Works with srcset naturally

Cons

  • Needs the hero in the HTML, which a client-rendered app may not have

Preload in the head

Add link rel="preload" as="image" with imagesrcset and imagesizes matching the img

Add fetchpriority="high" to the preload

Pros

  • Works when the image is a CSS background or rendered by JavaScript
  • Starts the fetch before the body is parsed

Cons

  • The URL lives in two places and can drift
  • A mismatched srcset downloads two images
  • Wasted if the hero changes per user

Leave it to the browser

Hero img in the HTML, no hints

Pros

  • Nothing to maintain

Cons

  • Starts at Low or Medium and waits for layout to be boosted
  • Loses bandwidth to scripts and fonts in the meantime

What we pick: Use the first option whenever the hero can be in the HTML: it fixes both discovery and priority with one attribute. Reach for a preload only when the image is hidden from the scanner (CSS background, client-rendered hero) and you cannot change that.

Resource hints in detail

Resource hints are link tags in the head (or Link response headers) that tell the browser about work it will need to do, before it would find out on its own. Five matter. Each does a different amount of work, and each has a cost when used on the wrong thing.

Five link tags in the head of a page, each annotated. dns-prefetch to a reviews origin: DNS lookup only. preconnect to the image CDN: DNS, TCP, and TLS now. preload with as font and crossorigin: a file this page needs that would be found late. modulepreload for store.js: fetch and parse a module early. prefetch for checkout.js: for the next page, at the lowest priority.inside the head, one link tag per linerel="dns-prefetch" href="https://rv.io"DNS lookup onlyrel="preconnect" href="https://img.cdn"DNS + TCP + TLS nowrel="preload" as="font" crossoriginthis page, found laterel="modulepreload" href="/store.js"fetch + parse modulerel="prefetch" href="/checkout.js"next page, lowest
From top to bottom each hint does more work, but the last one is for a different page. Each line is the inside of a link tag; the font preload also needs an href.
From cheapest to most specific. The first two warm up connections; the last three fetch bytes.
HintWhat it does nowPriorityUse it for
dns-prefetchLooks up the domain name onlyNot a fetchMany third-party origins, cheaply
preconnectDNS lookup, TCP handshake, and TLS handshakeNot a fetchTwo to four critical origins
preloadDownloads a file this page needsSet by as, adjustable with fetchpriorityCritical files found late
modulepreloadDownloads, parses, and compiles a JS moduleLike a scriptModules imported deep in the graph
prefetchDownloads a file for a later pageLowestThe likely next page's files

preconnect and dns-prefetch

Before the first byte can come from a new origin, the browser does three things: a DNS lookup (find the IP address), a TCP handshake (open the connection), and a TLS handshake (agree on encryption). On a 100 ms mobile round trip that is roughly 200 to 300 ms of setup. preconnect does that setup early, while the browser is still busy with the HTML, so the first real request to that origin starts at full speed.

html
<!-- Images come from this CDN: open the connection now -->
<link rel="preconnect" href="https://img.shoeshop-cdn.com">

<!-- Fonts are fetched in CORS mode, so this one needs crossorigin -->
<link rel="preconnect" href="https://fonts.shoeshop-cdn.com" crossorigin>

<!-- Less important origins: just the DNS lookup -->
<link rel="dns-prefetch" href="https://reviews.example-widgets.com">
A connection opened without crossorigin cannot be reused for a CORS request like a font, and the reverse.
  • When to use it: for origins you are sure the first screen will hit: your image CDN, your font host, your API on another domain.
  • The cost: each preconnect opens a real connection that uses CPU and competes with other early work. Chrome closes one that stays unused for about 10 seconds, so a preconnect for something fetched 15 seconds later is wasted. Keep it to two to four origins.
  • The mistake: preconnecting to a font origin without crossorigin. Connections are kept apart by credentials mode, so the font request opens a second connection and the preconnect is wasted.
  • dns-prefetch does only the first step. It is cheap, so it suits origins that matter less or come later, like a reviews widget.

preload: fetch this now, this page needs it

preload tells the browser to download a specific file for the current page right away. It is the fix for late discovery: fonts named in CSS, a hero set as a CSS background, a critical script imported by another script. The browser keeps the response in memory and hands it over when the page asks for it.

html
<!-- The headline font, found two steps late otherwise -->
<link rel="preload" href="/fonts/inter-700.woff2"
      as="font" type="font/woff2" crossorigin>

<!-- A hero set as a CSS background, with responsive sources -->
<link rel="preload" as="image" fetchpriority="high"
      imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
      imagesizes="100vw">
as is required. Fonts also need crossorigin, even on your own domain.
  • as is required. It tells the browser what the file is (font, image, style, script, fetch), which sets its priority, its Accept header, and its security checks. A preload without as, or with the wrong one, gets the wrong treatment and may be fetched twice.
  • Fonts need crossorigin. Fonts are always requested in CORS mode, even from your own origin, so a preload without crossorigin does not match the real request and the font downloads twice.
  • type lets the browser skip formats it cannot use, such as type="image/avif" in an old browser.
  • Unused preloads waste bandwidth. Chrome prints a console warning when a preloaded file is not used within a few seconds of the load event. Treat that warning as a bug.
  • One to three preloads per page is typical. Each one competes with the others and with the files the browser found itself.

Font weights, font-display, and responsive hero images belong to the web fonts and images lessons. For loading order, preload only the one font the first screen's headline uses.

modulepreload: flatten a deep import chain

With native ES modules, main.js imports router.js, which imports store.js. The browser only finds store.js after downloading and parsing the two before it: a waterfall of round trips.

link rel="modulepreload" lists those modules up front. Unlike a plain preload as="script", it can also parse and compile them and put them in the module map, so they are ready when the import runs. Bundlers such as Vite inject these tags for the chunks each entry needs.

prefetch: for the next page, not this one

prefetch downloads a file at the lowest priority, when the browser has nothing better to do, so that a likely next page loads faster. It is for future navigations. Use it for the JavaScript chunk of the checkout page when the user is on the cart, not for anything the current page needs: that would arrive last.

  • Prefetched files go into the HTTP cache. Chrome also keeps them usable for about five minutes even if their cache headers would not allow it, so the next page can use them.
  • Safari does not support link rel="prefetch" by default, so treat it as a bonus, not a guarantee.
  • Every prefetch costs data. Skip it when the user has turned on data saving (the Save-Data header) or is on a slow connection.
  • For whole-page prefetching and prerendering on hover, Chromium browsers offer the Speculation Rules API. The prefetching lesson covers it, along with prefetching data.

Sending hints before the HTML: 103 Early Hints

Hints in the HTML only start once the HTML arrives. If the server needs 400 ms to build the page, those 400 ms are idle network time. A server or CDN can send an interim 103 Early Hints response at once, carrying Link headers that point at the stylesheet with rel=preload; as=style or at an origin with rel=preconnect.

The browser starts those fetches while the server is still working. All major browsers act on preconnect in Early Hints. Chrome and Firefox also act on preload, but Safari does not, so measure before you count on it.

Hero is:
Turn on:
A request waterfall over 4 seconds for the shoe page. The hero image is found at 1800 ms and downloads from 2100 to 3700 ms. The headline text can paint at 1210 ms. LCP is 3.90 seconds, rated needs improvement.index.htmlapp.cssapp.js/api/productfont.woff2connect CDNhero.avifgrid photosLCP 3.90 s0.0 s1.0 s2.0 s3.0 s4.0 sheadline text paints at 1.21 s
The hero is found only after app.js runs and the product data arrives. Hints that help the download cannot make up for discovering it 1.8 seconds in.

LCP 3.90 s (needs improvement). Hero requested at 2.10 s.

Check your understanding · Exercise 3

Which hint matches each need correctly? Select all that apply.

Lazy loading below the fold

The fold is the bottom edge of the first screen. Everything below it is in the soon or later lane. On the shoe page that is twenty product photos, an embedded video, the reviews widget, and a map of stores: well over half the page's bytes, none of which the shopper sees in the first second.

Lazy loading means not fetching those things until the user is about to see them. It frees bandwidth for the now lane, and it saves bytes outright for every user who never scrolls that far, which on many pages is most of them.

A product page drawn in a window, with a dashed line marking the fold, the bottom of the first screen. Above it: the header, the hero image, and the title and price, all in the HTML; the hero has fetchpriority high. Below it: a grid of photos with loading lazy, the reviews widget loaded with import when it comes near, and a video shown as a facade until clicked.product pagehero imagephotophotoreviewsvideothe foldfirst screen: start nowhero: in the HTML,fetchpriority="high"header, title, price: in the HTMLbelow the fold: waitphotos:loading="lazy"reviews: import() when nearvideo: a facade until clicked
Above the fold, the goal is to start early. Below it, the goal is to not start at all until the user is on their way there.

Native lazy loading: loading="lazy"

For images and iframes, one attribute does it. loading="lazy" tells the browser to hold the request until the element is within a certain distance of the viewport. loading="eager" is the default and means load right away. It works on img in every major browser, and on iframe in every major browser since late 2023.

html
<!-- Product grid, below the fold: wait until the user scrolls near -->
<img src="/p/4412-400.avif" width="400" height="400"
     loading="lazy" alt="Blue trail shoe, top view">

<!-- A video embed far down the page -->
<iframe src="https://video.example.com/embed/fit-guide"
        width="560" height="315" loading="lazy"
        title="How to find your size"></iframe>
Always give lazy images a width and height, so the browser reserves their space before they load.
  • How far ahead it loads. The browser starts fetching before the element is visible, so it is usually ready in time. Chrome uses about 1,250 px on fast connections and 2,500 px on slow ones; other browsers pick their own margins. You do not control this distance.
  • Reserve the space. A lazy image without width and height (or a CSS aspect-ratio) has zero height until it loads, then pushes everything below it down. That is a layout shift, and it counts against CLS.
  • Never lazy-load the LCP image or anything in the first screen. A lazy image waits for layout to know where it is, which adds delay to the one image you most want early. This was a real-world regression: when WordPress 5.5 turned on lazy loading for all images in 2020, sites whose hero became lazy got slower LCP. Later versions stopped lazy-loading the first content images, and WordPress 6.3 also adds fetchpriority="high" to the likely LCP image.

Lazy components with IntersectionObserver

The attribute covers images and iframes. For your own components, such as the reviews widget or the store map, use IntersectionObserver. It calls you back when an element gets close to the viewport, without you listening to every scroll event. Give it a rootMargin so work starts a little before the element scrolls into view.

ts
const section = document.querySelector<HTMLElement>("#reviews")!;

const observer = new IntersectionObserver(
  async ([entry]) => {
    if (!entry.isIntersecting) return;
    observer.disconnect();
    const { mountReviews } = await import("./reviews");
    mountReviews(section);
  },
  { rootMargin: "600px 0px" }, // start 600 px before it scrolls in
);

observer.observe(section);
The reviews code and its data are not downloaded at all for shoppers who never scroll that far.

The dynamic import() is what makes this pay off: the reviews code becomes its own chunk, separate from the main bundle. How bundlers split code, and how React's lazy and Suspense wrap this pattern, is the subject of the code splitting lesson.

A bar chart of kilobytes downloaded by the shoe page, to scale. With everything eager: 3,480 KB, including 1,800 KB of grid photos, a 600 KB video embed, and 300 KB of chat script. With the lane plan, for a shopper who stays on the first screen: 630 KB. With the lane plan, for a shopper who scrolls to the very end: 2,580 KB, because the video and chat still load only on a click.kilobytes downloaded, to scaleeverything eager3,480 KBlane plan, shopper stays on first screen630 KBlane plan, shopper scrolls to the end2,580 KB
Lazy loading saves bytes outright for everyone who does not scroll, and even the shopper who reads the whole page never pays for features they did not open.

Load on intent: the later lane

Some things should not load on scroll either, because only a few users ever use them: the share dialog, the size guide, the live chat. Load them on intent, the first sign the user wants them.

  • Hover or focus on the trigger often gives a few hundred milliseconds of head start before the click on a desktop. Start the import() then.
  • Tap or click is the latest moment. Show a small pending state while the code arrives.
  • Facades for heavy third-party embeds: show a static image of the video player or chat button, and swap in the real embed, often hundreds of kilobytes of script, only when the user clicks it.
  • Idle time for things most users will want but not at once: requestIdleCallback, or a short timer after the load event, for analytics or a below-fold carousel's code.
A decision flowchart. First question: is it in the first screen? If yes, load it eagerly, and give the hero fetchpriority high. If no, next question: is it an image or iframe? If yes, use loading lazy with width and height. If no, next question: do most users need it? If yes, use an IntersectionObserver with import, starting 600 pixels early. If no, load it on intent: hover, focus, or click.In the first screen?eagerhero: fetchpriority=highyesnoImage or iframe?loading="lazy"plus width and heightyesnoMost users need it?observer + import()start 600 px earlyyesnoload on intenthover, focus, or click
Ask in order. Each no pushes the resource one lane later, and each yes names the one tool for it.

Check your understanding · Exercise 4

Which of these should get loading="lazy"? Select all that apply.

What to show while things load: skeletons, spinners, and nothing

Even with a perfect order, some content arrives late: the reviews come from a slow service, the recommendations need a second request. The last part of a loading strategy is what the user sees in the gap. There are three common answers: show nothing, show a spinner, or show a skeleton.

A skeleton is a grey outline of the layout to come: a box where the photo will be, two bars where the title and price will be. A spinner is a single animated icon that says "something is happening" without saying what or where.

Show:
Data takes:
A product page at 800 milliseconds. A skeleton is showing. Data arrives at 1200 ms and content shows at 1200 ms. Nothing moves when it arrives.product page: reviewsfooter and moreskeletondata at 1200 mscontent at 1200 msshown for 900 msno shiftno flicker
The space is reserved from the start, so nothing moves when the reviews arrive.

A skeleton is showing.

Why skeletons usually win for content

  • They hold the space. If the skeleton has the same size as the real content, nothing moves when the content arrives. A spinner in a 40 px box that becomes a 600 px review list pushes the rest of the page down: a layout shift that hurts CLS.
  • They show where and what. The user sees that a grid of cards is coming, and the wait feels shorter because the page looks nearly done.
  • They make the page progressive. Each region can fill in on its own as its data arrives, instead of one spinner holding the whole page hostage until the slowest request finishes.

When a spinner, or nothing, is better

Match the indicator to what is loading and how long it usually takes.
SituationBest choiceWhy
A content region with a known layout (feed, card grid, profile)SkeletonReserves space and shows the shape
An action the user started (Save, Pay, Send)Spinner or busy state on the buttonThe layout is not changing; the user needs to know the action is in progress
Content whose shape you cannot predict (search results of mixed types)A simple placeholder of fixed heightA detailed skeleton that guesses wrong causes a shift anyway
A wait usually under about 300 msNothing at firstA flash of skeleton for 100 ms looks like flicker, not progress
A long task over 10 seconds (export, upload)A progress bar with real progressUsers want to know how much is left

Getting the timing right

A loading indicator that appears and disappears within a moment is worse than none: it flickers. A common rule is to wait about 200 to 300 ms before showing it, and once it is shown, keep it for at least 300 to 500 ms so it does not blink. Fast responses then show no indicator at all, and slow ones show a calm, steady one.

A state machine for one loading region. From idle, a request starts and the region enters waiting, with no indicator yet. If data arrives within 300 milliseconds, it goes straight to content. If 300 milliseconds pass first, it shows a skeleton sized like the content. From the skeleton, data arriving moves it to content, after the skeleton has been shown for at least 400 milliseconds. If the request fails, it moves to error, with a message and a retry button. Retry shows the skeleton again.idlenothing asked yetrequest startswaitingno indicator yetcontentreal data showndata within 300 ms300 ms passskeletonsized like contentdata in, kept 400+ mserrormessage + retryrequest failsretry
Fast responses never show an indicator, and slow ones show a steady one. The skeleton always ends in content or an error, never in endless shimmer.

React's useTransition and Suspense follow the same idea: during a transition, React keeps the old screen up instead of falling back to a skeleton straight away. Streaming server rendering takes it further: the server sends the page shell and skeletons first, then streams each region's HTML as its data is ready.

Skeleton mistakes

  • A skeleton that does not match. Three skeleton cards that turn into five real ones, or a 200 px skeleton for a 320 px card, shift the layout just like a spinner would. Size skeletons from the same CSS as the real component.
  • A whole page of grey boxes. If the first screen is all skeleton, the user still sees nothing useful. The now lane should be real content, rendered on the server; skeletons are for the soon lane.
  • Skeletons for content that could be in the HTML. If the server already knows the product title, send it. A skeleton is a fallback for data you truly do not have yet.
  • Endless shimmer. If the request fails, the skeleton must turn into an error with a retry, not shimmer forever.

Accessibility of loading states

A skeleton is visual only. Screen reader users need the same information in words, and they should not hear a list of empty boxes.

  • Mark the loading region with aria-busy="true", and set it back to false when the content is in.
  • Hide the decorative skeleton shapes with aria-hidden="true", and give the region a short text such as "Loading reviews" in a visually hidden element.
  • Announce completion sparingly with a polite live region (aria-live="polite"), for example "12 reviews loaded", and never for every small update.
  • Respect prefers-reduced-motion: turn the shimmer animation into a static tint for users who ask for less motion.
  • Do not move focus into a region just because it finished loading. Leave focus where the user put it.

Check your understanding · Exercise 5

A dashboard shows four charts. Each chart's data takes 0.5 to 2 seconds. Which loading approaches work well? Select all that apply.

Failure modes, and how to find them

Loading strategies fail quietly. Nothing throws an error; the page just gets a little slower, or a hint silently does nothing. You find these problems by looking at the waterfall, not at the code.

Where to look

  • DevTools Network panel. Turn on the Priority column, and set throttling to a slow 4G profile. Find the LCP image: when does its request start, and at what priority? The Initiator column shows who found it (the parser, a stylesheet, or a script).
  • DevTools Performance panel and Lighthouse. They flag render-blocking requests, long chains of dependent requests, a lazy-loaded LCP image, and an LCP image that the HTML does not reveal.
  • Field data. Split LCP into its four parts, as the Core Web Vitals lesson shows. A large resource load delay is a discovery or priority problem, which is exactly what this lesson fixes.

Symptoms, causes, fixes

Most loading bugs are visible in a throttled waterfall within a minute.
What you seeLikely causeFix
The LCP image request starts after the main bundleThe hero is rendered by JavaScript or set in CSSPut the img in server HTML, or preload it with fetchpriority="high"
The LCP image starts early but at LowNo priority hint; waiting for layout to boost itAdd fetchpriority="high" to the hero
The LCP image starts late and is marked lazyloading="lazy" on a first-screen imageRemove it from the first few images
Each font downloads twiceFont preload without crossorigin, or a different URLAdd crossorigin; make the URL match @font-face exactly
Console: preloaded but not used within a few secondsPreloading a file the page does not need, or with the wrong asRemove it or fix as
Two hero images downloadPreload imagesrcset does not match the img's srcset and sizesGenerate both from one source of truth
The page shifts when lazy content arrivesNo size reserved, or a skeleton of the wrong sizeSet width and height or aspect-ratio; size skeletons from the real CSS
Mobile data use jumps after a releaseAggressive prefetch of next pagesPrefetch on intent, and skip it under Save-Data

The over-hinting trap

Hints are easy to add and hard to remove, because nobody is sure what each one does a year later. A typical decay: the team preloads the hero, then the logo, then two fonts, then the main bundle "to be safe". Now five files share the early bandwidth, and the hero arrives later than with no hints at all.

Treat every hint as code with a reason: write down which file it serves and which metric it moves, and check it in the waterfall after each release. A good page often has only a handful of hand-written hints: one or two preconnects, one font preload, maybe one image preload. Bundler-generated modulepreloads come on top.

Lazy-loading edge cases

  • Printing and find-in-page. Text in a lazy component that has not loaded is missing from a printout or a Ctrl+F search. Where this matters, load the rest when the browser is idle, not only on scroll.
  • Anchors and restored scroll. A link to #reviews, or the back button restoring a scroll position, can land the user in the middle of unloaded content. Reserved sizes keep the position correct while it fills in.
  • Very fast scrolling. A user flinging down a long grid can outrun the lazy margin and see empty boxes for a moment. Reserved space with a light background colour makes this look calm.
  • Search engines. Crawlers render the page but do not scroll like a person. Content that only loads on scroll or on click may not be indexed. Keep important text in the HTML.

Check your understanding · Exercise 6

In a throttled waterfall, the hero image request starts at 1.6 s, right after app.js finishes, at High priority. What will fix the delay? Select all that apply.

In real products and frameworks

You rarely write every hint by hand. Modern frameworks bake in a loading strategy, and knowing what they do lets you spot when the default is wrong for your page.

What frameworks do for you

  • Image components lazy-load by default. Next.js Image and Angular NgOptimizedImage set loading="lazy" unless you say otherwise. In Angular, the priority attribute turns lazy loading off and adds fetchpriority="high", and development builds warn when the LCP image lacks it. In Next.js 16, set fetchPriority="high" or loading="eager" on the hero (the older priority prop is deprecated). Either way, the default is right for the grid and wrong for the hero.
  • Bundlers add modulepreload. Vite injects them for each entry's chunks, and Next.js and other frameworks preload the scripts each route needs.
  • Routers prefetch the next page. Next.js prefetches the code (and, for static routes, the content) of Link targets as they scroll into view, in production builds. Remix and React Router can prefetch on hover or intent. Astro has an opt-in prefetch with hover, tap, and viewport strategies.
  • Suspense boundaries are loading plans. In React frameworks with streaming, every Suspense boundary is a decision about which part of the page can show a skeleton and fill in later.

A full plan for the shoe page

Here is the whole strategy in one place, the way you would write it in a design doc. Every line says which lane a resource is in and the one tool that puts it there.

The now lane is short on purpose, and everything in it is named in the first HTML response.
ResourceLaneHow it is loaded
HTML with title, price, and hero imgNowServer-rendered, streamed, cached at the CDN for anonymous users
Critical CSSNowInlined for the first screen; the rest in a route stylesheet
Hero imageNowimg in HTML, fetchpriority="high", responsive srcset
Headline fontNowpreload with as="font" and crossorigin; other weights load normally
Image CDN and font originsNowpreconnect (the font one with crossorigin)
App JavaScriptNow, off the parsing pathtype="module" with bundler modulepreload
Size picker dataNowIncluded in the HTML, not fetched by the client
Product grid photosSoonloading="lazy" with width and height
Reviews widget and its dataSoonIntersectionObserver plus import(), with a sized skeleton
Fit videoLaterFacade image; real iframe on click
Chat, analyticsLaterAfter load, when idle
Checkout chunkLaterPrefetch on hover of Add to cart, skipped under Save-Data

Notice what the plan does not have: no preload for the main bundle (the bundler handles it), no fetchpriority on anything but the hero, and no hints for the soon lane at all. Fewer, sharper hints beat many vague ones.

Think first

The same shop is a client-rendered single-page app and cannot move to server rendering this quarter. What can it still do for the hero?

Using it in a system design interview

Loading strategy comes up in almost every frontend system design interview, usually in the performance or optimization part near the end. The interviewer is checking whether you can turn "make it fast" into concrete decisions about order.

When to bring it up

  • When you draw the first screen of a page-level design (news feed, product page, video page), say what is in the now lane and how it is found early.
  • When the design has a long list or grid: mention lazy loading for media and virtualization for the DOM.
  • When there are heavy optional features (editor, chat, video player): say they load on intent, in their own chunks.
  • When data arrives from several services: say which regions get skeletons and how they avoid layout shift.

What to say, in about a minute

Structure the answer the way the browser works: discovery first, then priority, then what waits, then what the user sees while waiting, then how you would prove it worked. If you can draw, sketch a short waterfall with the HTML, the hero, and the main script, and show the hero starting right after the HTML.

text
1. Name the LCP element and make it discoverable:
   server-render the first screen, hero img in HTML,
   fetchpriority=high on it.
2. Keep the critical path short: small critical CSS,
   scripts deferred or as modules, preconnect to the
   image CDN, preload only the headline font.
3. Lazy-load below the fold: loading=lazy for media,
   IntersectionObserver + import() for widgets.
4. Load optional features on intent; prefetch the
   likely next route, skipped under Save-Data.
5. Skeletons sized like the real content for slow
   regions, with aria-busy, so CLS stays near zero.
6. Verify in a throttled waterfall and with field LCP
   subparts; watch for unused preloads.
Six steps: discovery, critical path, soon, later, waiting states, measurement.

How deep to go by level

Interviewers do not expect every detail at every level. They expect the right ideas in the right order, with more precision and more trade-offs as the level goes up.

LevelWhat a strong answer includes
JuniorLazy-load images below the fold, do not lazy-load the hero, use defer for scripts, show skeletons instead of a blank page
MidThe preload scanner and late discovery; fetchpriority on the LCP image; preconnect vs preload vs prefetch with correct use; skeletons sized to avoid CLS; code loaded on intent
SeniorA lane plan for the whole page; the costs of over-hinting; crossorigin and as pitfalls; 103 Early Hints; CDN prioritization; prefetch budgets and Save-Data; measuring with LCP subparts in the field; how framework defaults fit in

Check your understanding · Exercise 7

An interviewer says: "Our product page has a 4-second LCP on mobile." Which first steps show the strongest reasoning? Select all that apply.

Summary

  • Sort every file into three lanes: now (the first screen), soon (just below it), and later (on intent or idle). Keep the now lane short.
  • Two levers: discovery and priority. Priority cannot fix discovery, so put critical resources where the preload scanner can see them: in the server HTML.
  • fetchpriority="high" on the LCP image is often the single most valuable change for LCP, and it is Baseline since late 2024. Use high on one or two resources, not many.
  • preconnect warms a connection, preload fetches a late-found file for this page (with a correct as, and crossorigin for fonts), modulepreload flattens module chains, and prefetch is lowest priority for the next page.
  • loading="lazy" on images and iframes below the fold, with width and height; never on the hero. Load components with IntersectionObserver and import(), and optional features on intent.
  • Skeletons sized like the real content prevent layout shift and show progress; delay them about 200 to 300 ms to avoid flicker, and make them accessible with aria-busy.
  • Every hint is code with a cost. Check a throttled waterfall after each release and remove hints that no longer earn their place.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is the difference between preload, prefetch, and preconnect?

Question 2

Your LCP image is a hero banner. Walk me through how you would make it load fast.

Question 3

When would you use a skeleton instead of a spinner?

Question 4

Why is lazy-loading every image on the page a bad idea?

Question 5

Design the loading strategy for a video watch page.

Practice it

These design problems lean on loading strategies.

References

Read next