Plate 71
find-up vs locate-path: 47 µs Sync vs 596 µs Async
Aditya Challa8 min read
I timed find-up 8.0.0 against locate-path 8.0.0 finding package.json 12 folders above the working directory on this box: findUpSync took 47 µs, async findUp took 596 µs, and a hand-written loop around async locatePath took 571 µs (Node 20, median, run 1). The sync API was 12.6× to 13.7× faster than the async one across both runs, and sync won every case I tried (6× to 14×).
Short answer: they are not rivals so much as two layers. find-up calls locate-path once per folder on its way up, so pick locate-path when you only check one folder and find-up when you need to walk up, and in both cases reach for the sync function unless you are checking a slow or network disk. No affiliate links in this post.
What I tested
- Machine: 8 vCPU Intel Xeon / 15 GB RAM shared Linux cloud box, tested 6 Oct 2026 about 04:15 to 04:30 IST.
- Versions: find-up 8.0.0 and locate-path 8.0.0 (both npm latest that day; find-up 8 depends on locate-path ^8.0.0), Node 20.19.2, esbuild 0.28.2 for bundle sizes.
- Workload: four cases. One folder with 8 candidate config names where only the last one exists; package.json 12 folders up; 8 config names checked in each of 12 folders up to a .myapprc.json at the top; and a miss, walking 12 folders up to a stopAt folder without finding anything. As a baseline I also timed a hand-rolled loop that calls fs.statSync inside try / catch.
- Timing: median of 9 rounds of 2,000 calls after 200 warm-up calls, two processes with the run order flipped, reported in microseconds per call. Scripts and JSON in /workspace/bench-find-up-locate-path/.
- Also measured: cold import plus first call (11 fresh processes), esbuild bundle size, fresh install size, and behavior checks for directories, symlinks, matcher functions, preserveOrder, findUpMultiple and findDown.
- Not tested: network or Docker bind-mounted disks (where async may help), Windows or macOS, Bun and Deno, path-exists or other find-up alternatives such as empathic.
How much does walking up cost?
Node 20.19.2, median µs per call (run 1 / run 2):
| Case | findUp | findUpSync | locatePath loop (async) | hand-rolled statSync loop |
|---|---|---|---|---|
| package.json, 12 folders up | 595.63 / 613.63 | 47.19 / 44.85 | 570.66 / 546.42 | 154.98 / 162.02 |
| 8 names per folder, 12 folders up | 1,874.72 / 1,834.29 | 269.37 / 284.68 | 1,738.92 / 1,803.62 | 1,301.32 / 1,361.42 |
| miss, 12 folders to stopAt | 563.63 / 573.81 | 41.41 / 43.99 | not run | 161.29 / 167.03 |
find-up's own overhead on top of locate-path was small: 596 to 614 µs vs 546 to 571 µs for the same walk written by hand, so roughly 4% to 12% (2% to 8% in the 8-name case). The big number is async vs sync. Every async check is a separate fs.promises.stat call plus promise scheduling, while the sync path runs a plain statSync with throwIfNoEntry: false. My own try / catch loop was 3× slower than findUpSync because throwing and catching an ENOENT error for each missing file costs more than the stat itself. If you then glob from the folder you found, the next costs are in fast-glob vs globby and micromatch vs picomatch.
Is locate-path faster in a single folder?
One folder, 8 candidate names, match on the last name. Median µs per call (run 1 / run 2):
| Call | µs |
|---|---|
| locatePathSync | 15.02 / 13.82 |
| findUpSync with stopAt set to the same folder | 19.86 / 20.09 |
| hand-rolled statSync try / catch | 77.89 / 75.87 |
| locatePath (default: concurrency Infinity, preserveOrder true) | 138.56 / 135.92 |
| findUp with stopAt set to the same folder | 152.53 / 139.77 |
| locatePath, preserveOrder: false | 184.88 / 132.14 |
| locatePath, concurrency: 1 | 269.08 / 266.57 |
In one folder, locate-path saved about 5 to 6 µs sync and 4 to 14 µs async over calling find-up with stopAt. preserveOrder: false, which the docs suggest for speed, gave no reliable gain here: one run slower, one run 3% faster. concurrency: 1 doubled the time, because the 8 stats then ran one after another. Path handling in these loops is node:path; if you need the same code on Windows-style paths, see pathe vs node:path.
Do they behave the same?
| Check | Result |
|---|---|
| locatePath(['package.json']) from 12 folders down | undefined; it never walks up |
| findUp('package.json') with a second package.json 6 folders up | returned the nearer one |
| findUpMultiple('package.json', stopAt) | both files, nearest first |
| findUp('.git') where .git is a folder | undefined; default type is 'file' |
| findUp('.git', { type: 'directory' }) | found |
| Matcher function returning findUpStop at folder l6 | undefined; stopped early |
| findDown('package.json', depth 10) from the top | found the top-level file first |
| locatePath with 3 existing files, preserveOrder: false, 200 calls | first name 188 times, second name 12 times |
| Symlinked file, default options | matched |
| Symlinked file, allowSymlinks: false | undefined (both packages) |
| cwd passed as a file URL | worked in both |
| import { pathExists } from 'find-up' | SyntaxError; not exported in v8 |
Two of these are easy to trip over. The .git case returns nothing unless you pass type: 'directory', which is how repo-root finders quietly fail. And preserveOrder: false really does change the answer when more than one candidate exists, so only use it when any match will do. Package-manager detection is a common use for this exact walk (lockfiles up the tree); I compared two of those in pm-detector vs which-pm. Config loaders do the same walk; c12 and friends sit on top of it, and confbox vs dotenv covers the parsing step after the file is found.
What do they cost to install and load?
| Measure | find-up 8.0.0 | locate-path 8.0.0 |
|---|---|---|
| esbuild bundle, minified | 3,512 B | 2,379 B |
| Bundle gzip -9 | 1,514 B | 1,160 B |
| Fresh npm install | 176 KB, 6 packages | 104 KB, 4 packages |
| Cold import + first async call (median of 11) | 35.77 ms | 28.58 ms |
| Cold import + first sync call (median of 11) | 34.26 ms | 26.07 ms |
Both are tiny. find-up adds unicorn-magic on top of locate-path, p-locate, p-limit and yocto-queue. The cold-start medians were noisy on this shared box (single runs from 25 to 52 ms) and mostly measure Node startup, so I would not choose on them. For a CLI that starts often, the argument parser and process runner usually cost more; see citty vs commander and tinyexec vs execa.
Which one should you pick?
| Situation | My pick |
|---|---|
| Check a few names in one known folder | locatePathSync |
| Find the nearest package.json, lockfile or config upward | findUpSync |
| Find every package.json up to a monorepo root | findUpMultiple or findUpMultipleSync with stopAt |
| Find a repo root by its .git folder | findUp with type: 'directory' |
| Code already async and on a slow or network disk | findUp (not measured here) |
| Any match will do and you want fewer awaits | locatePath with preserveOrder: false, but check the result order |
| Fewest dependencies | locate-path, or a statSync loop with throwIfNoEntry: false |
Bottom line: on this box find-up 8.0.0 cost about 2% to 12% over a locate-path loop doing the same walk, and the sync versions of both were 6× to 14× faster than the async ones. Who should change code: anyone calling await findUp() during startup on a local disk, where findUpSync returned in 45 to 47 µs instead of 596 to 614 µs. Who can leave it alone: code that runs the walk once per process, where half a millisecond is noise next to a 35 ms Node start. If you also watch the folder you found, see chokidar vs @parcel/watcher.
How this was made: I installed both packages on the ShopperCove box, built a 12-folder fixture, timed four lookup cases over 9 rounds of 2,000 calls in two processes with the order flipped, checked cold start in 11 fresh processes, bundled each entry with esbuild, ran a fresh npm install for size, and ran behavior checks for directories, symlinks, matchers and ordering. The JSON results sit next to the scripts. The write-up was drafted with AI help and checked against that output.
Sources
- https://github.com/sindresorhus/find-up
- https://www.npmjs.com/package/find-up
- https://github.com/sindresorhus/locate-path
- https://www.npmjs.com/package/locate-path
- https://nodejs.org/api/fs.html#fsstatsyncpath-options
Related
- https://www.shoppercove.com/blog/fast-glob-vs-globby
- https://www.shoppercove.com/blog/micromatch-vs-picomatch
- https://www.shoppercove.com/blog/pathe-vs-node-path
- https://www.shoppercove.com/blog/pm-detector-vs-which-pm
- https://www.shoppercove.com/blog/confbox-vs-dotenv
- https://www.shoppercove.com/blog/citty-vs-commander
- https://www.shoppercove.com/blog/tinyexec-vs-execa
- https://www.shoppercove.com/blog/chokidar-vs-parcel-watcher
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~04:15-04:30 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 20.19.2). find-up 8.0.0 (depends on locate-path ^8.0.0) vs locate-path 8.0.0; esbuild 0.28.2. 12-folder fixture. Median of 9 x 2,000 calls after 200 warm-up, two processes flipped (µs): pkg 12 up findUp 595.63/613.63, findUpSync 47.19/44.85, locatePath loop 570.66/546.42, statSync try/catch 154.98/162.02; 8 names 1,874.72/1,834.29, 269.37/284.68, 1,738.92/1,803.62, 1,301.32/1,361.42; miss 563.63/573.81, 41.41/43.99, 161.29/167.03; one folder locatePathSync 15.02/13.82, findUpSync 19.86/20.09, locatePath 138.56/135.92, noOrder 184.88/132.14, conc1 269.08/266.57. Behavior: locatePath never walks up; .git needs type directory; preserveOrder false gave 2nd name 12/200; allowSymlinks false rejects symlink; pathExists not exported in v8. Bundle gzip 1,514 vs 1,160 B; install 176 KB/6 vs 104 KB/4; cold 35.77 vs 28.58 ms (noisy). Not tested: network disks, Windows/macOS, Bun/Deno, empathic. No affiliate.