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.

Amit Yadav
Amit Yadav

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:

ScenarioLazy load?Why
Single nav icon (< 1KB)NoOverhead exceeds savings
10–50 inline icons on one pageNoCombined < 30KB; not worth the complexity
100+ icons in a grid/pickerYesDOM + JS parse cost is significant
SVG illustration > 30KBYesSubstantial asset size
Icons below fold in accordion/tabYesDefers parse until user needs it
Icons in a dynamically opened modalYesNo need to parse before modal opens
Rule of thumb

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.

next/dynamic vs React.lazy

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

TechniqueUse caseJS requiredCLS risk
<img loading="lazy">Large external SVG filesNoneLow (need width/height)
React.lazy + IntersectionObserverBelow-fold React icon componentsYes (small hook)Low (use skeleton)
SVG sprite + <use>100+ icon gridsNoneLow
Astro client:visibleAstro islands with iconsBuilt-inLow
VirtualizationScrollable icon pickersYes (@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
Share this post