CogniPrep imports icons from @hugeicons/core-free-icons. Installed, that one package is:

du -sh node_modules/.pnpm/@hugeicons+core-free-icons@4.2.2/node_modules/@hugeicons/core-free-icons # 90M (29M cjs, 60M esm, 664K types) ls node_modules/.pnpm/@hugeicons+core-free-icons@4.2.2/.../dist/esm | wc -l # 10957 (5,478 icon modules, plus a .map for each, plus the index) 

Nine percent of a 1.0 GB node_modules, for pictures. The two index files tell you how the package expects to be consumed:

File Bytes What is in it
dist/esm/index.js 617,945 5,473 lines of pure re-export, one module per icon
dist/cjs/index.js 6,508,748 every icon, inlined into one module

package.json says "sideEffects": false, each icon is its own ESM module, and each icon array is exported under several names. Picking one at random:

export { default as Drill, default as Wrench, default as Wrench01FreeIcons, default as Wrench01Icon, default as WrenchFreeIcons, default as WrenchIcon } from './Wrench01Icon.js'; 

That is a well-built barrel. It is still a barrel, and a bundler asked for one name out of it has to walk 617,945 bytes of re-exports to find out what it can drop. Which is why next.config.mjs carries this:

experimental: { optimizePackageImports: ['@hugeicons/core-free-icons', '@hugeicons/react', 'framer-motion'], } 

The comment next to it records what used to be there too: lucide-react, listed in this array in a project that has never had lucide-react as a dependency. Dead config in a performance option, which is the genre of mistake you only find by reading the file against package.json.

What the repository actually asks for

grep -rl "@hugeicons" --include="*.tsx" --include="*.ts" app components lib | wc -l # 132 

Parsing the import statements rather than counting files: 131 import statements, 167 distinct icon names. Four of those names (ImageIcon, RecordIcon, VideoIcon, WrenchIcon) have no module of their own, because they are aliases, which also means this codebase imports Wrench01Icon on one page and WrenchIcon on another and gets the same array both times.

167 out of 5,478 is 3% of the library. The question is what fraction of it a visitor downloads.

Measuring it rather than assuming it

Icon data in this library is an array of SVG primitives, so an icon that reaches the browser brings a recognisable string with it: the first 40 characters of its first path d attribute. That is enough to search for.

# every script the homepage references curl -s https://cogniprep.app/ > home.html for c in $(grep -o '/_next/static/chunks/[a-zA-Z0-9._-]*\.js' home.html | sort -u); do curl -s "https://cogniprep.app$c" -o "chunks/$(basename $c)" done 

Then, for each of the 167 names, read d out of the package's own module and look for it in the 11 chunks (586,603 bytes) and in the HTML (467,679 bytes):

Where the icon's path data appears Count
In the JavaScript and in the HTML 4
In the JavaScript only 5
In the HTML only 1
Nowhere on the page 153

Ten icons out of 5,478. Tree-shaking is doing its job, and the version of that sentence I can defend is: on this page, in this deployment, today, Wrench01Icon, SmartPhone01Icon, AiChat01Icon and 150 others are absent from every byte the browser received.

Totals, for scale: 7,049 bytes of path data across all homepage chunks, and 21,265 bytes of d attributes in the HTML, where SVG arrives as markup rather than as data.

The split in that table is the interesting part

Which column an icon lands in tells you which kind of component rendered it.

  • In the HTML only (TickDouble02Icon): rendered by a server component. The icon array was imported, read and turned into markup on the server, and never shipped as data at all. The cost is the bytes of the <svg> in the document, which also means it is in the RSC payload and therefore counted twice in the HTML.
  • In the JavaScript only (AlertCircleIcon, Cancel01Icon, Download01Icon, Loading03Icon, Tick02Icon): imported by a client component that was not painting them at load. A spinner, a dismiss button, a download affordance. The data ships so the component can render it later without a round trip.
  • In both (ArrowRight01Icon, ArrowUpRight01Icon, Mail01Icon, StarIcon): rendered by a client component, so the server sent the markup for the first paint and the client got the array for everything after.

None of those is a bug. But it does mean "we only use 167 icons" is the wrong unit, and even "ten icons on the homepage" is the wrong unit: the number that costs a visitor anything is the five in the second row, which are in the bundle precisely because they are not on screen yet.

What the 90 MB still costs

Nothing a visitor ever sees, and three things we see:

  1. Install and cold cache. 10,957 files in one directory of one package. On CI without a warm store that is the bulk of install time for a project whose own source is a fraction of it.
  2. The CJS index. dist/cjs/index.js is 6.5 MB in a single module. Anything resolving this package through CJS (a script, an older test runner, a tool that does not honour exports) loads the whole library as one file. The ESM path is what keeps the browser bill at ten icons.
  3. Two names for one icon. The alias exports are convenient and they hide duplication from code review: nothing warns you that WrenchIcon and Wrench01Icon are the same array, so a team can standardise on a name that does not exist as a module.

If you are carrying a large icon dependency, the check worth running once is this one, because it converts a worry into a number. Fetch your own chunks, search them for your own icons' path data, and count what came through.

See it

Everything above runs against the live site with curl and grep. The homepage is the page I measured: 11 scripts, 586,603 bytes, 7,049 bytes of icon path data. For a page with more icon-heavy chrome, try a provider page or the pricing page and repeat the sweep; the set that comes through changes with the page, which is the whole point. If you would rather see the icons than count them, the homepage carries 44 inline <svg> elements against 22 on the pricing page, 20 on the assessment centre page and 16 on the provider page, which is one grep -o '<svg' | wc -l per URL.