Plate 47
citty vs commander: 0.022 ms vs 0.011 ms Parse
Aditya Challa5 min read
I timed the same CLI define+parse loop on this box: citty 0.2.2 took about 0.022 ms per cold run and commander 15.0.0 about 0.011 ms. That is roughly 2× in commander's favor on parse, while citty's esbuild node minify+gzip landed at 3,644 B against commander's 11,184 B.
Short answer: pick citty when you want a tiny UnJS-style CLI on Node's util.parseArgs with typed args and lazy subcommands. Pick commander when you need the mature option/help ecosystem, variadic arguments, or the fastest recreate+parse on this box. 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 03:40 IST.
- Versions: citty 0.2.2, commander 15.0.0 (both npm latest that day), Node 20.19.2, esbuild 0.28.2 node ESM minify + gzip -9.
- Workload: one positional, three flags (--friendly, --count, --verbose). Cold path = redefine the command and parse those argv values 5,000 times per round; warm path for citty = define once and runCommand 5,000 times. Median of 9 rounds, two processes with the library order reversed. Scripts and JSON in /workspace/bench-citty-commander/.
- Also measured: renderUsage / helpInformation, nested subcommand dispatch, unknown-option errors, boolean --no- negation, extra positionals vs variadic args, install folder size, and engines fields.
- Not tested: interactive prompts, custom help configuration beyond defaults, commander plugins, Windows argv quirks, Bun/Deno hosts, and Node 22+ even though commander 15 declares engines.node >= 22.12.0 (it still ran here on 20.19.2).
How fast is citty vs commander?
| Job (median of 9, ms per define+parse) | Run 1 | Run 2 |
|---|---|---|
| citty 0.2.2, cold | 0.0224 | 0.0214 |
| commander 15.0.0, cold | 0.0119 | 0.0108 |
| citty, warm (define once) | 0.0213 | 0.0244 |
| citty renderUsage | 0.040 | — |
| commander helpInformation | 0.023 | — |
Commander won the cold recreate+parse by about 2× on this box. citty's warm path did not pull ahead of its cold path, so most of the cost is inside runCommand / parseArgs rather than defineCommand. Help text generation was in the same ballpark (about 0.02–0.04 ms). For process spawning next to a CLI, see tinyexec vs execa; for colored terminal output, picocolors vs chalk.
What does each one cost to install and bundle?
| Package | Minified | gzip | Input files | Alone on disk |
|---|---|---|---|---|
| citty 0.2.2 | 8,977 B | 3,644 B | 3 | 76 KB (1 package) |
| commander 15.0.0 | 39,145 B | 11,184 B | 8 | 252 KB (1 package) |
Both are zero-dependency single packages. citty is about 3× smaller gzip and about 3× smaller on disk after a fresh install. That gap matters for published CLIs the same way it did for loggers in consola vs pino and for locking size in CI via size-limit vs bundlesize. If you bundle the CLI with tsup vs unbuild, citty's smaller graph stays small.
Do they behave the same?
| Check | citty 0.2.2 | commander 15.0.0 |
|---|---|---|
| Nested subcommand greet Ada | sub run fired; parent also runs | action fired; returned hi Ada |
| Unknown option --nope | no throw in this harness (lands in _) | commander.unknownOption |
| Boolean default true + --no-fancy | fancy false | supported via default + --no- |
| Extra args a b c on one positional | files=a, _=[a,b,c] | needs a variadic files argument → [a,b,c] |
| Module format | ESM | ESM (type module) |
| engines.node | unset | >= 22.12.0 (ran on 20.19.2 here) |
| Parser base | Node util.parseArgs | commander parser |
Two practical gaps: commander rejects unknown options by default and has first-class variadic arguments; citty keeps extras on _ and leans on parseArgs. citty's API is object-shaped (defineCommand + args map), which fits typed UnJS tooling; commander is method-chained and has a much larger help/option surface. Path helpers next to argv parsing are covered in pathe vs node:path, and if your CLI globs files, fast-glob vs globby is the sibling choice.
Which one should you pick?
| Situation | My pick |
|---|---|
| New UnJS / Nuxt-style CLI, smallest bundle | citty |
| Need variadic args, strict unknown-option errors, rich help | commander |
| Hot path that rebuilds and parses argv thousands of times | commander (about 2× here) |
| CommonJS script without a bundler | neither is ideal (both ESM); wrap or dynamic-import |
| Already deep in commander options/plugins | stay |
Bottom line: on this box commander 15.0.0 defined and parsed in about 0.011 ms versus citty 0.2.2 at about 0.022 ms, while citty shipped at 3.6 KB gzip against 11.2 KB. Who should not switch to citty: teams that depend on commander's option validation, variadic args, or plugin ecosystem. Who should not stay on commander for a greenfield tiny CLI: anyone optimizing install and bundle size who is fine with parseArgs-shaped flags.
How this was made: I installed both packages on the ShopperCove box, timed cold define+parse (and citty warm) over 5,000 iterations × 9 rounds in two order-reversed processes, bundled each with esbuild, and checked nested commands, unknown options, negation, and variadic 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/citty
- https://github.com/tj/commander.js
- https://nodejs.org/api/util.html#utilparseargsconfig
- https://www.npmjs.com/package/citty
- https://www.npmjs.com/package/commander
- https://esbuild.github.io/
Related
- https://www.shoppercove.com/blog/tinyexec-vs-execa
- https://www.shoppercove.com/blog/consola-vs-pino
- https://www.shoppercove.com/blog/picocolors-vs-chalk
- https://www.shoppercove.com/blog/size-limit-vs-bundlesize
- https://www.shoppercove.com/blog/tsup-vs-unbuild
- https://www.shoppercove.com/blog/pathe-vs-node-path
- https://www.shoppercove.com/blog/fast-glob-vs-globby
- https://www.shoppercove.com/blog/knip-vs-depcheck
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~03:40 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 20.19.2). citty 0.2.2 vs commander 15.0.0; esbuild 0.28.2 node ESM minify + gzip -9. Workload: positional name + --friendly/--count/--verbose. Cold = redefine + parse 5,000× per round, median of 9, order-reversed (ms): citty 0.0224/0.0214, commander 0.0119/0.0108. citty warm (define once) 0.0213/0.0244. Usage/help ~0.040 vs ~0.023 ms. Bundle 8,977/3,644 B vs 39,145/11,184 B. Install 76 KB vs 252 KB. Behavior: both dispatch nested greet; commander throws unknownOption; citty --no-fancy clears default-true boolean; citty extra positionals land on _; commander needs variadic. commander package engines >=22.12.0, still ran here. Not tested: prompts, plugins, Windows, Bun/Deno, Node 22+. No affiliate.