← back to blog
Performance2026-05-18· IconSearch Team

Icon Bundle Size in React — Why Your Icons Are Bloating Your App (And How to Fix It)

Real benchmark data: react-icons adds 81KB for 50 icons. Lucide adds 5KB. This guide explains why, how to audit your icon bundle, and how to fix tree-shaking for every major icon library in 2026.

#bundle size#tree shaking#react#performance#lucide#react-icons#vite#nextjs
// This article was researched and edited by the IconSearch team. Content may be AI-assisted and is reviewed for accuracy.

A developer on a recent Reddit thread posted a question that gets asked every week: "Why is my React app so slow to load? I only added an icon library." They had installed react-icons and imported five icons. Their bundle had grown by over 80KB.

This is not a rare mistake. It is the default outcome if you install an icon library without understanding how tree-shaking works — and more importantly, when it does not work. Icon libraries are deceptively dangerous from a bundle size perspective because they are visually trivial (tiny SVG shapes) but architecturally significant (every major library handles module bundling differently).

This guide covers exactly what is happening, with real benchmark numbers, and how to fix it for every library you are likely to be using in 2026.

The Real Numbers — Icon Bundle Size Benchmarks

Before explaining why, here is what the actual data looks like. These measurements are from a Next.js 15 project with Turbopack, measuring the gzip bundle delta for importing 50, 100, and 200 icons from each library.

Library50 Icons100 Icons200 Icons
Lucide React+5.16 KB+8.58 KB+15.72 KB
Heroicons+3.49 KB+9.23 KB+19.09 KB
Iconify+11.53 KB+17.67 KB+26.13 KB
Phosphor React+33.91 KB+48.68 KB+102.27 KB
Radix Icons+63.13 KB+63.37 KB+63.86 KB
react-icons+81.07 KB+81.34 KB+81.82 KB

The Radix Icons and react-icons rows explain the Reddit developer's confusion. Both libraries add roughly the same amount of bundle weight whether you import 50 icons or 200 — because they are not tree-shaking properly. You are paying the full library cost regardless of how many icons you use.

Lucide and Heroicons scale linearly. More icons you use, slightly more bundle. That is correct behavior.

Why Tree-Shaking Fails for react-icons

Tree-shaking is your bundler's ability to remove unused code from the final build. For it to work, three conditions must be true. The library must use ES module exports (not CommonJS). The imports must be specific named imports (not wildcard or default imports). The bundler must be configured to treat the library as side-effect free.

react-icons fails the first condition. Despite appearances, the package ships its internal structure as CommonJS, not ESM. Modern bundlers like Webpack and Turbopack cannot reliably tree-shake CommonJS modules because CommonJS exports are determined at runtime, not statically at build time.

This is why the benchmark shows 81KB for 50 icons and 81KB for 200 icons. The bundler cannot remove individual icons — it includes the entire sub-pack (all Font Awesome icons, all Material icons, etc.) as a single chunk.

// This looks like a named import — it is not tree-shakeable in react-icons
import { FaHome, FaBell, FaCog } from 'react-icons/fa'

// The entire /fa pack comes along regardless of what you import
// ~80KB minimum regardless of usage

The fix for react-icons is to import from the individual icon file paths:

// ✅ Import from the direct file — bypasses the CJS barrel export
import { FaHome } from 'react-icons/fa/index.esm'

// Or use the @react-icons/all-files package which is ESM-first
import { FaHome } from '@react-icons/all-files/fa/FaHome'

The @react-icons/all-files package is the ESM-native version of react-icons. It takes longer to install (it is a large package) but each icon is a standalone ESM module that tree-shakes correctly.

Why Radix Icons Has a Flat Bundle Curve

Radix Icons shows 63KB for 50 icons and 63KB for 200 icons — nearly identical. This is the hallmark of a single-entry barrel file that the bundler cannot split.

The reason is architectural. Radix Icons ships a single index.js file that exports all icons together. When you import one icon, the bundler resolves the import to that single file and must include the whole thing.

// This resolves to a single bundle entry — all icons come with it
import { HomeIcon } from '@radix-ui/react-icons'

Radix Icons is a small, specialized library (15x15px icons for Radix UI components) and the 63KB cost is relatively fixed. If you only need a handful of Radix-specific icons for a Radix UI component library, this is acceptable. If you are using it for general-purpose icons, the cost is unjustified.

The Lucide + Vite Dev Mode Problem

Lucide React is one of the best-performing libraries in production, but it has a well-documented issue in Vite development mode that causes developers to falsely believe their bundle is huge.

Vite does not tree-shake in development mode. In dev, Vite uses native ES modules and serves files individually, which means it imports the entire lucide-react package even when you only use five icons. Build times can be slow and dev server startup can take seconds longer than expected.

A developer measured this and found importing from the default lucide-react path loaded 1,637 modules in dev mode. Switching to direct icon imports reduced it to 35 modules — a 98% reduction.

The fix is a Vite alias that points lucide-react to the individual icon files:

// vite.config.ts
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'

export default defineConfig({
  resolve: {
    alias: {
      'lucide-react/icons': fileURLToPath(
        new URL('./node_modules/lucide-react/dist/esm/icons', import.meta.url)
      ),
    },
  },
})

Then create a type declaration file so TypeScript understands the alias:

// src/lucide.d.ts
declare module 'lucide-react/icons/*' {
  import { LucideProps } from 'lucide-react'
  import { FC } from 'react'
  const Icon: FC<LucideProps>
  export default Icon
}

After adding the alias, update your imports to use the direct path:

// Before — loads all 1,600+ lucide modules in Vite dev mode
import { Home, Bell, Settings } from 'lucide-react'

// After — loads only the 3 modules you actually need
import Home from 'lucide-react/icons/home'
import Bell from 'lucide-react/icons/bell'
import Settings from 'lucide-react/icons/settings'

Build time drops from 5.6 seconds to under 1 second in projects with many icon imports. Production bundle size is unaffected — Vite tree-shakes correctly in production regardless — but the dev experience improvement is significant.

How to Audit Your Current Icon Bundle

Before optimizing, you need to know what you are actually shipping. These two tools cover all major build setups.

For Next.js projects:

npm install @next/bundle-analyzer --save-dev
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})
ANALYZE=true npm run build

This opens an interactive treemap in your browser where you can see exactly which icon library is contributing what size. Look for large rectangles labeled with your icon library names — those are the problem areas.

For Vite projects:

npm install rollup-plugin-visualizer --save-dev
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [
    visualizer({
      open: true,
      filename: 'bundle-stats.html',
      gzipSize: true,
    }),
  ],
}
npm run build

The visualizer generates an stats.html file that opens automatically. Gzip size is the number that matters most — that is what users actually download.

The Phosphor Icons Situation

Phosphor React's numbers in the benchmark look alarming — 102KB for 200 icons compared to Lucide's 16KB. This needs context.

Phosphor offers six weight variants per icon (thin, light, regular, bold, fill, duotone). Each variant is a separately exported component. When tree-shaking calculates what to include, it needs to include the component logic for the weight system, which carries a higher baseline cost than single-variant libraries.

The practical impact depends heavily on how you use it. If you use one or two weights consistently, the overhead is manageable. If you use all six weights across many icons, the cost multiplies.

// Using one weight — reasonable bundle cost
import { House } from '@phosphor-icons/react'
<House size={24} />

// Using multiple weights — cost adds up quickly
import { House, HouseBold, HouseFill, HouseDuotone } from '@phosphor-icons/react'

If you need Phosphor specifically for duotone icons on marketing pages or onboarding flows, a practical pattern is to lazy-load the Phosphor components so they do not appear in the initial bundle:

import { lazy, Suspense } from 'react'

const PhosphorIcon = lazy(() =>
  import('@phosphor-icons/react').then(mod => ({ default: mod.RocketLaunch }))
)

function OnboardingStep() {
  return (
    <Suspense fallback={<div className="w-12 h-12" />}>
      <PhosphorIcon size={48} weight="duotone" />
    </Suspense>
  )
}

This defers Phosphor's weight entirely to a separate chunk that only loads when the onboarding component renders.

The Right Import Pattern for Every Library

The single most important habit for icon bundle size is avoiding barrel imports. A barrel import is any import from a package's root that the bundler might resolve to a single large file.

Here are the correct import patterns for each major library:

//  Lucide React — named imports from the package root work correctly
import { Home, Bell, Settings } from 'lucide-react'
// Or direct imports for Vite dev performance:
import Home from 'lucide-react/icons/home'

//  Heroicons — import from the specific style subdirectory
import { HomeIcon } from '@heroicons/react/24/outline'
import { HomeIcon } from '@heroicons/react/24/solid'
//  Never import from the package root
import { HomeIcon } from '@heroicons/react' // loads everything

//  Tabler Icons — named imports work fine
import { IconHome, IconBell } from '@tabler/icons-react'

//  Phosphor Icons — named imports work fine
import { House, Bell } from '@phosphor-icons/react'

//  react-icons — use @react-icons/all-files for ESM tree-shaking
import { FaHome } from '@react-icons/all-files/fa/FaHome'
//  Avoid the default react-icons package for performance-critical apps
import { FaHome } from 'react-icons/fa' // ships entire FA pack

//  Font Awesome — always import individual icons, never the whole set
import { faHome } from '@fortawesome/free-solid-svg-icons'
//  This includes every solid icon — destroys tree-shaking
import { fas } from '@fortawesome/free-solid-svg-icons'
library.add(fas)

Setting a Bundle Size Budget for Icons

The best way to prevent icon bloat from creeping back in is to automate enforcement. Most CI pipelines can be configured to fail when icon bundle size exceeds a threshold.

For Next.js, the built-in size limits feature works:

// next.config.js
module.exports = {
  experimental: {
    bundlePagesRouterDependencies: true,
  },
  // Fail build if any page exceeds these limits
  onDemandEntries: {
    maxInactiveAge: 25 * 1000,
  },
}

For more precise control, bundlewatch lets you set per-package limits:

npm install --save-dev bundlewatch
// package.json
{
  "bundlewatch": {
    "files": [
      {
        "path": ".next/static/chunks/*.js",
        "maxSize": "100kb"
      }
    ]
  }
}

Add bundlewatch to your CI step and it will fail the build with a clear error message if icon changes push the bundle over your limit.

Practical Recommendations

For new projects in 2026, the decision is straightforward. Use Lucide React as your primary library — it has the best balance of icon count, bundle efficiency, and developer experience. Use the Vite alias fix if you are on Vite and notice slow dev server startup. Use the direct path imports if you are importing more than 20 icons.

If you are on an existing project using react-icons, migrating to @react-icons/all-files is the fastest improvement you can make. It is the same API, the same icon names, just ESM-first. The switch typically takes under an hour and can reduce your bundle by 50KB or more.

If you need Phosphor for its weight variants, lazy-load it on routes where it appears rather than including it in the main bundle. The visual quality is worth the overhead — just keep it out of the critical path.

If you need Font Awesome specifically for brand icons, import individual icons only. Never use library.add(fas) in production. The convenience of global registration is not worth including 2,000 solid icons in your main bundle.

Frequently Asked Questions

← back to all posts