Why we moved off Underscore

The core Dropbox web app is large, irregular in age, and used by millions daily. After finishing migrations from CoffeeScript to TypeScript, jQuery to React, and a custom Flux implementation to Redux, our utility library was the obvious next target.

At research time, Underscore hadn't shipped an update in three years. Lodash, by contrast, is actively maintained, battle-tested, and the most widely used utility library on the web. It's optimized for front-end CPU performance — for instance, it's designed to exploit JIT compilation in modern JavaScript engines. It also complements our React/Redux stack for building functional selector layers. Lodash can be consumed as a full library, as individual modules, or as a custom build with only the functions you need.

Planning the migration

We used a Web Enhancement Proposal (WEP), modeled on Python's PEP process, to get alignment across affected teams. The goal we set: a minimized, custom Lodash bundle that could be heavily cached across the primary web application, with all Underscore usage migrated over. More than 100 engineers engaged with the document before we started making changes.

Before choosing tools, we audited how Underscore was actually used in our codebase. That informed a list of Lodash functions we knew we needed—not exhaustive, but covering roughly 90% of our usage. We also looked at places where native JavaScript had evolved enough to replace Underscore calls entirely.

Choosing a bundling approach

Our build toolchain is Bazel, which enforces determinism but has no built-in tree shaking for JavaScript. We needed a generated, custom bundle plus accurate TypeScript typings.

Lodash's own CLI was ruled out: it has no type awareness, weak tree shaking, and is being deprecated with Lodash 5.0.0 in favor of bundling with Webpack. Rollup was also considered, but Webpack won on plugin support—specifically lodash-webpack-plugin and lodash-ts-imports-loader, both crucial for shrinking bundle size.

Building the bundle

Our first attempt produced a single pre-minified Lodash library and typings file, configured so Webpack emitted an index.ts re-exporting the functions we wanted. The JavaScript bundle came in at 12k, which was acceptable. The generated typings, however, did not behave as expected.

Rather than producing a single lodash.d.ts encapsulating all types, the build emitted imports from individual Lodash modules, re-exported at the top level. It created a set of unlinked functions rather than one importable package. That broke our intent: developers needed to import * as lodash from 'lodash'. We wanted one import for developer experience, so we could serve the bundle separately and let browsers cache it.

The fix was a two-stage build. A first build generated a complete typings file as if no tree shaking were happening. A second build produced the properly tree-shaken, minified bundle. The cost: we checked in a full version of Lodash typings, named lodash-full.d.ts.

The final setup ensures a TypeError if someone calls a Lodash function not in our custom bundle, giving us a strict firewall.

Integrating with Bazel and full toolchain

We wrapped the two Webpack builds in a Bazel build file with rules for generating both the tree-shaken bundle and the full typings file. Development mode worked cleanly.

Two integration issues surfaced. First, our broader build chain disliked the Webpack-produced minified file and its source maps didn't mesh with Bazel-generated ones. The fix was reconfiguring Uglify to emit an unminified, tree-shaken file, and letting Bazel's own minification pass handle the rest—a tradeoff we accepted even with Webpack's extra runtime comments and dependency cruft.

The second problem involved stripping functionality that some Underscore-dependent code was still using. LodashModuleReplacementPlugin had removed Lodash shorthands—forms that let you call a function like keyBy with just a string predicate instead of a full function. Adjusting the plugin's config restored the needed shorthands.

Migrating the codebase

With the build solution in place, automation became the next phase. We had prior experience automating large-scale change from the CoffeeScript-to-TypeScript migration. We used Codemod for the conversion itself.

First, we enumerated every Underscore usage pattern across the codebase. Six distinct import or call styles needed grep patterns. Next, we built a mapping table for each function in use, separating native replacements from Lodash replacements, with notes on edge cases. For example:

  • _.contains(list, value, [fromIndex]) maps to list.includes(value, [fromIndex]).
  • _.countBy(list, iteratee, [context]) maps to lodash.countBy(list, iteratee) — the context argument is not supported.

Each mapping was verified with a script asserting behavior equivalence between the old and new implementations.

Application code migrated first, with test code kept on Underscore. Running migrated application code against unmigrated tests gave confidence that no bug had slipped through both sides simultaneously.

Executing the rollover

Most codemods were simple bash scripts for rule-based conversions. After running them across the whole codebase, we split changes into roughly ten diffs grouped by codebase ownership. That simplified tracking down reviewers and organizing the mailing effort.

Some conversions required hands-on work. Anything using $u.chain syntax needed to be rewritten by hand. Functions like object, has, create, matches, and template resisted automatic migration to their Lodash or native equivalents. forEach demanded special care because its object and array behaviors differ. Imports of individual Underscore functions couldn't be codemodded either.

We spent a full week with the ten diffs open, re-testing and re-reading each one. Landing them happened in dependency order, so no page would import both Underscore and Lodash for longer than necessary. That sequencing took a couple of days.

The end result was remarkably clean: exactly one bug. A manual conversion on an internal tool used splice where slice was the intended call. The fix shipped quickly, and no external users were affected.

Outcomes and Lessons Learned

What looked like a routine library swap turned out to be a multi-step process with several surprises. A lightweight proposal format helped surface user concerns before any code changed, but it didn't catch everything: some assumptions were wrong, and several tools behaved differently than expected. The need for a custom build added further steps that we hadn't planned for.

The decisive factor was experimentation. By prototyping and testing the migration path ahead of time, we were able to roll out the change with almost no issues. Still, a working migration isn't the same as an adopted one. To make sure the new library actually got used, we ran internal tech talks on functional programming with Lodash and promoted a Lodash "function of the week" in our frontend newsletter.

Our hope is that documenting this research makes the journey easier for the next team faced with migrating a large, decade-old codebase from one utility library to another.