Twenty installs, one package
Eleven popular packages walk up a directory tree, and together they install twenty times. empathic does the same work in one package with no dependencies.
Almost every JavaScript tool needs to answer one question when it starts: where am I?
A bundler looks for its config file. A test runner looks for the nearest package.json. A compiler looks for a cache folder in node_modules/.cache. Each answer comes from the same small operation: start in a directory, check for a name, go up one level, and repeat.
That operation is about ten lines of code. On npm, it is a whole family of packages.
#The family
When I started empathic in August 2024, I made a list of the packages that do some version of this walk. The list is still at the top of notes.ts in the repo:
find-up,find-up-simple,locate-path, andwalk-up-pathfind a file in a parent directory.pkg-up,package-up, andpkg-dirfind the nearestpackage.json.find-cache-dirbuilds anode_modules/.cache/<name>path.resolve-fromandresolve-cwdresolve a module from a directory.escaladedoes the walk with a callback. I wrote that one, so I am part of the problem.
Each one is fine. Most are small, well tested, and written by people I respect. The problem is not any one package. The problem is all of them at once.
#Count the installs, not the packages
Many of these packages depend on each other, and on other small packages. find-up uses locate-path, which uses p-locate, which uses p-limit, which uses yocto-queue. pkg-up and find-cache-dir each ask for an older major of find-up, so npm installs extra copies next to them.
I installed the current major of all eleven into an empty project. The result:
- 20 package installs, from 17 unique names. Three are duplicate copies of
find-upandpkg-dirin nestednode_modulesfolders. - 164,768 bytes on disk.
The same install for empathic gives one package and 22,308 bytes. That number is larger than it has to be, because it ships both ESM and CommonJS files and their types. The code that runs is much smaller. Bundled and minified, all five empathic modules together are 1,007 bytes gzip. For comparison, the bundled dependency tree of find-up alone is 1,571 bytes, find-cache-dir is 1,643, and pkg-up is 1,515.
So the whole of empathic is smaller than each of the large trees it replaces. To be fair, it is not smaller than every package on the list. escalade and walk-up-path are about 300 and 220 bytes gzip, and they each do one thing. The point is not that one file beats another. The point is the sum.
#Why the sum matters
These packages are not rare. Here are last week's downloads from the npm API, measured when I wrote this:
| Package | Downloads / week |
|---|---|
find-up |
327,607,833 |
locate-path |
320,208,798 |
resolve-from |
242,427,171 |
path-exists |
235,578,159 |
escalade |
205,993,689 |
pkg-dir |
87,605,250 |
find-cache-dir |
43,418,587 |
A big project does not pick one of these. It gets most of them, through different tools that each picked a different one. Your bundler uses find-up, your test runner uses pkg-dir, your compiler uses find-cache-dir, and now three trees in your node_modules do the same walk in three slightly different ways.
Every one of those installs is a tarball to download, a folder to extract, a lockfile entry to review, and a publisher to trust. That last one matters more each year. None of these packages has done anything wrong, but every extra package is one more account that can be taken over. I wrote more about that cost in The packages nobody knows they install.
#One walk, built up in layers
empathic has five modules. Each one is a separate export, so you import only what you use:
import { resolve } from "node:path";
import * as find from "empathic/find";
import * as pkg from "empathic/package";
let cwd = resolve("path/to/acme/websites/dashboard");
find.up("foobar.config.js", { cwd });
//=> "/.../path/to/acme/foobar.config.js"
pkg.up({ cwd });
//=> "/.../path/to/acme/package.json"
pkg.cache("foobar", { cwd, create: true });
//=> "/.../path/to/acme/node_modules/.cache/foobar"
The modules stack on each other. walk is the base: it returns every parent directory of a path, and nothing more.
export function up(base: string, options?: Options): string[] {
let { last, cwd } = options || {};
let tmp = absolute(base, cwd);
let root = absolute(last || "/", cwd);
let prev: string | undefined,
arr: string[] = [];
while (prev !== root) {
arr.push(tmp);
tmp = dirname((prev = tmp));
if (tmp === prev) break;
}
return arr;
}
find loops over that list and checks each directory for a name. package is find with package.json as the name, plus the cache folder logic. resolve turns a module name into a path from a directory. access holds the permission checks that package needs before it creates a cache folder.
That layering is the whole trick. The family on npm has the same layers, but each layer is its own package with its own version range. In empathic, they are files in one folder that ship together, so they can never ask for different copies of each other.
#Sync on purpose
Most of the family is async. empathic is sync, and that is a choice, not a shortcut.
A tool asks where am I? once, at startup, before it can do anything else. Nothing useful runs while it waits. An async walk pays for a promise and a trip through the event loop on every directory, and the caller just waits for the result anyway.
The benchmarks show the cost. On my M1 Max, in Deno 2.3.6, with the target six directories up:
| Package | Time per call |
|---|---|
escalade |
235.4 µs |
find-up |
207.8 µs |
find-up-simple |
156.0 µs |
find-up-simple (sync) |
71.9 µs |
empathic/find.up |
7.3 µs |
That is about 28 times faster than find-up. When the walk goes past fifteen directories and never finds the target, the gap is about 31 times. The full results, and the commands to run them on your own machine, are in benchmarks.md. CI runs every benchmark and logs the module sizes on each push.
Yes, my own escalade is the slowest row. I left it there.
A few microseconds will not change your build time. I do not claim it will. The speed is a side effect of doing less: existsSync on each directory, one array, and no promises. I wrote about that pattern in JavaScript performance tips I actually use, and about how to read a table like this one in How to benchmark honestly.
#Changes that broke things
1.0.0 shipped on September 3, 2024, five days after the first commit. The API was mostly right, with one exception.
The walk had a stop option, and stop was exclusive: the walk ended before it checked that directory. If you have to stop and think about what an option includes, the name is wrong. In 2.0.0, it became last, and last is inclusive: the walk checks that directory, and then it stops. The name now says what it does.
2.0.0 also added find.file and find.dir, which match only a file or only a directory with that name. 2.1.0, in September 2026, added a type option to find.any for the same reason.
Node 14 support came in 1.1.0, and so did CommonJS output. The packages that use this walk are build tools, and a lot of build tools still require() their dependencies. A deduplication package that half the ecosystem cannot load does not deduplicate anything.
#Adoption
The library only matters if other projects switch to it. A few have:
@babel/corereplacedfind-up-simpleandfind-cache-directorywithempathicin babel/babel#18030.@babel/coregets about 188 million downloads a week.- Storybook depends on it in its core package.
tsdowndepends on it.
The Babel PR says it better than I can. It admits that empathic is larger than find-up-simple, and then it argues that the switch is still right, because the wider ecosystem already uses empathic. One shared package is better than ten good ones.
#Smaller than the sum
I did not write empathic because the family was bad. I wrote it because the family had grown into a tree, and the tree was in every project I opened.
Deduplication is boring work. It is not a new idea, and nobody stars a repo because it removed twenty folders from their node_modules. But every one of those folders was code that somebody had to download, trust, and keep up to date, and most of them did the same ten lines.
This is the same instinct as Small on purpose, one level up. That post is about keeping one package small. This one is about keeping the set small. A single package can be tiny and still be one of twenty that do the same job.