Lazy loading icons
How to lazy load icons with native attributes and IntersectionObserver, reserve space to avoid layout shift, and decide which icons to load right away.
5 min read
A page that lists 300 tokens doesn't need 300 logo requests before anyone scrolls. Lazy loading defers images until they're about to come into view, which saves data on mobile connections and gets the visible part of the page ready sooner. For icons, two HTML attributes do most of the work. The details that matter are reserving space and knowing which icons not to defer.
The native attributes
<img
src="/icons/token/usdc.svg"
alt="USDC"
width="32"
height="32"
loading="lazy"
decoding="async"
>
loading="lazy" tells the browser to wait until the image is near the viewport before fetching it. Browsers start loading a little before the image scrolls into view, at a distance they choose themselves, which can depend on the connection. It's supported in all current major browsers, works without JavaScript, and also applies to <iframe>. A browser that doesn't recognize the attribute simply loads the image right away.
decoding="async" hints that the browser may decode the image without holding up the rest of the content. The values are sync, async and auto (the default). For small SVG icons the effect is usually minor, and browsers are free to ignore the hint, but it does no harm.
Neither attribute exists for CSS. Icons drawn as a background-image or a mask-image load when the element is rendered, wherever it is on the page. To defer those, use IntersectionObserver, described below.
Reserve the space
A lazy image that arrives without reserved space pushes content around. That's layout shift, measured by Cumulative Layout Shift (CLS), one of the Core Web Vitals, where 0.1 or less counts as good. Icons are easy to protect because their size is fixed. Always give them dimensions, either as attributes or in CSS:
.token-row img {
width: 24px;
height: 24px;
flex-shrink: 0;
}
flex-shrink: 0 stops a long token name in a flex row from squeezing the icon. If the same icon appears at 24 px in a list and 48 px in a header, set the dimensions to the displayed size in each place; an SVG scales to match without losing quality.
It also helps to give icons a neutral placeholder, such as a circle in your surface color behind the image, so a slow connection shows a calm grid instead of empty holes. The same placeholder can double as the fallback for tokens without a logo; see fallback icons for unknown tokens.
Don't lazy load what's visible first
Lazy loading the icons on the first screen makes them slower, not faster: the browser has to lay out the page to discover they're in view before it fetches them. Load those normally, by leaving out loading or setting loading="eager".
The difficulty is that the server doesn't know the viewport size. Choose a count based on your layout at common sizes. In a token list with 56 px rows, a 900 px tall viewport shows about 15 rows, so loading the first 12 to 16 icons eagerly is reasonable. In an icon grid with 8 columns, the first two or three rows, 16 to 24 icons, cover most screens.
{tokens.map((token, i) => (
<li key={token.id} className="token-row">
<img
src={token.logo}
alt=""
width={24}
height={24}
loading={i < 16 ? 'eager' : 'lazy'}
decoding="async"
/>
<span>{token.name}</span>
</li>
))}
The alt text is empty here because the token's name sits right next to the icon. Guessing too high costs a few extra small requests; guessing too low causes visible pop-in on the first screen, so err on the high side. A small icon is rarely the page's Largest Contentful Paint element, so fetchpriority="high" is usually better spent on a hero image than on icons.
Custom loading with IntersectionObserver
The native attribute covers <img>. You need IntersectionObserver when:
- you fetch SVG markup to render it inline, so CSS can style it;
- the icon is a CSS background or mask;
- you want to control the loading distance yourself;
- you want to defer a whole component, not only an image.
import { useEffect, useRef, useState } from 'react'
export function useInView<T extends Element>(rootMargin = '200px') {
const ref = useRef<T>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
const el = ref.current
if (el === null || inView) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true)
observer.disconnect()
}
},
{ rootMargin },
)
observer.observe(el)
return () => observer.disconnect()
}, [inView, rootMargin])
return { ref, inView }
}
export function MaskIcon({ src, label }: { src: string; label: string }) {
const { ref, inView } = useInView<HTMLSpanElement>()
return (
<span
ref={ref}
role="img"
aria-label={label}
className="mask-icon"
style={inView ? { maskImage: `url(${src})` } : undefined}
/>
)
}
The .mask-icon class supplies the fixed size, background-color: currentColor and mask-size: contain. The mask image is only set once the element nears the viewport, so the file isn't requested before then. Disconnecting after the first intersection matters, because each icon only needs to load once. One observer per icon is fine for a few hundred icons; for thousands, share a single observer and keep a map from element to callback.
Inside a scrolling container, such as a token selector in a modal, pass the container as the observer's root. Otherwise rootMargin extends the viewport, not the container, and icons just below the container's visible area won't start loading early.
Native or custom
loading="lazy" |
IntersectionObserver | |
|---|---|---|
| Works without JavaScript | Yes | No |
| Loading distance | Chosen by the browser | You set rootMargin |
| CSS backgrounds and masks | No | Yes |
| Inline SVG fetched as text | No | Yes |
| Code to maintain | None | A hook or helper |
Start with the native attribute on every <img> icon below the first screen, and reach for IntersectionObserver only for the cases it can't handle. To confirm it's working, open the Network panel in DevTools, filter to images, reload, then scroll and watch requests appear as you go. If every icon loads on page load, check that the attribute actually reached the rendered HTML.
Lazy loading decides when the first request happens; caching decides whether the second visit needs a request at all. The guide on caching SVG files covers the headers, and rendering thousands of icons covers grids where loading is only part of the problem.