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
TypeScript Integration and Type Inference
One of the sharpest distinctions among these three libraries lies in how deeply they integrate with the TypeScript type system. Lodash ships types via @types/lodash, a community-maintained package that lags behind TypeScript's evolving capabilities and often produces overly broad return types. For example, _.pick(obj, ["a", "b"]) returns a partial of the original type but loses literal key information in many scenarios. es-toolkit ships its own TypeScript types and nails the pick and omit signatures — the return type is exactly Pick<T, K> or Omit<T, K>. Remeda pushes this furthest: its pipe() function correctly narrows types through each transformation step, so if you filter with a type guard the downstream operations know the narrower type. For teams running strict mode with noUncheckedIndexedAccess, remeda's type inference catches more potential runtime errors at compile time than either of its competitors.
Migration Path from Lodash
Migrating from lodash is a common need in 2026, and each library handles it differently. es-toolkit's es-toolkit/compat subpath provides a nearly drop-in lodash replacement — you change one import line and the API stays identical, including subtle behavioral edge cases that the es-toolkit team has carefully matched. The compat module is the lowest-risk migration strategy because it lets you verify behavior parity without rewriting call sites. Remeda requires more active migration since its API is intentionally different (data-last, functional style), but the investment pays off for codebases doing complex data transformations. If you want to audit lodash usage before migrating, run npx depcheck or npx jscodeshift with a codemod to identify which lodash functions you actually use — many projects use fewer than ten distinct functions, making es-toolkit a natural fit.
Performance Nuances in Production
The 2-3x performance advantage of es-toolkit over lodash matters most in hot code paths: event handlers that fire hundreds of times per second, data processing pipelines processing thousands of records, and React rendering functions called during rapid state changes. For typical CRUD operations — a few groupBy calls during a server response — the absolute difference is microseconds, not milliseconds. Where the numbers become meaningful is in server-side rendering contexts where a single page render invokes utility functions thousands of times per request, or in data ingestion pipelines where you process hundreds of thousands of npm registry records. Remeda's lazy evaluation is particularly powerful for large arrays where early termination prevents unnecessary iteration — a pipeline calling take(5) after filter and sortBy stops processing once five results accumulate, rather than sorting the entire filtered array.
Bundle Size and Tree-Shaking in Practice
The 97% smaller per-function bundle claim for es-toolkit deserves context. Lodash's debounce function is ~3.4 KB because it pulls in lodash's internal utility layer — once you import two or three lodash functions, the shared internals are loaded once and subsequent functions add relatively little. With es-toolkit, each function is truly independent with zero shared runtime, so the bundle savings compound as you use more functions rather than plateauing. For browser-targeted code on slow connections, this matters: shaving 20-30 KB from an already-heavy JavaScript bundle meaningfully improves first load performance. Remeda's ESM exports tree-shake well with modern bundlers like esbuild or Rollup, though its functional style means you often import the whole R namespace in practice — pipe-heavy code can end up pulling in more of remeda than you might expect.
Ecosystem and Community Adoption Context
Lodash's 25M weekly download dominance reflects how deeply it's embedded in the JavaScript ecosystem's dependency graph — most projects that use lodash do so transitively through other packages, not as a direct dependency. This means even if you never explicitly import lodash, it's likely in your node_modules. es-toolkit's rapid growth to 2M downloads reflects direct adoption: developers who have consciously chosen it over lodash. Remeda's more modest 500K downloads reflects its narrower target audience — TypeScript developers doing data pipeline work, not general utility users. The UnJS ecosystem (Nuxt, Nitro, h3) has adopted es-toolkit as its preferred utility library, which has driven discovery. The React ecosystem and most meta-frameworks remain neutral — they neither depend on nor recommend a particular utility library, leaving the choice to application developers.
Self-Hosting and Edge Considerations
Unlike databases or search engines, utility libraries have no self-hosting concerns — they're pure JavaScript code that runs wherever your application runs. However, es-toolkit and remeda's ESM-only approach means they require a bundler or modern runtime. Deno, Bun, and Node.js 22+ import ESM natively, so direct use without a build step works. Cloudflare Workers and Vercel Edge Functions handle ESM fine. The one edge case is server-side CommonJS projects — legacy Express apps or Jest configs that use require() — where lodash's dual CJS/ESM distribution is still an advantage. For these, the es-toolkit/compat module ships both formats.
Practical Decision Framework for Teams
When a team is evaluating utility libraries for a new project, the decision is typically faster than it appears. If the project has significant lodash usage to migrate, start with es-toolkit/compat and remove the compat layer function by function over time as you gain confidence. If the project is TypeScript-heavy with complex data pipelines — transforming API responses, building aggregations from database results, processing event streams — invest the afternoon to learn remeda's pipe syntax because the type inference and lazy evaluation pay for themselves. If the project is brownfield with existing lodash and no migration budget, simply switch to lodash-es and add individual function imports via the eslint-plugin-lodash import rule to ensure tree-shaking happens correctly. The wrong answer is importing all of lodash as a default import in a browser bundle without tree-shaking — that remains a significant performance mistake in 2026 regardless of which library you choose.
Compare utility and data manipulation packages on PkgPulse →
See also: Lodash vs Underscore and Lodash vs Ramda, acorn vs @babel/parser vs espree.