Unpacked Size Trends: Are npm Packages Getting Bigger?
TL;DR
npm packages are getting bigger on disk but smaller in browser bundles. The unpacked size of top packages has grown ~15% over 5 years — mostly from TypeScript declaration files (.d.ts) and source maps added for better debugging. But gzipped bundle size for the same packages has stayed flat or shrunk, thanks to better tree-shaking and build tools. The metric that matters for users: gzipped bundle, not unpacked npm tarball.
Key Takeaways
- Unpacked size grew ~15% over 5 years — but mostly from .d.ts and sourcemaps
- Gzipped browser bundle is flat or shrinking — what users actually download
- TypeScript declarations can double the unpacked size without affecting bundle
- Tree shaking means installed size ≠ bundled size for modern packages
- Outliers: Moment.js (shrinking slowly), Tailwind v4 (smaller), Prisma (growing from features)
Why Package Size Metrics Are Confusing
Developers frequently confuse three distinct size measurements when evaluating npm packages. Understanding which metric matters for which concern is the prerequisite for making good decisions.
Unpacked size is what you see in node_modules/. It's the total bytes of all files in the package tarball after extraction — JavaScript source, TypeScript declarations, source maps, documentation, LICENSE files, and any binary assets. This number matters for: supply chain auditing (larger packages are more attack surface), disk space planning in constrained environments, and CI pipeline performance when node_modules must be cached.
Bundled size (minified + gzipped) is what users' browsers download when your application loads. This is the number that determines page load performance. Modern bundlers tree-shake unused code, compress aggressively, and split code into chunks that load on demand. A package with 80KB unpacked size might contribute 3KB to your actual bundle if only two utility functions are used.
Tree-shaken size is the most accurate bundle impact measure — it's what a specific import pattern contributes to your bundle. import { format } from 'date-fns' pulls ~2KB; import * as dateFns from 'date-fns' pulls ~80KB. The same package, different imports, 40x size difference.
The disconnect between unpacked size and bundle impact is why developers sometimes make poor tradeoffs: rejecting a useful package because it looks "heavy" on npm's package page, while keeping a legacy package that looks lighter but doesn't tree-shake.
Understanding the Size Metrics
# Three different "sizes" you'll encounter:
# 1. Unpacked size (npm tarball extracted)
npm view package-name --json | jq '."dist.unpackedSize"'
# What you see in node_modules/
# Includes: JS files, .d.ts types, source maps, README, LICENSE
# 2. Bundled size (what your build includes)
npx bundlephobia-cli package-name
# Shows: minified + gzipped
# After tree-shaking: much smaller than unpacked
# 3. Tree-shaken size (what users download)
# Depends on your import patterns:
import { format } from 'date-fns'; // ~1KB for just format()
import * as dateFns from 'date-fns'; // ~80KB for entire library
# The right metric:
# → Evaluating supply chain: unpacked size (what's on your server)
# → Performance impact: gzipped bundle size
# → Security exposure: dependency count (not size)
What's Actually Growing: A Breakdown
The ~15% growth in average unpacked size across the npm ecosystem is real but attributable to specific causes rather than general code bloat:
TypeScript declarations (.d.ts files): The TypeScript adoption curve has driven most packages to ship type declarations. A package that previously shipped only index.js and index.cjs now also ships index.d.ts with full type information. For a complex package with many exports, the declaration files can be larger than the runtime JavaScript. React's type declarations are a good example: @types/react at ~10MB is larger than the react runtime package itself. These files are never bundled — they're consumed by the TypeScript compiler during development.
Source maps (.map files): Source maps enable debugging minified code in browser DevTools by mapping minified variable names and line numbers back to original source. A source map file is typically 30-50% the size of the compiled JavaScript it describes. Production deployments usually configure build tools to generate sourcemaps separately (uploaded to Sentry or Datadog) rather than including them inline, so users don't download them. But they inflate the npm tarball.
Dual CJS + ESM packages: Modern packages ship both CommonJS (require()) and ES Module (import) versions. This doubles the JavaScript in the tarball. Bundlers pick the ESM version for tree-shaking benefits; Node.js picks the appropriate one based on context. Eventually, as CJS becomes less necessary, packages will drop the duplicate files and shrink. Until then, dual packages inflate unpacked sizes without affecting what end users download.
Size Growth Breakdown
What's making packages "bigger" on disk (unpacked):
1. TypeScript declaration files (.d.ts): +20-40% typical
→ React @types: each major version adds more precise types
→ Package ships types in /dist/*.d.ts
→ Not bundled by any tool — just type-checking artifacts
2. Source maps: +30-50% of JS size
→ .map files: enable debugging the original source in browser DevTools
→ Not bundled unless you explicitly configure it
→ Production sourcemaps: separate upload to Sentry/DataDog
3. CJS + ESM dual packages: 2x size
→ Modern packages ship both CommonJS (/dist/cjs/) and ESM (/dist/esm/)
→ You use one, the other sits there
→ Node tools slowly eliminating the need for CJS
4. Multiple build targets:
→ react: separate browser, server, profiling builds
→ Each needed for different environments
What's NOT making packages bigger:
→ Better tree-shaking actually removes unused code
→ Gzip compression catches repetitive code patterns
→ Smaller algorithmic choices (some libraries rewrote for smaller output)
Packages That Shrunk
# Tailwind CSS v4 — significant size reduction
# v3: 6.2MB unpacked (large CSS processing engine)
# v4: 1.9MB unpacked (rewrote in Rust, moved processing to build time)
# Browser bundle: near-zero runtime JS in v4
# Zod v4 (upcoming) — expected size reduction
# v3: 14KB gzipped
# v4 preview: ~6KB gzipped (rewrote internals for performance)
# date-fns v3
# v2: 81KB gzipped full import
# v3: Better tree-shaking, per-function imports documented clearly
# Average project usage: ~3-8KB (only imported functions)
# React 19
# Bundle size: slightly smaller than React 18
# Eliminated some compatibility code for old React patterns
# New hooks added but net size about the same
# esbuild: consistently tiny
# 0.19: 8.5MB unpacked (Go binary included)
# 0.21: 9.1MB (multiple architectures)
# The binary is large; the API package is tiny
Tailwind v4's size reduction deserves attention as a case study. The v3 engine was a Node.js JavaScript implementation that processed CSS at runtime during builds. Tailwind v4 rewrote the core in Rust and shifted the processing model — the build-time Rust binary processes CSS faster, produces smaller output, and the runtime JavaScript that ships to browsers is essentially zero. Users browsing a Tailwind v4 site don't download any Tailwind JavaScript at all.
This Rust rewrite pattern is becoming common for performance-critical build tools. esbuild, SWC, Turbopack, and Rolldown all use lower-level languages for performance. The installed size may be larger (because native binaries are bigger than JavaScript), but the output they produce is smaller and faster to generate.
Packages That Grew (For Good Reasons)
# Next.js
# next@12: ~75MB unpacked
# next@15: ~145MB unpacked (+93%)
# Why: Added App Router code, Edge runtime support, Turbopack integration
# More sophisticated build tooling, more bundled utilities
# Browser bundle impact: minimal — Next.js code doesn't ship to browser
# Prisma
# @prisma/client@4: ~2MB unpacked
# @prisma/client@5: ~4MB unpacked (+100%)
# Why: More database adapters, edge runtime support, TypeScript improvements
# Query engine: separate binary, ~30MB additional but downloaded separately
# TypeScript
# typescript@4: ~60MB unpacked
# typescript@5: ~85MB unpacked (+40%)
# Why: More built-in library definitions, better LSP support files
# None of this goes to production
# @types/node
# Growing with each Node.js version
# More built-in modules = more type declarations
# Only used in development, never bundled
The Next.js growth from 75MB to 145MB is alarming until you understand what it contains. Most of the growth is build-time infrastructure: Webpack/Turbopack internals, the App Router implementation, edge runtime adapters, and the TypeScript compilation pipeline. Application users never download any of this. When you measure Next.js's impact on your users by running Lighthouse on a deployed Next.js app, the framework contribution is typically 30-60KB of JavaScript — not 145MB.
The distinction matters for infrastructure: CI pipelines that cache node_modules need proportionally more storage and cache transfer bandwidth as tools grow. On a machine with 100 projects, larger dev dependencies consume proportionally more disk. These are real costs worth optimizing — just not by choosing smaller runtime libraries.
Checking Size Before Installing
# bundlephobia: web UI
# bundlephobia.com/package/package-name
# CLI equivalent:
npx bundlephobia-cli react # 6.4kB gzipped
npx bundlephobia-cli moment # 72.1kB gzipped — avoid
npx bundlephobia-cli date-fns # 86.3kB (full), but tree-shakeable
npx bundlephobia-cli dayjs # 2.7kB gzipped
# Check multiple alternatives at once:
npx bundlephobia-cli zustand jotai valtio
# Package Size
# zustand 1.8kB
# jotai 3.1kB
# valtio 2.5kB
# Check tree-shakeability:
npx bundlephobia-cli date-fns --tree-shaking
# Package's sideEffects field in package.json:
npm view date-fns --json | jq '.sideEffects'
# false → fully tree-shakeable
# true or absent → may not tree-shake cleanly
The Dual Package Pattern (CJS + ESM)
# Modern packages ship both CJS and ESM in the same tarball
# Doubles the unpacked size, halves the user's compatibility issues
# package.json pattern:
{
"main": "./dist/index.cjs", # CommonJS (Node.js require())
"module": "./dist/index.mjs", # ESM (import)
"exports": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
}
# Your bundler picks the right one:
# Vite: uses .mjs automatically (tree-shakeable)
# Next.js: uses .mjs for client, .cjs for server
# Node.js 18+: uses .mjs with type: "module"
# The future: ESM-only packages are emerging
# Node.js 22+: all package types supported without workarounds
# Eventually CJS versions won't be needed → packages will shrink
Size Optimization Checklist
# Reduce your bundle size:
# 1. Check your current bundle
npm install -D @next/bundle-analyzer # Next.js
# Or: npx vite-bundle-visualizer # Vite
# Shows: which packages are largest in YOUR bundle
# 2. Find heavy packages
npx cost-of-modules --no-install
# Shows: size contribution of each dep in node_modules
# 3. Replace obvious offenders
# moment (72KB) → dayjs (2.7KB)
# lodash (71KB full) → individual functions or built-ins
# uuid (14KB) → nanoid (1KB) or crypto.randomUUID()
# axios (11KB) → ky (2.5KB) or native fetch
# 4. Verify tree-shaking works
# Import specifically, not the whole package:
import { format, parseISO } from 'date-fns'; # Good: only ~2KB
import * as dateFns from 'date-fns'; # Bad: ~80KB
# 5. Check vendor chunks
# In Next.js: vendor bundle > 200KB = investigate
# In Vite: use import() for lazy loading large components
# 6. Image optimization (often bigger win than package size)
# next/image, Astro's built-in optimizer, or sharp
# An unoptimized hero image can be 10x larger than your entire JS bundle
What Actually Matters: A Decision Framework
For most projects, the right priority order for package evaluation is:
- Functionality fit — does it do what you need?
- Maintenance status — is it actively maintained?
- Security history — has it had CVEs?
- Gzipped bundle size — what will users download?
- Unpacked size — relevant for CI caching, supply chain auditing
Optimizing for unpacked size before addressing gzipped bundle size is a misallocation of effort. A 100KB package that tree-shakes to 5KB in your bundle is far better than a 10KB package that can't tree-shake and bloats your bundle with 10KB regardless of what you use.
The most impactful size optimizations are rarely package substitutions. They're code splitting (loading code when needed rather than on initial page load), image optimization, and choosing to server-render more content. A developer spending hours reducing JavaScript bundle size by 20KB while the site loads a 500KB hero image unoptimized has their priorities backwards.
Tools for Measuring Package Size in Practice
Understanding package size in the abstract is useful; measuring your application's actual bundle contribution is essential. The tools available in 2026 make both measurements straightforward.
Bundlephobia and PkgPulse
Bundlephobia (bundlephobia.com) shows the minified and gzipped bundle size of any npm package, along with its dependency tree visualization. It's the standard first check when evaluating a new package dependency. The "export costs" view breaks down how much each named export contributes — useful for verifying that a library supports tree-shaking before committing.
PkgPulse's size data pairs bundle size with download trends and maintenance signals. A package with a large bundle size but strong tree-shaking and active maintenance is a different risk profile from a package with the same bundle size but declining downloads and infrequent releases. Both data points matter for dependency decisions.
@next/bundle-analyzer
For Next.js applications, @next/bundle-analyzer generates an interactive treemap of your production bundle. Each rectangle represents a module, sized proportionally to its contribution. This visualization reveals which dependencies dominate your bundle — often third-party packages that looked small on bundlephobia but have significant transitive dependencies that didn't tree-shake.
size-limit
size-limit is a CI tool that fails your build when bundle size exceeds a configurable threshold. This is the package-size equivalent of code coverage thresholds — it prevents regressions by making size changes visible in pull requests. The GitHub Actions integration shows a bundle size diff comment on PRs, making the cost of each dependency addition explicit before merge.
The Right Optimization Order
Bundle size optimization priorities, in order of typical impact:
-
Code splitting — loading JavaScript when it's needed rather than on initial page load. Next.js does this automatically for page-level code. Dynamic imports (
await import('./heavy-component')) apply it at the component level. -
Image optimization — a single unoptimized hero image at 2MB outweighs 100KB of JavaScript optimization.
next/imagehandles this automatically; other frameworks need explicit configuration. -
Remove dead dependencies — run
npx depcheckto identify packages in your package.json that aren't imported anywhere. These are safe to remove and have no tree-shaking complexity. -
Replace heavy dependencies — swapping
lodashforlodash-es(or individual function imports) is a classic optimization.momenttodate-fnsorday.jsis another. These substitutions require testing but often reduce bundle size by 50-80KB. -
Analyze unpacked size relative to bundle contribution — a package with 5MB unpacked size and 5KB bundle contribution (after tree-shaking) is not a problem. A package with 500KB unpacked size and 300KB bundle contribution (no tree-shaking) is worth replacing.
Treating unpacked size as a proxy for bundle impact conflates two metrics that often diverge significantly. The development workflow concern (how much disk space and install time does this add?) is separate from the production concern (how much does this add to what users download?). Both matter, but they matter in different contexts.
The Right Questions to Ask About Package Size
When evaluating whether a package's size is a problem, three questions produce more useful answers than looking at unpacked size alone.
First: does the package tree-shake? A 200KB package that tree-shakes down to 8KB in your specific usage (you import two utility functions out of a library of fifty) has a real bundle contribution of 8KB. bundlephobia shows this under "export costs" — it calculates the gzipped size of each named export in isolation. Lodash-es, for example, has an unpacked size of 1.4MB but allows importing individual functions at 1-5KB each. The unpacked size is nearly irrelevant if tree-shaking works correctly.
Second: is the size justified by the functionality? Some size is earned. A PDF generation library that weighs 800KB unpacked but handles the full PDF specification — forms, annotations, encryption, font embedding — delivers functionality that would take thousands of lines of code to implement from scratch. Comparing its size to a simpler library that can't do any of those things is not a fair comparison.
Third: who is the dependency's user? A dependency that's only used in your build pipeline (webpack, esbuild, TypeScript itself) contributes to install time but not to what users download. A dependency that ships to the browser contributes to page load time. These are different concerns. typescript at 7MB unpacked is a development-only dependency that never reaches your users. react at 6MB unpacked generates 48KB gzipped in the browser — a number that actually matters for performance.
The trend toward packages growing in unpacked size reflects several legitimate developments: TypeScript type definitions added to packages (compile-time only), source maps included for better debugging, and test files included in the published package (an anti-pattern, but common). None of these affect what users download. A package that doubled in unpacked size from 200KB to 400KB because it added TypeScript definitions and source maps is not actually larger for users.
The packages whose growth does matter are those with new runtime dependencies — importing heavier modules, adding polyfills, or including previously tree-shakeable code in ways that prevent tree-shaking. These changes show up in bundle analysis, not just unpacked size metrics. That's why PkgPulse shows both unpacked size and bundle size side by side — the gap between them tells the story of tree-shaking effectiveness.
Compare bundle sizes and package health at PkgPulse. Related: Best JavaScript Package Managers 2026, Best TypeScript Build Tools 2026, and Best Monorepo Tools 2026.
See the live comparison
View vite vs. webpack on PkgPulse →