Plate 93
mlly vs acorn: 0.046 ms vs 0.017 ms findExports
Aditya Challa6 min read
I timed mlly 1.8.2 findExports against acorn 8.19.0 parse on a 240 B ESM fixture on this box: 0.046 ms vs 0.017 ms per call. That is about 2.7× in acorn's favor on raw parse, while mlly's full esbuild node minify+gzip landed at 42,531 B against acorn's 34,173 B.
Short answer: pick mlly when you need ESM utilities (findExports, findStaticImports, detectSyntax, resolvePath) without writing an AST walk. Pick acorn when you need a full Program AST, custom walkers, or the fastest parse of the same source. mlly depends on acorn and uses its tokenizer on some paths, so this is a layering choice, not a drop-in fork. 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:50 IST.
- Versions: mlly 1.8.2, acorn 8.19.0 (both npm latest that day), Node 20.19.2, esbuild 0.28.2 node ESM minify + gzip -9.
- Workload: three fixtures (240 B / 2,720 B / 34,608 B). Timed mlly findExports, findStaticImports, findExportNames, detectSyntax, resolvePathSync vs acorn.parse, a hand-rolled export-name walk on the AST, and acorn.tokenizer drain. Median of 9 rounds (5,000 / 2,000 / 500 iters by size), two order-stable processes. Scripts and JSON in /workspace/bench-mlly-acorn/.
- Also measured: esbuild bundle sizes for mlly (named exports) vs findExports-only vs acorn parse+tokenizer, and alone-on-disk install size.
- Not tested: TypeScript/JSX plugins, acorn-walk / estree transformers, magicast/recast codemods, Bun/Deno hosts, and adversarial malformed sources beyond what detectSyntax classifies.
How fast is mlly findExports vs acorn.parse?
| Job (median of 9, ms) | Run 1 | Run 2 |
|---|---|---|
| mlly findExports, 240 B | 0.0456 | 0.0459 |
| acorn.parse, 240 B | 0.0176 | 0.0173 |
| acorn export-name walk, 240 B | 0.0166 | 0.0171 |
| mlly findStaticImports, 240 B | 0.0198 | 0.0203 |
| mlly detectSyntax, 240 B | 0.00042 | 0.00041 |
| mlly findExports, 2,720 B | 0.344 | 0.346 |
| acorn.parse, 2,720 B | 0.190 | 0.189 |
| mlly findExports, 34,608 B | 11.17 | 10.89 |
| acorn.parse, 34,608 B | 10.33 | 10.20 |
| acorn.tokenizer drain, 240 B | 0.0099 | 0.0101 |
| mlly resolvePathSync | 0.0165 | 0.0169 |
On the small ESM file acorn.parse was about 2.7× faster than mlly findExports. On the 34 KB synthetic module they were nearly tied (about 11.0 vs 10.3 ms). detectSyntax stayed under 0.0005 ms because it is a regex scan, not a full parse. For loader/register paths next to module analysis, see esbuild-register vs jiti; for compiler/parser stacks, swc vs esbuild/babel.
What does each one cost to install and bundle?
| Package / entry | Minified | gzip | Input files | Alone on disk |
|---|---|---|---|---|
| mlly (findExports + imports + detectSyntax + resolvePathSync) | 144,451 B | 42,531 B | 13 | 1,572 KB (mlly + acorn + pathe + pkg-types + ufo) |
| mlly findExports only | 130,782 B | 37,764 B | 13 | — |
| acorn (parse + tokenizer) | 121,656 B | 34,173 B | 2 | 604 KB (1 package) |
mlly's install pulls its dependency tree; acorn alone is about 2.6× smaller on disk here. Bundle gzip still favors acorn by about 8 KB for the entries above. That gap matters the same way size locks do in size-limit vs bundlesize and when you ship analyzers via tsup vs unbuild.
Do they behave the same?
| Check | mlly 1.8.2 | acorn 8.19.0 |
|---|---|---|
| Export names on small fixture | VERSION, greet, pathUtil, default, App | VERSION, greet, default, pathUtil |
| findExports detail | typed rows (declaration / named / default) with start/end | full Program AST; you walk Export* nodes |
| Static imports | findStaticImports → node:fs/promises, pathe | ImportDeclaration nodes on the AST |
| detectSyntax ESM-only | hasESM true, hasCJS false | N/A (parse with sourceType module) |
| detectSyntax CJS-only | hasCJS true | parse as script/module separately |
| Mixed import + module.exports | isMixed true | valid only if you choose sourceType carefully |
| Path resolve | resolvePathSync to absolute file URL/path | not in scope (parser only) |
| Dependency | depends on acorn (^8.16), uses tokenizer | zero runtime deps |
mlly's findExportNames listed App from export default class App while the simple AST walk only recorded default — a reminder that regex/export-shape helpers and a minimal walker disagree on edge cases. Path helpers that sit next to resolvePath are covered in pathe vs node:path; URL shaping next to mlly's ufo dependency is in ufo vs url. Unused-export cleanup after analysis pairs with knip vs depcheck.
Which one should you pick?
| Situation | My pick |
|---|---|
| List exports/imports from source text without an AST | mlly |
| Need Program AST, plugins, or custom visitors | acorn |
| Fastest parse of medium/large ESM on this box | acorn (about 2.7× on 240 B; near-tie at 34 KB) |
| Syntax kind scan only (ESM vs CJS) | mlly detectSyntax |
| Resolve a specifier to a path | mlly resolvePath / resolvePathSync |
| Already writing estree transforms | stay on acorn (or add a walker) |
Bottom line: on this box mlly 1.8.2 findExports took about 0.046 ms on a 240 B file versus acorn 8.19.0 parse at about 0.017 ms, while mlly's analyzed bundle gzipped to 42.5 KB against acorn's 34.2 KB. Who should not switch to raw acorn: tooling authors who want findExports / detectSyntax / resolve without maintaining AST walks. Who should not pull all of mlly for a single parse: teams that already own an estree pipeline and only need parse + walk.
How this was made: I installed both packages on the ShopperCove box, timed findExports / imports / detectSyntax / resolve against acorn.parse / tokenizer / a small export walker over three fixtures × 9 rounds in two processes, bundled each entry with esbuild, and compared install trees. 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/mlly
- https://github.com/acornjs/acorn
- https://www.npmjs.com/package/mlly
- https://www.npmjs.com/package/acorn
- https://esbuild.github.io/
Related
- https://www.shoppercove.com/blog/esbuild-register-vs-jiti
- https://www.shoppercove.com/blog/swc-vs-esbuild-babel
- https://www.shoppercove.com/blog/terser-vs-esbuild-swc-minify
- https://www.shoppercove.com/blog/pathe-vs-node-path
- https://www.shoppercove.com/blog/ufo-vs-url
- https://www.shoppercove.com/blog/size-limit-vs-bundlesize
- https://www.shoppercove.com/blog/tsup-vs-unbuild
- https://www.shoppercove.com/blog/knip-vs-depcheck
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~03:50 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 20.19.2). mlly 1.8.2 vs acorn 8.19.0; esbuild 0.28.2 node ESM minify + gzip -9. Fixtures 240/2,720/34,608 B. Median of 9 rounds × 5,000/2,000/500: findExports 0.0456/0.0459 vs parse 0.0176/0.0173; medium 0.344/0.346 vs 0.190/0.189; large 11.17/10.89 vs 10.33/10.20. detectSyntax 0.00042/0.00041; findStaticImports 0.0198/0.0203; tokenizer 0.0099/0.0101; resolvePathSync 0.0165/0.0169. Bundle gzip mlly 42,531 B vs acorn 34,173 B; install 1,572 vs 604 KB. Behavior: findExportNames listed App + default; simple AST walk listed default only. Not tested: TS/JSX plugins, magicast/recast, Bun/Deno. No affiliate.