The Smallest Bundle: Top npm Packages Under 5KB 2026
Why Bundle Size Matters More Than You Think
The relationship between JavaScript bundle size and user experience is more direct than most developers account for in their dependency decisions. A 100KB increase in JavaScript payload doesn't just mean 100KB more to download — it means 100KB more to parse, compile, and execute. On a mid-range Android device, parsing 1MB of JavaScript takes roughly 1-4 seconds. That time comes directly out of your Largest Contentful Paint and First Input Delay metrics, which feed directly into Google's Core Web Vitals ranking signals.
The mobile network and device context matters enormously. Desktop developers often test on fast machines with fiber connections, where a 50KB difference in bundle size is imperceptible. But roughly 60% of global web traffic is mobile, and a significant portion of that is on 3G connections or slower. A 100KB bundle that takes 50ms to download on fiber takes over 2 seconds on a slow 3G connection. The packages you choose have a measurable impact on the experience of a meaningful segment of your users.
Tree-shaking complicates the calculus. A package that's 300KB installed might contribute only 5KB to your production bundle if it's tree-shakeable and you only use a few functions. date-fns is the canonical example: the package is 78KB minified, but a typical usage of three or four date functions contributes under 5KB. The key is knowing which packages are actually tree-shakeable and using a bundler that takes advantage of it. Both Vite and webpack 5 with usedExports: true do this correctly. The bundlephobia.com entry for any package shows both the full size and an estimate of the tree-shakeable reduction.
The supply chain dimension compounds the size argument. A zero-dependency package doesn't just keep your bundle small — it keeps your dependency graph clean, reduces the attack surface for supply chain attacks, and eliminates the risk of transitive dependency version conflicts. See most depended-on npm packages for the security dimension of this.
TL;DR
Bundle size is a first-class concern in 2026, and the best libraries have internalized it. Day.js (2KB) replacing Moment.js (300KB), Zustand (2KB) replacing Redux (7KB+middleware), nanoid (1KB) replacing uuid (14KB) — the pattern is clear: modern JavaScript libraries are getting smaller, not bigger. Here are the best sub-5KB packages by category, and how to check bundle size before installing anything.
Key Takeaways
- Day.js: 2KB — full date library, Moment.js-compatible API
- Zustand: 2KB — complete state management, no provider needed
- nanoid: 1KB — secure unique ID generation, replaces uuid (14KB)
- clsx: 0.5KB — className utility that shadcn/ui standardized
- Use bundlephobia.com — check size BEFORE installing, includes gzipped + minified
How to Check Bundle Size
# Before installing any package, check:
# 1. bundlephobia.com/package/package-name
# 2. Or CLI:
npx bundlephobia-cli package-name
# Key metrics:
# - Minified size: raw bytes after minification
# - Gzipped size: what your users actually download
# - Tree-shakeable: yes = you might only pay for what you import
# - Side effects: false = better tree-shaking
# Example check:
npx bundlephobia-cli moment # 300.3kB minified (72.1kB gzipped)
npx bundlephobia-cli dayjs # 6.6kB minified (2.7kB gzipped) ← same API
npx bundlephobia-cli date-fns # 78.4kB minified (but tree-shakeable, <5KB for typical usage)
Category: Dates & Time
The date library landscape has clarified significantly. Moment.js is definitively deprecated — its own documentation recommends migrating away. The two successor libraries are Day.js and date-fns, and they serve different use cases. Day.js is designed as a drop-in Moment.js replacement with a nearly identical API: if you want to minimize code changes during migration, Day.js is the easier path. date-fns uses a functional model with imported named functions — no chained methods, immutable operations, and better tree-shaking. For new projects that value TypeScript-first design, date-fns v3 is the stronger choice.
| Package | Gzipped | Notes |
|---|---|---|
| Day.js | 2.7KB | Moment.js-compatible API |
| date-fns (used functions) | ~2-8KB | Tree-shakeable, functional |
| Luxon | 23KB | Full Intl support, larger |
| Moment.js | 72KB | Do not use for new projects |
// Day.js — full date library in 2KB
import dayjs from 'dayjs';
// Most Moment.js code works with s/moment/dayjs/
dayjs('2026-03-08').add(7, 'day').format('YYYY-MM-DD')
// Plugins: dayjs/plugin/relativeTime, dayjs/plugin/timezone
Category: Unique IDs
For generating unique identifiers, the right choice depends on your format requirements. If you need standard UUID v4 format and are targeting Node 18+ or modern browsers, the built-in crypto.randomUUID() is always the best answer — zero bytes, no dependency, backed by the OS cryptographic random number generator. nanoid is the right choice when you need shorter IDs (the default 21-character URL-safe format) or when you need configurable length and alphabet. It's cryptographically secure, has zero dependencies, and is the most commonly adopted UUID alternative in the npm ecosystem.
| Package | Gzipped | Notes |
|---|---|---|
| nanoid | 1.1KB | URL-safe, cryptographically secure |
| uuid v4 (just v4) | ~3KB | Tree-shakeable in v9+ |
| cuid2 | 2KB | Monotonic, sortable |
Built-in crypto.randomUUID() | 0KB | Node 18+, Web Crypto API |
// nanoid — 1KB
import { nanoid } from 'nanoid';
const id = nanoid(); // 21 chars: V1StGXR8_Z5jdHi6B-myT
const shortId = nanoid(10); // Custom length: V1StGXR8_Z
// Built-in (Node 18+ / modern browsers) — 0KB!
const id = crypto.randomUUID(); // Standard UUID v4 format
// Use this unless you need shorter IDs (nanoid)
Category: Utilities
| Package | Gzipped | Notes |
|---|---|---|
| clsx | 0.5KB | className builder (shadcn/ui standard) |
| cn (tailwind-merge + clsx) | ~3KB | Tailwind className merging |
| klona | 1.1KB | Deep clone (faster than structuredClone for small objects) |
| mitt | 0.3KB | Tiny event emitter |
| ms | 0.5KB | Time string to ms: ms('2 days') |
| bytes | 0.8KB | Bytes to human: bytes(1024) → '1KB' |
// clsx — 0.5KB className builder
import { clsx } from 'clsx';
clsx('foo', { bar: true, baz: false }, ['qux']) // 'foo bar qux'
// With tailwind-merge (3KB together):
import { cn } from '@/lib/utils'; // shadcn/ui convention
cn('px-4 py-2', isLarge && 'px-8 py-4', className)
// mitt — 0.3KB event emitter
import mitt from 'mitt';
const emitter = mitt<{ userLogin: string; logout: void }>();
emitter.on('userLogin', (userId) => console.log(userId));
emitter.emit('userLogin', '123');
Category: State Management
The state management landscape has completed a significant consolidation in 2026. Redux's mental model — actions, reducers, middleware, selectors — made sense at a time when UI state was genuinely complex and needed strong conventions. For most modern React applications, that complexity is overkill. Zustand's 1.8KB store covers the large majority of use cases without providers, boilerplate, or a significant learning curve. Jotai's atomic model is the right choice when you need fine-grained subscriptions — components re-render only when the specific atom they read changes, which matters for performance in large trees. Valtio's proxy-based model is the most intuitive of the three for developers familiar with mutable state — you write state.count++ and subscribers update automatically.
| Package | Gzipped | Notes |
|---|---|---|
| Zustand | 1.8KB | Complete store, no boilerplate |
| Valtio | 2.5KB | Proxy-based, auto-subscription |
| Jotai (core) | 3.1KB | Atomic model, TypeScript-first |
| nanostores | 1.1KB | Framework-agnostic atoms |
| Nano Stores | 1.1KB | Best for multi-framework monorepos |
// Zustand — 1.8KB for complete state management
import { create } from 'zustand';
const useStore = create<{ count: number; increment: () => void }>((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
}));
// That's it. No Provider. No boilerplate. 1.8KB.
Category: Validation
Validation library bundle size is more nuanced than other categories because of tree-shaking. Zod's published bundle size of ~14KB shrinks significantly when tree-shaken in a modern bundler — a schema with a few string, number, and object validators typically contributes 4-6KB to a production bundle. Valibot takes the modular approach further: each validation rule is a separate import, so you literally only pay for the rules you use. In practice, Valibot's advantage is most significant for very small forms with a handful of rules. For complex schemas with many validators, Zod's broader ecosystem support (tRPC, Drizzle, React Hook Form integration, shadcn/ui) often outweighs Valibot's size advantage.
| Package | Gzipped | Notes |
|---|---|---|
| Valibot (tree-shaken) | ~1-3KB | Modular, pay per rule |
| Zod (tree-shaken) | ~4-6KB | Better DX, more ecosystem support |
| TypeBox | ~3KB | JSON Schema + TypeScript |
| ArkType | ~5KB | TypeScript syntax validation |
// Valibot — modular, only pay for what you import
import { object, string, email, minLength, parse } from 'valibot';
const schema = object({ email: string([email()]), name: string([minLength(2)]) });
// This imports: ~1.5KB (just these rules)
// vs importing full library: ~2.8KB
Category: HTTP Clients
The case for adding an HTTP client library in 2026 is narrower than it used to be. Node 18+ ships with native fetch, and modern browsers have had it since 2015. For the simple pattern of making a GET or POST request and parsing JSON, fetch with a thin wrapper is 0KB and handles the vast majority of use cases. The cases where a library adds clear value: automatic retries with exponential backoff (ky and wretch both handle this cleanly), request/response interceptors for logging or auth token injection, and typed response handling. If you need retries and timeout control without the complexity of axios, ky at 2.5KB is the minimum-viable HTTP client library worth adding.
| Package | Gzipped | Notes |
|---|---|---|
| Built-in fetch | 0KB | Node 18+, modern browsers |
| ky | 2.5KB | fetch-based, retries, timeout |
| wretch | 3.2KB | fetch wrapper, middleware pattern |
| ofetch | 3.8KB | Used by Nuxt, H3 |
// ky — 2.5KB with retries and timeout
import ky from 'ky';
const data = await ky.get('https://api.example.com/users', {
timeout: 5000,
retry: 3,
}).json();
// For most use cases, built-in fetch + a tiny wrapper is enough:
async function get<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${url}`);
return res.json();
}
// 0KB
Category: React Utilities
| Package | Gzipped | Notes |
|---|---|---|
| react-hot-toast | 3.5KB | Toast notifications |
| sonner | 3.2KB | More polished toasts |
| use-debounce | 0.9KB | Debounce hook |
| react-use | varies | Hook library (import individually) |
// sonner — 3.2KB toast library
import { Toaster, toast } from 'sonner';
// In layout: <Toaster />
toast.success('Saved!');
toast.error('Failed to save');
toast.promise(saveUser(), {
loading: 'Saving...',
success: 'Saved!',
error: 'Failed',
});
The Zero-Dependency Champions
Zero runtime dependencies is a meaningful quality signal that combines bundle size, security, and maintenance concerns into a single check. A package with zero dependencies has a bounded attack surface: the only code you're trusting is the code in that package. There are no transitive dependencies that can introduce a vulnerability, get abandoned, or break on a Node.js version upgrade.
The packages in this list that have zero runtime dependencies represent the gold standard of focused, self-contained utilities: nanoid, clsx, mitt, ms, dayjs, zustand, valtio, and klona. Each of these does one thing and does it with no external dependencies at all. React and similar peer dependencies don't count — peer dependencies are your responsibility to provide and not bundled into the package.
When evaluating a new package, checking the dependency count is one of the fastest quality signals. A utility package with 5+ transitive dependencies should make you pause and ask whether a simpler alternative exists. The npm registry shows dependent counts on every package page, and npm view package-name dependencies on the CLI shows you the direct dependencies in seconds.
# Packages with zero runtime dependencies (lowest supply chain risk):
# These are the safest to install:
# nanoid: 0 deps
# clsx: 0 deps
# mitt: 0 deps
# ms: 0 deps
# dayjs: 0 deps
# zustand: 0 deps (React is peer dep only)
# valtio: 0 deps (React is peer dep only)
# klona: 0 deps
# Check dependency count:
npm view package-name dependencies
# If empty/undefined: zero runtime dependencies ✅
Replacing Bloated Dependencies
# Common swaps that cut bundle size significantly:
# Date handling:
# moment (72KB) → dayjs (2.7KB) or date-fns (tree-shaken)
# ID generation:
# uuid full (14KB) → nanoid (1.1KB) or crypto.randomUUID()
# State:
# redux + react-redux + redux-toolkit (10KB+) → zustand (2KB) for simple cases
# Validation:
# yup (30KB) → zod (14KB) → valibot (tree-shaken ~2KB)
# Classnames:
# classnames (1KB but older) → clsx (0.5KB, same API)
# Event emitter:
# eventemitter3 (2KB) → mitt (0.3KB)
# Deep clone:
# lodash.clonedeep (5KB) → structuredClone (built-in) or klona (1.1KB)
# HTTP:
# axios (11KB) → ky (2.5KB) or native fetch (0KB)
Auditing Your Existing Bundle
If you have an existing project and want to understand where your JavaScript bytes are going, bundle analysis tools give you a visual breakdown of what's in your production bundle.
For Vite projects, the rollup-plugin-visualizer generates a treemap of your bundle contents — you can see exactly which packages are taking up space and whether they appear to be tree-shaken correctly:
npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({ open: true, template: 'treemap' })
],
};
Running vite build generates a stats.html file that opens automatically in your browser, showing a proportional treemap of every module in your bundle.
For Next.js, the @next/bundle-analyzer provides similar analysis. Enable it in your Next.js config:
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
Run with ANALYZE=true npm run build to get a visual breakdown of client and server bundles.
The most common findings in bundle audits: a package you thought was tree-shaken is actually being imported in full (usually because of a barrel export or import * as somewhere); a library you stopped using is still in the bundle because of a forgotten import; or a dependency of a dependency is enormous and could be replaced with a smaller alternative.
The packages covered in this guide emerged from exactly this kind of analysis — they're the libraries that developers reach for when they look at their bundle analyzer output and decide they can do better. Every KB you remove from your bundle is a KB your users don't have to download, parse, or execute. At scale, these choices compound into measurable differences in load time and user experience.
Compare bundle sizes and package health on PkgPulse.
See also: Bun vs Vite and AVA vs Jest, Unpacked Size Trends: Are npm Packages Getting Bigger?.
See the live comparison
View date fns vs. dayjs on PkgPulse →