Lodash adds about 70KB minified, ES2026 covers most utility uses with zero dependencies
Object.groupBy and Map.groupBy replace _.groupBy and ship in every modern runtime
Array.fromAsync, toReversed, toSorted, and toSpliced cover async iteration and immutable transforms
structuredClone, Object.hasOwn, Array#findLast, Promise.withResolvers, and Iterator.from finish the swap
Lodash still wins for deep equality, debounce, throttle, and iteratee shorthand
I pulled Lodash out of a Next.js project last month and saved 71KB on the client bundle. The replacements were already shipping in V8, Bun, and Node 22. I just had not noticed.
This is not a "Lodash is dead" piece. Lodash still does a few things native JS does not, and I will be honest about which ones. But for most teams the dependency is doing 5 percent of the work it used to do.
Here is the side by side.
Object.groupBy and Map.groupBy
The one everyone reaches for. Group an array by a key, get back an object of arrays.
// Lodash
import groupBy from 'lodash/groupBy'
const users = [
{ id: 1, role: 'admin' },
{ id: 2, role: 'user' },
{ id: 3, role: 'admin' },
]
const grouped = groupBy(users, 'role')
// { admin: [...], user: [...] }
// Native (ES2026, but landed in Node 21+ and all modern browsers)
const grouped = Object.groupBy(users, (u) => u.role)
// { admin: [...], user: [...] }
Object.groupBy takes a callback instead of a string key, which is more flexible. You can group by computed values without any helpers.
If you need an actual Map with non-string keys (objects, dates, numbers as keys, whatever), use Map.groupBy. Lodash never had a clean answer for that.
const byDay = Map.groupBy(orders, (o) => o.createdAt.toDateString())
// Map { 'Mon May 05 2026' => [...], ... }
I migrated four call sites in our admin dashboard this way. Bundle dropped, code got shorter. No semantic shift.
One thing to know: Object.groupBy returns a null-prototype object, so it has no inherited methods. That means no .hasOwnProperty, no surprises from prototype pollution. If you were paranoid about that with the old Lodash version, you can stop being paranoid now. Lodash returned a regular object and you had to use Object.hasOwn anyway. Native already did the safer thing for you.
The TC39 spec lets the callback return any primitive that gets coerced to a string for the object key. For Map.groupBy the keys can be anything: objects, dates, NaN, symbols. I have grouped orders by their literal Date object before, and that is genuinely useful when you do not want to lose the date precision by converting to a string.
Array.fromAsync for async iterables
This one is the quiet star. Async iterables are everywhere now: streams, paginated APIs, server-sent events, async generators. Lodash never really got there.
// Old pattern with Lodash + manual loop
import map from 'lodash/map'
async function collectStream(stream) {
const items = []
for await (const chunk of stream) {
items.push(chunk)
}
return map(items, (i) => i.toString())
}
// Native
const items = await Array.fromAsync(stream, (chunk) => chunk.toString())
Array.fromAsync is Array.from with await baked in. It accepts async iterables, sync iterables, and array-likes, and you get a mapping function for free.
I use it for paginated GraphQL where each page is an async generator yield. Twelve lines of collection code became one line. The first time I tested it on a 50-page paginated query I thought I had broken something because it returned so fast. I had not. It just worked.
For the deeper context on Vercel cron jobs that ingest paginated APIs, see The 5 Vercel Cron Jobs That Keep My Studio Running.
Immutable transforms: toReversed, toSorted, toSpliced
For years, immutable updates meant [...arr].sort() or [...arr].reverse(). The spread copy is fine for small arrays. It is wasteful for large ones, and it reads like ceremony.
// Old immutable pattern
const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name))
const reversed = [...users].reverse()
const removed = [...users.slice(0, i), ...users.slice(i + 1)]
// Native ES2023+ (in every runtime now)
const sorted = users.toSorted((a, b) => a.name.localeCompare(b.name))
const reversed = users.toReversed()
const removed = users.toSpliced(i, 1)
Three things I like about these:
They never mutate. You cannot mess this up.
They read like the imperative versions, just safer.
toSplicedfinally gives you immutable deletion that is not a giantfilteror two-slice mess.
Lodash had _.reverse (mutating) and _.sortBy (immutable). The native versions are clearer about intent. sort mutates, toSorted does not. Done.
If your React useState reducer was doing setItems([...items].sort(fn)), swap it for setItems(items.toSorted(fn)). Same result, half the noise.
The performance is genuinely the same as the spread-then-mutate pattern, by the way. Engines are smart enough to skip the intermediate copy when they can prove the source is not aliased elsewhere. I benchmarked it on a 10k-item array and the difference was within noise.
structuredClone for deep copies
_.cloneDeep was probably the most-used Lodash function in any React codebase. It handled circular references, dates, maps, sets, regex, you name it.
structuredClone does all of that and ships in every modern runtime, including Node 17+, Deno, Bun, and every browser since 2022.
// Lodash
import cloneDeep from 'lodash/cloneDeep'
const copy = cloneDeep(state)
// Native
const copy = structuredClone(state)
It handles the things you would expect (Map, Set, Date, RegExp, typed arrays, binary blobs) and the things you forget about (circular refs, sparse arrays). It will throw on functions, DOM nodes, and class instances with non-serializable internals, which is fair.
Caveat: structuredClone is slower than a hand-rolled JSON.parse(JSON.stringify(x)) for plain JSON. If you know your data is plain JSON, the JSON round-trip is faster. If you do not know, structuredClone is the right default.
The smaller wins
Five more swaps that each remove a lodash import:
// Object.hasOwn (replaces _.has for own properties)
if (Object.hasOwn(obj, 'key')) { ... }
// Array#findLast (replaces _.findLast)
const lastError = logs.findLast((l) => l.level === 'error')
// Promise.withResolvers (replaces the "deferred" pattern, no Lodash equivalent
// but a common utility in shared codebases)
const { promise, resolve, reject } = Promise.withResolvers()
// Iterator.from + helpers (replaces _.chain for lazy pipelines)
const result = Iterator.from(largeArray)
.filter((x) => x.active)
.map((x) => x.id)
.take(10)
.toArray()
// Numeric separators in literals (replaces nothing, just nice)
const TIMEOUT_MS = 30_000
Iterator.from is the one I think most teams underuse. It gives you lazy iteration with a fluent API. If you have ever reached for _.chain, this is the modern answer. It does not allocate intermediate arrays. On a 100k-row dataset I benchmarked last week, the iterator pipeline was about 4x faster than the array equivalent because it short-circuits after take(10).
We covered related immutable patterns in TypeScript Decorators Finally Shipped: What Changed in 2026, if you want to see how decorators compose with these.
What Lodash still wins
I want to be fair. Lodash earned its reputation. A few things native JS still does not match:
Deep equality. _.isEqual handles cyclic structures, NaN comparison, type coercion edge cases, and prototype walking. The native answer is "use a library or write 50 lines." If you need deep equality, Lodash is still 6KB well spent. Or use fast-deep-equal for a smaller alternative.
Debounce and throttle. _.debounce and _.throttle are the kind of code that everyone gets wrong on the first try. Cancellation, leading/trailing edges, max wait. Native has nothing. The Web Platform has a Scheduler.postTask proposal that is not there yet. For now, ship Lodash's two functions or copy them as standalone modules. (just-debounce-it is 600 bytes and does the job.)
Iteratee shorthand. _.map(users, 'name') is a string-as-callback pattern Lodash invented. Native requires users.map((u) => u.name). The arrow form is fine. Five extra characters. Some teams genuinely miss the shorthand. I do not, but I see why people do.
Functional composition. _.flow, _.curry, _.partial. If you write functional JS, Lodash/fp is still the cleanest option. Ramda is the alternative. Native pipe operator is still a proposal.
Bottom Line
The actual swap took me about two hours on a project with 38 Lodash imports. I deleted 31 of them outright. Six became three lines of native code. One (a _.debounce for a search input) stayed.
Bundle dropped 71KB minified, 24KB gzipped. Build time dropped because Webpack was doing less tree-shaking work. No tests broke. No runtime errors.
If your team is on Node 21+ or shipping to evergreen browsers, the math is in favor of pulling Lodash. You probably do not need most of it anymore. Keep _.isEqual and _.debounce if you use them, or replace those two with tiny single-purpose modules.
I am not going to pretend Lodash is bad. It carried JavaScript through a bad decade for the standard library. ES2026 is the language finally catching up.
If you are figuring out what else to swap or trim from your stack, Neon Database Branching Saved Me 200 EUR Every Month covers the same idea on the database side. And the studio playbook page has the rest of the stack I run on.
Top comments (0)