underscore review
Our clean install of Underscore 1.13.8 finished in 0.4 seconds and added 3 MB, yet a browser build still costs 21.7 KB minified or 7.4 KB gzipped when the whole package is imported. The library collects array, object, collection, and function helpers behind the familiar `_` API. Current JavaScript now covers much of its original job, while `debounce`, `throttle`, `memoize`, `isEqual`, and its chaining API remain useful in older applications. Version 1.13.8 is a security release: `flatten` and `isEqual` now use explicit stacks instead of recursive traversal, fixing CVE-2026-27601, which could cause denial of service in server applications with specially nested input.
Keep version 1.13.8 where an established `_` API saves migration work, especially after the stack-exhaustion security fix. For new browser or TypeScript code, native methods or a typed per-function library usually fit better than shipping the entire 21.7 KB build.
We installed it
| Install | ✓ · 0.4s | 2 packages on disk · 3 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package with exports map |
| Browser | 7.4 KB | gzipped (21.7 KB minified), bundled with esbuild |
| Types | — | no TypeScript types found |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does underscore install cleanly?
Yes. In a fresh container with an empty cache, npm install underscore finished in 0.4s, leaving 2 packages and 3 MB on disk. npm audit reported no known vulnerabilities.
How much does underscore add to a browser bundle?
7.4 KB gzipped (21.7 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does underscore work with both ESM and CommonJS?
Yes. Both import 'underscore' and require('underscore') worked in Node 22 in our run. The package is published as CommonJS with an exports map.
Does underscore include TypeScript types?
No type declarations were found in our install, so TypeScript users need their own declarations.
underscore or es-toolkit: which should you use?
es-toolkit: Choose it for current TypeScript projects that want typed utility functions and per-function imports. Keep version 1.13.8 where an established _ API saves migration work, especially after the stack-exhaustion security fix.
When should you not use underscore?
A new project only needs collection basics. Native map, filter, find, some, every, flat, Object.entries, and Object.groupBy remove the dependency and make the operation obvious to any JavaScript reader.
Use it if
- An existing application already uses `_` throughout its collection and object code, and replacing every call would create more risk than keeping the dependency
- You need `debounce`, `throttle`, `once`, `memoize`, or deep equality in plain JavaScript and want them under one small, stable API
- A browser page without a build step needs a script-tag utility library that can expose a global and later release that global through `noConflict()`
- Your project mixes CommonJS and ESM consumers, since version 1.13.8 provides an exports map and our checks succeeded with both `require()` and `import`
- A new project only needs collection basics. Native `map`, `filter`, `find`, `some`, `every`, `flat`, `Object.entries`, and `Object.groupBy` remove the dependency and make the operation obvious to any JavaScript reader.
- You expect TypeScript declarations in the package. Version 1.13.8 ships none, so typed projects need the separately maintained `@types/underscore` package.
- You need deep cloning or deep merging. `clone`, `extend`, and `defaults` are shallow, and nested objects keep their original references; only `isEqual` performs a deep traversal.
- Your browser code imports the package root for one helper. Our full-package build was 21.7 KB minified and 7.4 KB gzipped; use a per-function module path or a tree-shakeable alternative instead.
- Your security policy forbids runtime code generation. `template()` compiles with `new Function`, so a Content Security Policy that omits `unsafe-eval` blocks it.
- You want frequent feature releases. The current line began with 1.13.0 in 2021, and 1.13.8 mainly contains a denial-of-service fix plus documentation changes rather than new utility APIs.
Setup reality
In our fresh Node 22 Bookworm sandbox, npm install underscore@1.13.8 succeeded in 0.4 seconds. The result was 2 packages using 3 MB on disk. The package itself is 2660 KB unpacked, has no direct dependencies or peer dependencies, and npm audit reported 0 known vulnerabilities at every severity. Its license is MIT.
There are no credentials, environment variables, native builds, or config files. The package declares CommonJS and has an exports map. Both require('underscore') and ESM import _ from 'underscore' worked in our checks. TypeScript declarations are absent, which means a typed application must add @types/underscore or write a local declaration.
A root import includes the complete library. Our esbuild run using import * produced 21.7 KB minified and 7.4 KB gzipped. Browser code that needs one or two functions can import files such as underscore/modules/debounce.js; CommonJS callers can use the mapped module path as well. A script tag creates window._, and noConflict() restores the previous owner of that name.
The first runtime surprises come from individual helpers. memoize() uses only the first argument as its default cache key. extend() mutates its first object and does no deep merge. flatten() descends through all nested arrays unless a depth is supplied. The template compiler calls new Function, so strict browser CSP rules reject it. Version 1.13.8 removed recursive traversal from flatten() and isEqual() to prevent the stack-exhaustion path recorded as CVE-2026-27601.
Patterns
Load the complete API load-package
// ESM
import _ from 'underscore'
// CommonJS
const _ = require('underscore')
console.log(_.VERSION) // '1.13.8'Both module systems worked in our Node 22 check. This root entry loads the complete API rather than one helper.
Load a single module import-one-helper
import debounce from 'underscore/modules/debounce.js'
const scheduleSearch = debounce((query) => {
fetch(`/search?q=${encodeURIComponent(query)}`)
}, 250)Use the documented module path when browser weight matters. A root namespace import produced the full 21.7 KB minified bundle in our test.
Group and count records group-records
const orders = [
{ id: 1, state: 'paid' },
{ id: 2, state: 'draft' },
{ id: 3, state: 'paid' },
]
const byState = _.groupBy(orders, 'state')
const totals = _.countBy(orders, 'state')
const byId = _.indexBy(orders, 'id')A string iteratee reads that property. `indexBy` overwrites an earlier value when two records produce the same key.
Partition a collection split-records
const [ready, waiting] = _.partition(jobs, (job) => {
return job.state === 'ready'
})The result always has two arrays: matches first, non-matches second. It preserves the input order within each side.
Pick fields and apply defaults shape-object
const publicUser = _.pick(user, 'id', 'displayName')
const options = _.defaults(
{ timeout: request.timeout },
{ timeout: 5000, retries: 1 }
)
const copy = _.extend({}, base, overrides)`defaults` fills only undefined properties. `extend` is shallow and mutates its first argument, which is why the example starts with a new object.
Debounce and throttle events limit-event-rate
const saveDraft = _.debounce(() => save(editor.value), 400)
const updateScroll = _.throttle(() => draw(window.scrollY), 100)
editor.addEventListener('input', saveDraft)
window.addEventListener('scroll', updateScroll)
// Cancel work that has not fired yet
saveDraft.cancel()Create each wrapper once. Rebuilding it inside the event callback creates a new timer every time and defeats the rate limit.
Compare nested values compare-values
_.isEqual(
{ filters: ['open'], page: 2 },
{ filters: ['open'], page: 2 }
) // true
_.isMatch(user, { role: 'editor' }) // subset matchVersion 1.13.8 moved deep comparison to an explicit work stack, fixing the denial-of-service path in CVE-2026-27601.
Flatten nested arrays flatten-arrays
_.flatten([1, [2, [3, [4]]]]) // [1, 2, 3, 4]
_.flatten([1, [2, [3, [4]]]], 1) // [1, 2, [3, [4]]]
_.flatten([1, [2, [3, [4]]]], 2) // [1, 2, 3, [4]]With no depth argument, `flatten` descends all the way. Version 1.13.8 uses an explicit stack for deeply nested input.
Memoize with a complete cache key memoize-multiple-arguments
const price = _.memoize(
(sku, currency) => lookupPrice(sku, currency),
(sku, currency) => `${sku}:${currency}`
)
price.cache = Object.create(null)Without the second function, `memoize` keys only on the first argument. Calls with one SKU and different currencies would share the wrong result.
Chain collection operations chain-collection
const names = _.chain(users)
.filter((user) => user.active)
.sortBy('lastLoginAt')
.pluck('displayName')
.value()The final `value()` unwraps the chain. Forgetting it leaves an Underscore wrapper where the caller expects an array.
Escape values in a small template compile-html-template
const renderNotice = _.template(
'<p>Hello <%- name %>. <%= messageHtml %></p>'
)
const html = renderNotice({
name: user.displayName,
messageHtml: trustedMarkup,
})`<%- value %>` escapes HTML while `<%= value %>` inserts it raw. The compiler uses `new Function` and fails under a CSP that blocks `unsafe-eval`.
Restore a previous `_` global release-global-name
// After loading the UMD script in a browser
const utility = window._.noConflict()
utility.map([1, 2, 3], (value) => value * 2)`noConflict()` restores the value that owned `window._` before this script loaded and returns the library object for another variable.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| es-toolkit | npm | Choose it for current TypeScript projects that want typed utility functions and per-function imports |
| lodash | npm | Choose it when deep clone, deep merge, path access, or the wider iteratee API is required |
| remeda | npm | Choose it for TypeScript pipelines that need data-first and data-last calls with strong inference |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

