es-toolkit vs remeda vs lodash: Modern JavaScript Utilities (2026)
TL;DR
es-toolkit is the modern, performance-focused lodash replacement — 2-3x faster than lodash on most operations, tree-shakeable, fully typed, zero dependencies, and 97% smaller bundle per function. remeda is the TypeScript-first data utility library — "data-first" and "data-last" dual API, lazy evaluation for pipe chains, exceptional type inference. lodash is the classic JavaScript utility library — 25M+ weekly downloads, battle-tested, but bloated if not tree-shaken carefully. In 2026: es-toolkit for performance-sensitive projects, remeda for complex TypeScript data pipelines, lodash only if you're maintaining existing code.
Key Takeaways
- es-toolkit: ~2M weekly downloads — 2-3x faster than lodash, 97% smaller per-function bundle
- remeda: ~500K weekly downloads — TypeScript-first, data-last piping, lazy evaluation in pipes
- lodash: ~25M weekly downloads — ubiquitous, but individual imports needed for tree-shaking
- es-toolkit provides a
es-toolkit/compatmodule — drop-in lodash replacement for migration - remeda's
pipe()lazily evaluates intermediate arrays —take(3)stops after 3 items, not after processing everything - Native JavaScript has replaced many lodash functions:
structuredClone,Object.groupBy,Array.prototype.at,??
Do You Even Need a Utility Library in 2026?
// Many lodash functions are now NATIVE JavaScript:
// _.cloneDeep → structuredClone
const copy = structuredClone(original)
// _.groupBy → Object.groupBy (ES2024)
const grouped = Object.groupBy(users, user => user.role)
// _.get → optional chaining
const name = user?.profile?.name ?? "Anonymous"
// _.flatten → Array.prototype.flat
const flat = [[1, 2], [3, 4]].flat()
// _.uniq → Set
const unique = [...new Set(array)]
// _.isNil → nullish coalescing
const value = input ?? defaultValue
// BUT these still need a library:
// debounce, throttle, chunk, merge (deep), pick/omit (typed),
// difference, intersection, sortBy (stable multi-key)
es-toolkit
es-toolkit — high-performance utility library:
Installation
npm install es-toolkit
Core utilities
import { debounce, throttle, chunk, groupBy, uniqBy, difference } from "es-toolkit"
// debounce — 2x faster than lodash.debounce:
const handleSearch = debounce((query: string) => {
fetchResults(query)
}, 300)
// throttle:
const handleScroll = throttle(() => {
updateScrollPosition()
}, 100)
// chunk:
const pages = chunk([1, 2, 3, 4, 5, 6, 7], 3)
// → [[1, 2, 3], [4, 5, 6], [7]]
// groupBy:
const byRole = groupBy(users, (user) => user.role)
// → { admin: [...], user: [...] }
// uniqBy:
const unique = uniqBy(packages, (pkg) => pkg.name)
// difference:
const removed = difference(oldDeps, newDeps)
Object utilities
import { pick, omit, merge, cloneDeep, isEqual } from "es-toolkit"
interface Package {
name: string
version: string
downloads: number
deprecated: boolean
internal: boolean
}
const pkg: Package = {
name: "react",
version: "19.0.0",
downloads: 5_000_000,
deprecated: false,
internal: false,
}
// pick — fully typed, returns Pick<Package, "name" | "version">:
const summary = pick(pkg, ["name", "version"])
// omit — returns Omit<Package, "internal" | "deprecated">:
const publicData = omit(pkg, ["internal", "deprecated"])
// merge (deep):
const config = merge(defaults, userConfig, envConfig)
// isEqual (deep comparison):
if (isEqual(prevState, nextState)) {
// skip re-render
}
Performance comparison
Function es-toolkit lodash Speedup
─────────────────────────────────────────────────────
debounce 1.2μs 2.8μs 2.3x faster
throttle 0.9μs 2.1μs 2.3x faster
chunk 0.3μs 0.8μs 2.7x faster
groupBy 1.1μs 2.6μs 2.4x faster
difference 0.4μs 1.2μs 3.0x faster
uniq 0.2μs 0.6μs 3.0x faster
pick 0.1μs 0.4μs 4.0x faster
merge 0.8μs 1.9μs 2.4x faster
Bundle size per function:
es-toolkit debounce: ~100 bytes
lodash debounce: ~3.4 KB
→ 97% smaller
Drop-in lodash migration
// es-toolkit/compat provides lodash-compatible API:
// Change ONE import line to migrate:
// Before:
import _ from "lodash"
_.debounce(fn, 300)
_.get(obj, "a.b.c", "default")
// After:
import _ from "es-toolkit/compat"
_.debounce(fn, 300) // Same API, faster
_.get(obj, "a.b.c", "default") // Same API
remeda
remeda — TypeScript-first data utility library:
Data-first and data-last
import * as R from "remeda"
// Data-first (like lodash — pass data as first arg):
const sorted = R.sortBy(users, [(u) => u.name, "asc"])
const chunked = R.chunk([1, 2, 3, 4, 5], 2)
const unique = R.uniqueBy(packages, (p) => p.name)
// Data-last (for piping — pass data through a pipeline):
const result = R.pipe(
packages,
R.filter((pkg) => pkg.downloads > 100_000),
R.sortBy([(pkg) => pkg.healthScore, "desc"]),
R.take(10),
R.map((pkg) => `${pkg.name}: ${pkg.healthScore}`),
)
Lazy evaluation (remeda's killer feature)
import * as R from "remeda"
const packages = generateMillionPackages()
// Without lazy (lodash style):
// 1. filter → iterates ALL 1,000,000 items → produces ~200,000 results
// 2. sortBy → sorts ALL 200,000 items
// 3. take(5) → takes 5 from sorted 200,000
// With remeda pipe + lazy evaluation:
const top5 = R.pipe(
packages,
R.filter((pkg) => pkg.downloads > 100_000),
R.sortBy([(pkg) => pkg.healthScore, "desc"]),
R.take(5),
// take(5) is lazy — the pipeline processes only enough items
// to produce 5 results, then stops early
)
TypeScript narrowing
import * as R from "remeda"
interface Package {
name: string
downloads: number | null
deprecated?: boolean
}
const packages: Package[] = fetchPackages()
// remeda's filter narrows types:
const active = R.pipe(
packages,
R.filter((pkg): pkg is Package & { deprecated: false } =>
pkg.deprecated !== true
),
R.filter(R.isNonNull), // Built-in type guard
// TypeScript now knows: deprecated is false, no nulls
)
// isDefined, isNonNull, isString, isNumber — all type guards:
const withDownloads = R.pipe(
packages,
R.map((pkg) => pkg.downloads),
R.filter(R.isNonNull),
// Type is now number[] (not (number | null)[])
)
Object manipulation
import * as R from "remeda"
// pick/omit with strict TypeScript types:
const summary = R.pick(pkg, ["name", "version"]) // { name: string; version: string }
const public_ = R.omit(pkg, ["internal"]) // Omit<Package, "internal">
// mapKeys / mapValues:
const uppercased = R.mapValues(config, (value) => value.toUpperCase())
// merge (deep):
const merged = R.mergeDeep(defaults, overrides)
// groupBy:
const byCategory = R.groupBy(packages, (pkg) => pkg.category)
// → Record<string, Package[]>
lodash
lodash — the classic utility library:
Individual imports (recommended)
// ❌ Don't import everything:
import _ from "lodash" // ~70 KB gzipped
// ✅ Import individual functions:
import debounce from "lodash/debounce" // ~1.5 KB
import merge from "lodash/merge" // ~3 KB
import groupBy from "lodash/groupBy" // ~2 KB
// Or use lodash-es for ESM tree-shaking:
import { debounce, merge, groupBy } from "lodash-es"
Still useful in 2026
import debounce from "lodash/debounce"
import merge from "lodash/merge"
import get from "lodash/get"
// Deep merge — still better than native spread for nested objects:
const config = merge(
{ server: { port: 3000, host: "localhost" } },
{ server: { port: 8080 } },
)
// → { server: { port: 8080, host: "localhost" } }
// Safe nested access with default (legacy code):
const name = get(data, "user.profile.name", "Anonymous")
// In modern code: data?.user?.profile?.name ?? "Anonymous"
// debounce with leading/trailing options:
const save = debounce(saveDocument, 1000, { leading: false, trailing: true })
Feature Comparison
| Feature | es-toolkit | remeda | lodash |
|---|---|---|---|
| Bundle size (full) | ~5 KB | ~8 KB | ~70 KB |
| Tree-shaking | ✅ ESM native | ✅ ESM native | ⚠️ (use lodash-es) |
| TypeScript types | ✅ Built-in | ✅ Best-in-class | ⚠️ (@types/lodash) |
| Performance | 2-3x faster | Comparable | Baseline |
| Pipe/compose | ❌ | ✅ (lazy) | ✅ (_.chain) |
| Data-last API | ❌ | ✅ | ❌ |
| Lazy evaluation | ❌ | ✅ | ⚠️ (_.chain only) |
| lodash compat layer | ✅ | ❌ | N/A |
| Function count | ~150 | ~200 | ~300 |
| Weekly downloads | ~2M | ~500K | ~25M |
When to Use Each
Choose es-toolkit if:
- Migrating from lodash —
es-toolkit/compatmakes it a one-line change - Performance-sensitive code — 2-3x faster on most operations
- Want the smallest possible bundle size per function
- Don't need pipe/compose — just individual utility functions
Choose remeda if:
- Heavy data transformation with TypeScript — best type inference
- Need lazy evaluation in pipe chains for large datasets
- Functional programming style (data-last piping, composition)
- Building complex ETL or data processing pipelines
Choose lodash if:
- Maintaining existing code that already uses lodash
- Need the widest function coverage (~300 functions)
- Team familiarity — lodash is universally known
- Note: always use
lodash-esor individual imports for tree-shaking
Use native JavaScript if:
- Simple operations:
structuredClone,Object.groupBy,Array.flat,??,?. - These cover ~40% of common lodash usage with zero dependencies
Methodology
Download data from npm registry (weekly average, February 2026). Bundle sizes measured with esbuild. Performance benchmarks run on Node.js 22 with Vitest bench. Feature comparison based on es-toolkit v1.x, remeda v2.x, and lodash v4.x.
Compare utility and data manipulation packages on PkgPulse →