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.
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.
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.
| Technique | Discovery | Priority | Lane it serves |
|---|---|---|---|
| Put the resource in the HTML | Earlier | No change | Now |
preload | Earlier | Set by as | Now |
preconnect | Connection earlier | No change | Now |
fetchpriority | No change | Up or down | Now and soon |
loading="lazy" | Later on purpose | Starts only near viewport | Soon |
prefetch | Earlier, for the next page | Lowest | Later |
Dynamic import() on intent | Later on purpose | Normal when asked | Later |
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.
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-facein 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 intosrc, 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.
| Attribute | Downloads | Runs | Order kept? | Use it for |
|---|---|---|---|---|
| none (classic) | Blocks the parser | Immediately | Yes | Almost nothing in the head |
defer | In parallel | After parsing, before DOMContentLoaded | Yes | Your app code |
async | In parallel | As soon as it arrives, pausing the parser | No | Independent scripts like analytics |
type="module" | In parallel, with its imports | Like defer by default | Yes | Modern bundles |
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.
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
preloadis High. - Blocking scripts in the head are High;
asyncanddeferscripts 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.
prefetchrequests 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.
<!-- 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>
The common uses, in order of how often they matter:
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.fetchpriority="low"on images that are in the viewport by position but not visible, like hidden carousel slides or images behind a closed tab.fetchpriority="high"on anasyncscript that drives the first screen, or on apreloadfor a late-found critical file.priority: "low"onfetch()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?
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.
link tag; the font preload also needs an href.| Hint | What it does now | Priority | Use it for |
|---|---|---|---|
dns-prefetch | Looks up the domain name only | Not a fetch | Many third-party origins, cheaply |
preconnect | DNS lookup, TCP handshake, and TLS handshake | Not a fetch | Two to four critical origins |
preload | Downloads a file this page needs | Set by as, adjustable with fetchpriority | Critical files found late |
modulepreload | Downloads, parses, and compiles a JS module | Like a script | Modules imported deep in the graph |
prefetch | Downloads a file for a later page | Lowest | The 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.
<!-- 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">
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-prefetchdoes 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.
<!-- 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.asis required. It tells the browser what the file is (font,image,style,script,fetch), which sets its priority, itsAcceptheader, and its security checks. A preload withoutas, 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 withoutcrossorigindoes not match the real request and the font downloads twice. typelets the browser skip formats it cannot use, such astype="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
loadevent. 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-Dataheader) 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.
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.
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.
<!-- 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>
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
widthandheight(or a CSSaspect-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.
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 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.
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 theloadevent, for analytics or a below-fold carousel's code.
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.
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
| Situation | Best choice | Why |
|---|---|---|
| A content region with a known layout (feed, card grid, profile) | Skeleton | Reserves space and shows the shape |
| An action the user started (Save, Pay, Send) | Spinner or busy state on the button | The 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 height | A detailed skeleton that guesses wrong causes a shift anyway |
| A wait usually under about 300 ms | Nothing at first | A flash of skeleton for 100 ms looks like flicker, not progress |
| A long task over 10 seconds (export, upload) | A progress bar with real progress | Users 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.
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 tofalsewhen 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
| What you see | Likely cause | Fix |
|---|---|---|
| The LCP image request starts after the main bundle | The hero is rendered by JavaScript or set in CSS | Put the img in server HTML, or preload it with fetchpriority="high" |
| The LCP image starts early but at Low | No priority hint; waiting for layout to boost it | Add fetchpriority="high" to the hero |
| The LCP image starts late and is marked lazy | loading="lazy" on a first-screen image | Remove it from the first few images |
| Each font downloads twice | Font preload without crossorigin, or a different URL | Add crossorigin; make the URL match @font-face exactly |
| Console: preloaded but not used within a few seconds | Preloading a file the page does not need, or with the wrong as | Remove it or fix as |
| Two hero images download | Preload imagesrcset does not match the img's srcset and sizes | Generate both from one source of truth |
| The page shifts when lazy content arrives | No size reserved, or a skeleton of the wrong size | Set width and height or aspect-ratio; size skeletons from the real CSS |
| Mobile data use jumps after a release | Aggressive prefetch of next pages | Prefetch 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
Imageand AngularNgOptimizedImagesetloading="lazy"unless you say otherwise. In Angular, thepriorityattribute turns lazy loading off and addsfetchpriority="high", and development builds warn when the LCP image lacks it. In Next.js 16, setfetchPriority="high"orloading="eager"on the hero (the olderpriorityprop 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
Linktargets 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
Suspenseboundary 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.
| Resource | Lane | How it is loaded |
|---|---|---|
HTML with title, price, and hero img | Now | Server-rendered, streamed, cached at the CDN for anonymous users |
| Critical CSS | Now | Inlined for the first screen; the rest in a route stylesheet |
| Hero image | Now | img in HTML, fetchpriority="high", responsive srcset |
| Headline font | Now | preload with as="font" and crossorigin; other weights load normally |
| Image CDN and font origins | Now | preconnect (the font one with crossorigin) |
| App JavaScript | Now, off the parsing path | type="module" with bundler modulepreload |
| Size picker data | Now | Included in the HTML, not fetched by the client |
| Product grid photos | Soon | loading="lazy" with width and height |
| Reviews widget and its data | Soon | IntersectionObserver plus import(), with a sized skeleton |
| Fit video | Later | Facade image; real iframe on click |
| Chat, analytics | Later | After load, when idle |
| Checkout chunk | Later | Prefetch 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?
Make the hero discoverable without the app: put a preload for the hero image in the static HTML shell, with fetchpriority="high". If the hero varies per product, the server that serves the shell can at least fill in that one URL.
Remove the data request from the critical chain: inline the first product's JSON in the HTML, or start the API request from a tiny inline script before the bundle loads, so it runs in parallel with the bundle instead of after it.
preconnect to the API and image origins, and keep the bundle on the critical path as small as possible with route-level code splitting.
Show a skeleton the exact size of the product layout while the app boots, so the page is stable when content arrives.
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.
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.
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.
| Level | What a strong answer includes |
|---|---|
| Junior | Lazy-load images below the fold, do not lazy-load the hero, use defer for scripts, show skeletons instead of a blank page |
| Mid | The 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 |
| Senior | A 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. Usehighon one or two resources, not many.preconnectwarms a connection,preloadfetches a late-found file for this page (with a correctas, andcrossoriginfor fonts),modulepreloadflattens module chains, andprefetchis lowest priority for the next page.loading="lazy"on images and iframes below the fold, withwidthandheight; never on the hero. Load components withIntersectionObserverandimport(), 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?
preconnect fetches nothing. It does the DNS lookup, TCP handshake, and TLS handshake to an origin early, so the first real request there starts at full speed. Use it for two to four critical origins, and add crossorigin when the requests will be CORS, like fonts.
preload downloads a specific file the current page needs, now, at a priority set by its required as attribute. It fixes late discovery for fonts, CSS background images, and deep scripts. Fonts also need crossorigin, or they download twice.
prefetch downloads a file at the lowest priority for a future navigation. It should never be used for something the current page needs. It costs data, so prefetch on intent and skip it under Save-Data.
Question 2
Your LCP image is a hero banner. Walk me through how you would make it load fast.
First, discovery: make sure it is an img in the server-rendered HTML, so the preload scanner finds it within milliseconds. If it is a CSS background or rendered by JavaScript, move it into the HTML, or add a preload with matching imagesrcset and imagesizes.
Second, priority: add fetchpriority="high", and make sure it is not loading="lazy". Images start at low priority and only get boosted after layout.
Third, connection and bytes: preconnect to the image CDN if it is a separate origin, and serve a correctly sized AVIF or WebP through srcset.
Finally, verify in a throttled waterfall that the request starts right after the HTML, and in the field that LCP resource load delay is small.
Question 3
When would you use a skeleton instead of a spinner?
A skeleton for content regions whose layout I know, like a feed, a card grid, or a profile header. It reserves the exact space, so nothing shifts when content arrives, and it shows the user what is coming.
A spinner or a busy state for actions, like a Save button, where the layout does not change, and a progress bar for long tasks with measurable progress.
In both cases I would wait about 200 to 300 ms before showing anything to avoid flicker, mark the region aria-busy, hide the shapes from screen readers, and make sure failures turn into an error with retry.
Question 4
Why is lazy-loading every image on the page a bad idea?
Lazy images wait until layout knows where they are and whether they are near the viewport. For images in the first screen, especially the LCP image, that adds delay to exactly the content you want first.
The rule is: eager for the first few images and fetchpriority="high" on the hero, loading="lazy" with width and height for everything below the fold.
Question 5
Design the loading strategy for a video watch page.
Now: server-render the title, the player frame with a poster image marked fetchpriority="high", and preconnect to the video CDN. The player script loads as a module; the video itself starts with a small first segment.
Soon: the comments and recommendations load when their sections near the viewport, each with a sized skeleton, using IntersectionObserver and import(). Recommendation thumbnails use loading="lazy".
Later: the share dialog, playlist editor, and live chat load on intent. When the user hovers a recommended video, prefetch its page data at low priority, unless Save-Data is on.
I would check the waterfall to see the poster and player script start first, and track LCP and time to first frame in the field.
Practice it
These design problems lean on loading strategies.
References
- Optimize resource loading with the Fetch Priority API (web.dev) Default priorities, fetchpriority use cases, and case studies.
- Don't fight the browser preload scanner (web.dev) What the scanner sees and the patterns that hide resources from it.
- rel=preload (MDN) The as values, crossorigin, and responsive image preloads.
- rel=modulepreload (MDN)
- Establish network connections early (web.dev) preconnect and dns-prefetch, with their costs.
- Browser-level image lazy loading for the web (web.dev) loading=lazy behaviour, distance thresholds, and pitfalls.
- Optimize Largest Contentful Paint (web.dev) LCP subparts and how load delay ties back to discovery.
- HTML Standard: link types The spec for preload, prefetch, preconnect, dns-prefetch, and modulepreload.
Read next
- The critical rendering pathEverything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Web fontsLoading custom fonts without invisible text or a jumping page: choosing
font-display, preloading the right files, subsetting them, and matching the fallback font's size. - Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.