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)
Why Old Popular Packages Are Larger
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
The New Popular = Small Pattern
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)
The Exceptions (Popular and Large)
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.
See the live comparison
View date fns vs. dayjs on PkgPulse →