Lazy Loading SVGs: When and How to Do It
When lazy loading SVG icons actually helps performance — and four concrete techniques: native img lazy, dynamic imports, IntersectionObserver, and SVG sprites.
Lazy loading SVGs is a nuanced topic because SVGs are delivered in three different ways — inline HTML, external files, and bundled JS — and the correct approach depends on which you’re using. Applying the wrong technique to the wrong delivery method wastes engineering time and can even hurt performance.
The decision: should you lazy load this SVG?
Individual UI icons (chevrons, action buttons, nav items) are typically under 1KB optimized. Lazy loading them adds more overhead than it saves. Focus lazy loading effort where it actually moves metrics:
| Scenario | Lazy load? | Why |
|---|---|---|
| Single nav icon (< 1KB) | No | Overhead exceeds savings |
| 10–50 inline icons on one page | No | Combined < 30KB; not worth the complexity |
| 100+ icons in a grid/picker | Yes | DOM + JS parse cost is significant |
| SVG illustration > 30KB | Yes | Substantial asset size |
| Icons below fold in accordion/tab | Yes | Defers parse until user needs it |
| Icons in a dynamically opened modal | Yes | No need to parse before modal opens |
If all SVGs on the page total less than 50KB after SVGO optimization, lazy loading them adds complexity for no measurable gain. Run Lighthouse first — optimize with SVGO before reaching for lazy loading.
Technique 1: Native loading="lazy" (external SVG files)
If you load SVGs as <img> tags pointing to external .svg files, the native loading attribute works exactly as it does for JPEGs:
<!-- Deferred until the icon approaches the viewport (~1 screen length) -->
<img
src="/icons/complex-illustration.svg"
alt="Architecture diagram"
loading="lazy"
width="800"
height="400"
/>
When to use: Large decorative SVG illustrations served as separate files — hero artwork, diagram assets, marketing illustrations.
Limitation: You can’t style internal SVG paths with CSS when loading icons as <img>. fill: currentColor, :hover path selectors, and CSS variable theming don’t work.
<!-- ✅ Works: loading="lazy" on large external SVG -->
<img src="/artwork/feature-diagram.svg" loading="lazy" width="600" height="400" alt="Feature overview" />
<!-- ❌ Won't benefit from lazy loading AND can't be CSS-themed -->
<img src="/icons/settings.svg" loading="lazy" width="20" height="20" alt="" />
Technique 2: React dynamic imports + IntersectionObserver
For inline SVG React components that are large or many, code-split them with React.lazy + Suspense, combined with an IntersectionObserver to defer the JS fetch until the component is about to enter the viewport.
// hooks/useLazyComponent.ts
import { useState, useEffect, useRef } from 'react';
export function useLazyLoad() {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' } // Start loading 200px before entering viewport
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, visible };
}
// components/BelowFoldIllustration.tsx
import { lazy, Suspense } from 'react';
import { useLazyLoad } from '@/hooks/useLazyLoad';
const HeavyIllustration = lazy(() => import('./illustrations/HeavyIllustration'));
export function BelowFoldSection() {
const { ref, visible } = useLazyLoad();
return (
<div ref={ref} className="min-h-[300px]">
{visible ? (
<Suspense fallback={<div className="w-full h-64 bg-gray-100 animate-pulse rounded-xl" />}>
<HeavyIllustration />
</Suspense>
) : (
<div className="w-full h-64 bg-gray-100 rounded-xl" />
)}
</div>
);
}
The rootMargin: '200px' starts fetching the component before it enters the viewport — the user never sees a flash of the skeleton if their connection is fast.
In Next.js, use next/dynamic instead of React.lazy — it integrates with the App Router’s streaming and has a built-in loading prop. For the IntersectionObserver trigger, wrap the dynamic component in the same useLazyLoad hook above.
Technique 3: SVG Sprite with async loading
For pages displaying 100+ icons (icon search pages, pickers, galleries), creating 100+ dynamic imports creates 100+ network requests. The correct solution is an SVG sprite served as a single external file:
<!-- The sprite file is fetched once, asynchronously -->
<svg class="icon" width="24" height="24" aria-hidden="true">
<use href="/sprites/ui-icons.svg#search"></use>
</svg>
The browser fetches /sprites/ui-icons.svg as a single non-blocking request. While it downloads, the rest of the page renders. All icons in the sprite share that one request.
Generating the sprite in Node.js:
npm install --save-dev svg-sprite
// scripts/build-sprite.mjs
import SVGSpriter from 'svg-sprite';
import { glob } from 'glob';
import fs from 'fs/promises';
import path from 'path';
const spriter = new SVGSpriter({
dest: 'public/sprites',
mode: { symbol: { sprite: 'ui-icons.svg' } },
});
const files = await glob('assets/icons/*.svg');
for (const file of files) {
const content = await fs.readFile(file, 'utf8');
spriter.add(path.resolve(file), path.basename(file), content);
}
const { result } = await spriter.compileAsync();
for (const [, resource] of Object.entries(result.symbol)) {
await fs.writeFile(resource.path, resource.contents);
}
Technique 4: Astro client:visible directive
In Astro, any React/Vue/Svelte island that contains icons can be deferred to load only when scrolled into view — zero code required:
---
import IconHeavySection from '@/components/IconHeavySection';
---
<!-- This entire React island (including its icons) only hydrates when visible -->
<IconHeavySection client:visible />
Under the hood Astro uses IntersectionObserver — the same pattern as Technique 2, but declarative. For Svelte islands:
<SvelteIconGrid client:visible />
This is the simplest way to lazy load icon-heavy components in Astro and should be the default for all heavy below-fold content.
Technique 5: Virtualized icon grids
For an icon picker or search result grid that renders hundreds of SVG icons at once, virtualization is more effective than lazy loading — it keeps the DOM small regardless of scroll position.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function IconGrid({ icons }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: icons.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // icon card height
overscan: 5,
});
return (
<div ref={parentRef} className="h-[500px] overflow-auto">
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(item => (
<div key={item.key} style={{ transform: `translateY(${item.start}px)` }}>
<IconCard icon={icons[item.index]} />
</div>
))}
</div>
</div>
);
}
At any scroll position, only ~10–15 icons exist in the DOM — regardless of whether the total grid has 500 or 5,000.
Comparison table
| Technique | Use case | JS required | CLS risk |
|---|---|---|---|
<img loading="lazy"> | Large external SVG files | None | Low (need width/height) |
| React.lazy + IntersectionObserver | Below-fold React icon components | Yes (small hook) | Low (use skeleton) |
SVG sprite + <use> | 100+ icon grids | None | Low |
Astro client:visible | Astro islands with icons | Built-in | Low |
| Virtualization | Scrollable icon pickers | Yes (@tanstack/virtual) | None |
What not to lazy load
- Icons in the viewport on page load (LCP region) — loading=“lazy” on these delays your LCP score
- Tiny icons in buttons and nav — overhead exceeds gain
- Icons in form validation states — they need to render instantly on user input