Skip to main content

es-toolkit vs remeda vs lodash: Modern JavaScript Utilities (2026)

·PkgPulse Team

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/compat module — 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:

// ❌ 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

Featurees-toolkitremedalodash
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)
Performance2-3x fasterComparableBaseline
Pipe/compose✅ (lazy)✅ (_.chain)
Data-last API
Lazy evaluation⚠️ (_.chain only)
lodash compat layerN/A
Function count~150~200~300
Weekly downloads~2M~500K~25M

When to Use Each

Choose es-toolkit if:

  • Migrating from lodash — es-toolkit/compat makes 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-es or 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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.