mrkeyoor.com_
Sun 20 Sept 11:42 UTC
npmUtilsupdated 20 Sept 2026

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.

Verdict

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

Lab card: what happened when we installed underscoreScreenshot of underscore documentation
Install✓ · 0.4s2 packages on disk · 3 MB
ImportESM import works · require() works · CommonJS package with exports map
Browser7.4 KBgzipped (21.7 KB minified), bundled with esbuild
Typesno TypeScript types found
Known vulns00 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.

API stability5/5The 1.13 series has kept the familiar collection, object, and function interfaces since 2021, and the 1.13.8 diff changes the internals of `flatten` and `isEqual` without changing their documented calls. The exports map also names separate import and require targets. That record makes routine patch upgrades low risk, although code using undocumented internal files deserves its own tests.
Docs4/5The official site documents every public helper, shows arguments and return examples, links each entry to annotated source, lists CDN files, and carries a version-by-version change log. It also states the 1.13.8 security fix and names the affected functions. The weak spot is setup guidance: TypeScript declarations and practical root-versus-module import choices require reading the package manifest or outside material.
Maintenance4/5The repository is not archived, its latest push was 2026-08-12, and 1.13.8 was published on 2026-02-19 to fix CVE-2026-27601 rather than leaving users exposed. GitHub currently reports 52 open issues and pull requests, of which 30 are issues in the API result we checked. Work is active enough for fixes, but the long-lived 1.13 line and sparse feature changes point to conservative maintenance.
Ecosystem4/5The npm downloads API recorded 27,732,018 downloads for the week ending 2026-08-22. The package covers Node import, Node require, browser ESM, browser UMD, AMD, and direct per-function paths, so older and mixed-module applications still have a supported route. TypeScript support sits in the separate `@types/underscore` package, which adds another version to coordinate and keeps this score below the top mark.

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`
Skip it if

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 match

Version 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

PackageRegistryPick it when
es-toolkitnpmChoose it for current TypeScript projects that want typed utility functions and per-function imports
lodashnpmChoose it when deep clone, deep merge, path access, or the wider iteratee API is required
remedanpmChoose 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.