Skip to main content

How Package Popularity Correlates with Bundle Size

·PkgPulse Team

TL;DR

Popular packages are not systematically larger — the correlation between downloads and bundle size is weak. The most downloaded packages span the full size spectrum: lodash (71KB), react (6.4KB), and semver (0.1KB) are all in the top 50 by downloads. What actually predicts size: problem domain complexity, historical API baggage, and whether the package was designed for tree-shaking. The most important insight: new popular packages tend to be smaller than old popular packages.

Key Takeaways

  • No strong correlation between downloads and bundle size
  • Old popular packages are larger (pre-ESM, pre-tree-shaking era)
  • New popular packages are smaller (designed for modern bundlers)
  • The "popular = bloated" perception is because famous packages like Moment.js are old
  • Bundlephobia score is more predictive of whether you should use a package than download count

The Size Spectrum at the Top

Top packages by downloads with their gzipped bundle sizes:

< 5KB (tiny):
→ semver: 0.1KB (95M dependents)
→ ms: 0.5KB (15M/week)
→ clsx: 0.5KB (5M/week)
→ nanoid: 1.1KB (8M/week)
→ dayjs: 2.7KB (8M/week)
→ zustand: 1.8KB (8M/week)
→ react: 6.4KB (50M/week — surprisingly small!)

5-50KB (medium):
→ tailwindcss (runtime): 7.5KB (45M/week)
→ @tanstack/react-query: 13KB (10M/week)
→ zod: 14KB (14M/week)
→ axios: 11KB (35M/week)

50-150KB (large):
→ lodash: 71KB (28M/week) — but tree-shakeable
→ date-fns: 81KB full (14M/week) — very tree-shakeable
→ framer-motion: 47KB (5M/week)

> 150KB (very large):
→ moment: 300KB (14M/week) — avoid for new projects
→ three.js: 600KB (5M/week) — 3D library, expected
→ chart.js: 62KB (5M/week)
→ firebase: 200KB+ (2M/week)

Packages published pre-2018 were designed in a different era:

1. CommonJS (not ESM): no tree-shaking possible
   → You installed lodash → you got ALL of lodash
   → Even if you only used _.map, you paid for _.flatten too
   → Solution: lodash became tree-shakeable in lodash-es

2. Monolithic APIs: all functions in one bundle
   → Moment.js: all locales included (you can tree-shake but it's not obvious)
   → jQuery: entire DOM library even if you need 5%

3. No bundler-aware packaging:
   → "sideEffects: false" didn't exist
   → Packages couldn't tell bundlers "this is tree-shakeable"

4. Feature creep over time:
   → Good package → users request features → size grows
   → v1: 10KB → v5: 50KB (same core problem, 5x code)
   → Moment.js: started small, added locales, plugins, timezone → 300KB

New packages (post-2020) learn from this:
→ Day.js launched knowing it would replace Moment.js: stayed at 2.7KB
→ Zustand launched knowing it would replace Redux: designed for tree-shaking
→ Zod: functional design enables tree-shaking
→ Valibot: went further — explicitly modular, no central bundle

Packages launched 2020-2026 that became popular while staying small:

Type        Package             Launch  Gzipped  Downloads
Dates:      dayjs               2018    2.7KB    8M/week
State:      zustand             2020    1.8KB    8M/week
IDs:        nanoid              2017    1.1KB    8M/week
Classes:    clsx                2018    0.5KB    5M/week
HTTP:       ky                  2018    2.5KB    2M/week
Toasts:     sonner              2023    3.2KB    800K/week
Router:     wouter              2018    2.4KB    500K/week
Events:     mitt                2016    0.3KB    2M/week
Storage:    unstorage           2022    ~5KB     600K/week

Pattern: Modern popular packages are tiny by design.
Package authors in 2020+ know:
1. bundlephobia.com will show their size prominently
2. Users penalize large bundles
3. Tailwind's success showed "utility-first = small" wins
4. React ecosystem has bundle size culture

Compare to equivalent old packages:
dates:    moment    → 300KB  (vs dayjs 2.7KB)
state:    redux+RM  → 40KB   (vs zustand 1.8KB)
events:   eventemitter3 → 2KB (vs mitt 0.3KB)

Some packages are popular AND large — for good reasons:

three.js (600KB, 5M/week):
→ 3D rendering library — it IS the 3D engine
→ If you need WebGL, you need this
→ Can be tree-shaken to just the modules you use
→ There's no "small" 3D rendering library

framer-motion (47KB, 5M/week):
→ Animation is complex — physics simulation, spring calculations
→ Tree-shakeable: basic animation = 20KB
→ Accepted cost for quality of DX

firebase SDK (200KB+, 2M/week):
→ Includes: auth, firestore, storage, analytics
→ Modular since v9: import only what you use
→ Using just auth: ~30KB

echarts (900KB!, 3M/week):
→ Full-featured data visualization
→ Tree-shakeable but complex
→ For simple charts: Chart.js (62KB) or Recharts (52KB) are better

These are acceptable large bundles because:
→ The functionality justifies the size
→ There's no genuinely smaller alternative with same capabilities
→ They've invested in tree-shaking so partial usage is smaller

Checking the Size-to-Value Ratio

# Before installing, calculate size-to-value:

# Step 1: Check bundle size
npx bundlephobia-cli package-name

# Step 2: Ask: what does this give me?
# Is this problem solvable without the package?
# - Is there a built-in Node.js/Web API?
# - Is there a smaller package with same functionality?

# Step 3: Check tree-shakeability
npm view package-name --json | jq '.sideEffects'
# false → tree-shakeable (you pay for what you use)
# true/undefined → might pay for everything

# Step 4: Check what you'd actually use
# bundlephobia shows full size; your actual usage is often smaller

# Example calculation:
# date-fns: 81KB full, but:
# import { format, parseISO, addDays } from 'date-fns';
# Actual bundled: ~3KB (just those 3 functions)
# Worth it? Yes. 3KB for ergonomic date handling.

# vs moment: 72KB — not tree-shakeable in same way
# Worth it for new projects? No. Use dayjs (2.7KB) instead.

# The rule:
# Large non-tree-shakeable packages: justify with "no good alternative"
# Large tree-shakeable packages: calculate actual usage cost
# Small packages: rarely need to justify

The Bundle Budget Approach

// Set a budget for your JavaScript bundle:
// "First meaningful paint" target: < 200KB JS total

// Track your budget in CI:
// next.config.ts
import { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    // Next.js built-in size monitoring
  },
};

// Or: vite-plugin-analyze
// vite.config.ts
import analyze from 'rollup-plugin-analyzer';
export default defineConfig({
  plugins: [
    ...(process.env.ANALYZE ? [analyze({ summaryOnly: true })] : []),
  ],
});
// Run: ANALYZE=true vite build

// Budget targets (approximate):
// Content sites: < 50KB JS
// Marketing sites: < 100KB JS
// SaaS applications: < 300KB JS (first load)
// Complex dashboards: 500KB JS (acceptable for authenticated apps)

// When you exceed your budget:
// 1. Find the biggest chunks (bundle analyzer)
// 2. Check for duplicate packages (two versions of same lib)
// 3. Lazy-load heavy components: import('./HeavyChart')
// 4. Replace large packages with smaller alternatives

Compare bundle sizes and popularity data for npm packages at PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.