Skip to main content

How Package Popularity Correlates with Bundle Size

·PkgPulse Team
0

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.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.