Plate 97
c12 vs cosmiconfig: 0.58 ms vs 1.08 ms per .mjs Load
Aditya Challa9 min read
I timed c12 4.0.0-rc.2 against cosmiconfig 10.0.1 loading the same small config on this box: 0.58 ms vs 1.08 ms per myapp.config.mjs load, and 0.28 ms vs 1.09 ms for a .cjs file (Node 20, median, run 1; run 2 was 0.61 vs 1.12 and 0.26 vs 1.04). The same test flipped on package.json, where c12 took 3.3 to 3.4 ms vs cosmiconfig's 0.31 to 0.33 ms, and c12 only read package.json at all after I passed packageJson: true.
Short answer: pick c12 when you want layered config (defaults, overrides, $development blocks) and a loader that sees file edits in a running process. Pick cosmiconfig when you want the classic rc-file search (package.json, .myapprc.yaml, .config/), a smaller bundle, and no per-load memory growth. 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:25 IST.
- Versions: c12 4.0.0-rc.2 (the npm latest tag that day; 3.3.4 sits on the 3x tag) and cosmiconfig 10.0.1 (npm latest), Node 20.19.2, plus Node 22.14.0 for the TypeScript and cold-start checks. jiti 2.7.0 for the optional TypeScript fallback, esbuild 0.28.2 for bundle sizes.
- Workload: one config object (port, plugins array, nested build block) written four ways: myapp.config.mjs, myapp.config.cjs, YAML (myapp.config.yaml for c12, .myapprc.yaml for cosmiconfig, since each looks for different file names), and a "myapp" key in package.json. c12 ran loadConfig with rcFile, globalRc and dotenv off. cosmiconfig ran search() on a fresh explorer with cache: false, so both did a real load every time.
- Timing: median of 9 rounds of 300 loads, two processes with the run order flipped. Scripts and JSON in /workspace/bench-c12-cosmiconfig/.
- Also measured: cold import plus first load (11 fresh processes per Node version), heap and RSS growth over 2,000 loads, esbuild bundle size, fresh install size, TypeScript configs, edits while the process runs, a broken config file, and nested-folder search.
- Not tested: c12 extends with remote sources (giget), c12 watchConfig, dotenv loading, cosmiconfig-typescript-loader, cosmiconfigSync, Bun and Deno, Windows or macOS.
Is c12 faster than cosmiconfig?
Node 20.19.2, median ms per load (run 1 / run 2):
| Config file | c12 4.0.0-rc.2 | cosmiconfig 10.0.1, cache off |
|---|---|---|
| myapp.config.mjs | 0.585 / 0.607 | 1.084 / 1.124 |
| myapp.config.cjs | 0.279 / 0.262 | 1.095 / 1.041 |
| YAML | 0.757 / 0.790 | 0.594 / 0.541 |
| package.json key | 3.407 / 3.277 | 0.307 / 0.325 |
| myapp.config.mjs, cosmiconfig cache on | n/a | 0.0014 / 0.0015 |
c12 was about 1.85× faster on .mjs in both runs and about 4× faster on .cjs. cosmiconfig won on YAML and was 10× to 11× faster on package.json. The .mjs gap makes sense once you read the code: cosmiconfig walks its search list in order (package.json, then 8 rc names, then 8 .config/ names) before it reaches myapp.config.mjs, while c12 goes straight to the config file name. The package.json gap is c12 pulling in pkg-types to read the file. With its cache on, cosmiconfig answered repeat searches in about 1.5 µs, which is what most CLIs that load config once will see after the first call. If config merging is the part you care about, I measured the merge helper c12 uses in defu vs lodash.merge.
Which one sees a config edit without a restart?
| Check | c12 | cosmiconfig |
|---|---|---|
| Write port: 1, load, write port: 2, load again (same process) | 1, then 2 | 1, then 1 |
| Heap growth over 2,000 .mjs loads (two runs) | +8,549 / +8,624 KB | +565 / +372 KB |
| RSS growth over 2,000 .mjs loads | +26,240 / +25,852 KB | +10,112 / +10,880 KB |
This is the same design choice seen from two sides. c12 adds a counter query string to the file URL on every import, so Node treats each load as a new module and you get the edited value. The cost is that every one of those module copies stays in memory, about 4.3 KB of heap per load here. cosmiconfig imports the plain file URL, so Node's module cache hands back the first version even with cache: false on the explorer. For a dev server that reloads config on change, c12's behavior is the one you want; for a long-running process that calls loadConfig in a loop, it is a slow leak. If you watch config files yourself, my chokidar vs @parcel/watcher run covers that side.
What happens with a TypeScript config?
| myapp.config.ts | c12 | cosmiconfig |
|---|---|---|
| Node 20.19.2, nothing extra installed | failed: Unknown file extension ".ts", hint to install jiti | failed: Unexpected identifier 'Cfg' |
| Node 20.19.2 with jiti 2.7.0 installed | loaded (first load 180.1 ms) | still failed, same error |
| Node 22.14.0 with --experimental-strip-types | loaded | loaded |
Neither tool bundles a TypeScript loader in these versions. c12 falls back to jiti if it is installed, which makes a .ts config work on Node 20 at the cost of a slow first load. cosmiconfig needs a separate loader package for that. On Node 22 with type stripping on, both just import the file. I covered the loader trade-off in esbuild-register vs jiti and the runner side in tsx vs ts-node.
Do they find the same files?
| Check | c12 | cosmiconfig |
|---|---|---|
| Config 4 folders above cwd, default options | not found, returns | not found, returns null |
| Same, cosmiconfig searchStrategy: 'project' or 'global' | n/a | found |
| "myapp" key in package.json, default options | ignored, returns | found |
| Same, c12 packageJson: true | found | n/a |
| Folder with both myapp.config.yaml and .myapprc.yaml | read myapp.config.yaml | read .myapprc.yaml |
| defaults + overrides + $development block | merged: port 5173, minify false, sourcemap true, host added (the $development key stays in the result) | returned the raw object |
| Broken .mjs file (unfinished object) | error: Unexpected end of input, plus a jiti hint | error: Cannot read properties of undefined (reading 'async') |
Two of these will bite people switching from one to the other. cosmiconfig 10 no longer walks up directories unless you set searchStrategy, and c12 never walks up. And c12 ignores package.json unless you ask for it. The broken-file row also matters for support tickets: cosmiconfig's async loader fell back to require() after the import failed, and on Node 20.19 that fallback threw an internal TypeError that hid the real syntax error. c12 parses YAML, TOML, JSON5 and JSONC through confbox, which I compared in confbox vs dotenv; cosmiconfig uses js-yaml, measured in yaml vs js-yaml.
Which one starts faster and weighs less?
| Measure | c12 | cosmiconfig |
|---|---|---|
| Cold import + first .mjs load, Node 20 (median of 11) | 38.69 ms | 25.21 ms |
| Same, Node 22 | 22.65 ms | 15.48 ms |
| esbuild bundle, minified | 150,796 B | 80,997 B |
| Bundle gzip -9 | 48,903 B | 23,802 B |
| Fresh npm install | 744 KB, 8 packages | 1,976 KB, 4 packages |
| Install with jiti for .ts on Node 20 | 2,516 KB, 9 packages | n/a |
cosmiconfig started about 13 ms sooner on Node 20 and 7 ms sooner on Node 22 and bundled to half the size, while c12 was smaller on disk until you add jiti. For a CLI where a single config read happens at startup, that cold-start row matters more than the per-load table. If you are building that kind of CLI, the argument parser is the next thing to size; see citty vs commander. To keep the bundle number honest in CI, the setup in size-limit vs bundlesize works for both.
Which one should you pick?
| Situation | My pick |
|---|---|
| Dev server or build tool that reloads config on change | c12 |
| You want defaults, overrides and per-environment blocks merged for you | c12 |
| You need a .ts config on Node 20 and can add jiti | c12 |
| You want package.json and rc files found by default | cosmiconfig |
| Long-running process that loads config many times | cosmiconfig, or cache the c12 result yourself |
| Smallest bundle and fastest cold start | cosmiconfig |
| YAML-heavy config | cosmiconfig (0.54 to 0.59 vs 0.76 to 0.79 ms) |
Bottom line: on this box c12 loaded a JavaScript config 1.85× to 4× faster than cosmiconfig with caching off, saw edits without a restart, and merged layers for you. cosmiconfig was 10× to 11× faster on package.json, started faster, bundled to half the size, and did not grow memory with each load. Who should not switch to c12 yet: tools that rely on package.json config or on rc-file discovery, and anyone uneasy that the latest tag is still a release candidate. Who should try c12 now: frameworks and dev servers that already live in the unjs stack. If your users install Node through a version manager, the Node 22 type-stripping row is easier to reach; see Volta vs fnm.
How this was made: I installed both loaders on the ShopperCove box, timed four config formats over 9 rounds of 300 loads in two processes, measured cold start in 11 fresh processes on Node 20 and 22, tracked heap and RSS over 2,000 loads, bundled each entry with esbuild, ran a fresh npm install for size, and checked TypeScript, edits, broken files and search behavior. 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/unjs/c12
- https://unjs.io/packages/c12
- https://www.npmjs.com/package/c12
- https://github.com/cosmiconfig/cosmiconfig
- https://www.npmjs.com/package/cosmiconfig
- https://github.com/unjs/jiti
- https://nodejs.org/api/typescript.html
Related
- https://www.shoppercove.com/blog/defu-vs-lodash-merge
- https://www.shoppercove.com/blog/chokidar-vs-parcel-watcher
- https://www.shoppercove.com/blog/esbuild-register-vs-jiti
- https://www.shoppercove.com/blog/tsx-vs-ts-node
- https://www.shoppercove.com/blog/confbox-vs-dotenv
- https://www.shoppercove.com/blog/yaml-vs-js-yaml
- https://www.shoppercove.com/blog/citty-vs-commander
- https://www.shoppercove.com/blog/size-limit-vs-bundlesize
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~04:15-04:25 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 20.19.2 and 22.14.0). c12 4.0.0-rc.2 (latest tag; 3.3.4 on 3x) vs cosmiconfig 10.0.1; jiti 2.7.0; esbuild 0.28.2. Same config as .mjs/.cjs/YAML/package.json key. Median of 9 x 300 loads, two processes flipped: mjs 0.585/0.607 vs 1.084/1.124; cjs 0.279/0.262 vs 1.095/1.041; yaml 0.757/0.790 vs 0.594/0.541; pkg 3.407/3.277 vs 0.307/0.325; cosmiconfig cache on 0.0014. Edit reload: c12 1->2, cosmiconfig 1->1 (Node ESM cache). 2,000 loads heap +8,549/+8,624 KB vs +565/+372; RSS +26,240/+25,852 vs +10,112/+10,880. Cold N20 38.69 vs 25.21, N22 22.65 vs 15.48 ms. Bundle gzip 48,903 vs 23,802 B; install 744 KB/8 vs 1,976 KB/4 (c12+jiti 2,516 KB). TS N20 both fail, c12+jiti ok, both ok on N22 strip-types. cosmiconfig hid syntax error behind TypeError. Not tested: extends/giget, watchConfig, dotenv, cosmiconfig-typescript-loader, Bun/Deno. No affiliate.