Plate 33
magicast vs recast: 0.65 ms vs 0.42 ms per Vite Edit
Aditya Challa8 min read
I timed magicast 0.5.5 against recast 0.24.0 on one codemod job, adding a Vite plugin import plus a plugins entry and changing server.port in a 278 B vite.config.ts: 0.65 ms vs 0.42 ms per edit in run 1 and 0.55 ms vs 0.41 ms in run 2 (1.35× to 1.53×). The magicast version took 4 lines of code against 10 for hand-written recast, and on a 3,627 B config the two were within 10% of each other.
Short answer: pick magicast when you edit config files (vite.config, nuxt.config) from a CLI or setup script and want a proxy API plus ready helpers like addVitePlugin. Pick recast when you need full control over where nodes land, want byte-identical round trips, or are writing a general codemod. magicast ships a vendored copy of recast inside its own dist, so this is a choice of API level, not of engine. 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:10 IST.
- Versions: magicast 0.5.5 and recast 0.24.0 (both npm latest that day), @babel/parser 7.29.9 for recast's babel-ts parser, Node 20.19.2, esbuild 0.28.2 for bundle sizes.
- Workload: three fixtures. A 278 B vite.config.ts with comments and no semicolons, a 3,627 B nuxt-style config with 8 modules, 24 runtime flags and 20 route rules, and an 18,316 B route.tsx from TanStack Router.
- Jobs: parse only; config edit (add import Inspect from 'vite-plugin-inspect', push Inspect() into plugins, set server.port to 3000, then print); and on route.tsx, add one import and print. magicast used parseModule, addVitePlugin, getDefaultExportOptions and generateCode. recast used recast.parse with recast/parsers/babel-ts, ast-types builders and recast.print with single quotes.
- Timing: median of 9 rounds (2,000 / 300 / 60 iterations by size), two processes with the run order flipped. Scripts, outputs and JSON in /workspace/bench-magicast-recast/.
- Also measured: cold import and first edit (11 fresh processes), esbuild bundle size, fresh npm install size, line diffs of the output, round-trip fidelity, and rerun safety.
- Not tested: magicast loadFile / writeFile on disk, its parseExpression and builders beyond the helper, recast with esprima or flow parsers, jscodeshift, Bun and Deno, and configs exported through a variable.
How fast is magicast vs recast?
| Job (median ms, run 1 / run 2) | magicast 0.5.5 | recast 0.24.0 |
|---|---|---|
| Parse vite.config.ts, 278 B | 0.169 / 0.151 | 0.215 / 0.127 |
| Edit + print vite.config.ts | 0.649 / 0.550 | 0.424 / 0.406 |
| Parse nuxt.config.ts, 3,627 B | 1.857 / 1.825 | 1.689 / 1.652 |
| Edit + print nuxt.config.ts | 4.426 / 4.517 | 4.118 / 4.487 |
| Parse route.tsx, 18,316 B | 8.503 / 7.392 | 7.060 / 7.114 |
| Add import + print route.tsx | 16.79 / 18.05 | 15.91 / 16.70 |
Parsing was close because both run @babel/parser underneath. If you only need to read exports, not rewrite them, see mlly vs acorn. The gap on the small config came from magicast's proxy layer and helper work, and it shrank to 1% to 8% on the larger config and to 5% to 8% on route.tsx. For a setup CLI that edits one config once, both finished well under a millisecond on the Vite file. If you are choosing the CLI framework around that edit, my citty vs commander run covers the startup side.
Which one starts faster?
| Cold start (median of 11 processes, Node 20.19.2) | magicast | recast + babel-ts |
|---|---|---|
| Import only | 74.76 ms | 60.45 ms |
| Import + first vite.config.ts edit | 99.34 ms | 84.94 ms |
recast started about 14 ms sooner in a fresh process. In a one-shot npx scaffolder that is the number users feel more than the per-edit time. For CLIs that also detect the user's package manager before writing files, see pm-detector vs which-pm.
What does each one cost to install and bundle?
| Entry | esbuild minified | gzip -9 | Input files | Fresh npm install |
|---|---|---|---|---|
| magicast (parseModule, generateCode, two helpers) | 500,529 B | 127,685 B | 6 | 6,012 KB, 6 packages |
| recast + recast/parsers/babel-ts | 657,135 B | 160,904 B | 61 | 8,200 KB, 10 packages (with @babel/parser) |
| recast alone (default esprima parser) | — | — | — | 2,800 KB, 6 packages |
magicast came out smaller once recast had a TypeScript-capable parser installed, because magicast vendors its own copy of recast and pulls only @babel/parser, @babel/types and source-map-js. The recast bundle also kept esprima, its default parser, even though the babel-ts parser was used. If you ship either inside a CLI, tsup vs unbuild covers the build step.
Do they write the same output?
| Check (vite.config.ts edit) | magicast 0.5.5 | recast 0.24.0 |
|---|---|---|
| Lines added / removed | +3 / −5 (16 to 14 lines) | +4 / −4 (16 to 16 lines) |
| Where the new import landed | line 1, above the header comment | line 5, after the last import, with a blank line |
| Multi-line plugins array | collapsed to plugins: [vue(), Inspect()] | collapsed to plugins: [vue(), Inspect()] |
| // comments kept | yes (2 of 2) | yes (2 of 2) |
| Quote style | single, matched the file | single (quote: 'single' set by me) |
| New import line | ends with a semicolon in a no-semicolon file | ends with a semicolon in a no-semicolon file |
| Trailing newline | dropped | kept |
| Parse + print with no edits | identical except the final newline | byte-identical on all 3 fixtures |
| Code I wrote for the edit | 4 lines | 10 lines |
Both left a diff that a formatter would need to clean up: the collapsed array and the stray semicolon. I did not run a formatter in this bench; my dprint vs Prettier comparison covers that step. When the edit is merging plain objects rather than rewriting source, defu vs lodash.merge is the lighter path.
What breaks when the config is unusual?
| Case | magicast 0.5.5 | recast (my hand-written edit) |
|---|---|---|
| defineConfig(({ mode }) => ({ ... })) function config | addVitePlugin returned true but only added the import; plugins unchanged; server.port line threw TypeError | threw TypeError (my code expected an object) |
| Run addVitePlugin twice on one module | threw MagicastError: Changing import name is not yet implemented | n/a |
| Run the edit again on its own output | threw the same MagicastError | wrote a second import and a second Inspect() |
| Plugin options object | addVitePlugin options produced Inspect({ ... }) | build the object node yourself |
The function-config case is the one to watch: magicast reported success while the plugin never reached the plugins array. Check the generated code for the plugin call before you write files. If your setup tool also reads env or rc files next to the config, confbox vs dotenv covers that layer.
Which one should you pick?
| Situation | My pick |
|---|---|
| Add a plugin or module to vite.config / nuxt.config from a CLI | magicast |
| Precise control of insert position and formatting | recast |
| Byte-identical round trip on untouched files | recast |
| Shortest code for common config edits | magicast (4 vs 10 lines here) |
| One-shot scaffolder where startup matters | recast (about 14 ms faster cold) |
| TypeScript parser bundled without extra installs | magicast (6,012 vs 8,200 KB) |
| General AST codemods across many files | recast, or a tool built on it |
Bottom line: on this box magicast 0.5.5 took about 0.55 to 0.65 ms for a Vite config edit vs recast 0.24.0 at about 0.41 to 0.42 ms, with near-equal times on larger files, and it needed 4 lines of code instead of 10. Who should not switch to raw recast: setup CLIs that only touch standard config shapes. Who should not rely on magicast helpers alone: tools that must handle function configs or rerun safely, since I saw a silent partial edit and a thrown MagicastError on rerun. Type-checking after the edit is a separate step; see vite-plugin-checker vs tsc.
How this was made: I installed both packages on the ShopperCove box, timed parse, edit and print across three fixtures over 9 rounds in two processes, checked cold start in 11 fresh processes, bundled each entry with esbuild, ran fresh npm installs for size, and diffed every output file against its source. The JSON results and generated files sit next to the scripts. The write-up was drafted with AI help and checked against that output.
Sources
- https://github.com/unjs/magicast
- https://github.com/benjamn/recast
- https://www.npmjs.com/package/magicast
- https://www.npmjs.com/package/recast
- https://babeljs.io/docs/babel-parser
- https://esbuild.github.io/
Related
- https://www.shoppercove.com/blog/mlly-vs-acorn
- https://www.shoppercove.com/blog/citty-vs-commander
- https://www.shoppercove.com/blog/pm-detector-vs-which-pm
- https://www.shoppercove.com/blog/tsup-vs-unbuild
- https://www.shoppercove.com/blog/dprint-vs-prettier
- https://www.shoppercove.com/blog/defu-vs-lodash-merge
- https://www.shoppercove.com/blog/confbox-vs-dotenv
- https://www.shoppercove.com/blog/vite-plugin-checker-vs-tsc
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~04:10 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 20.19.2). magicast 0.5.5 vs recast 0.24.0 + recast/parsers/babel-ts with @babel/parser 7.29.9; esbuild 0.28.2. Fixtures 278 B vite.config.ts, 3,627 B nuxt-style, 18,316 B route.tsx. Median of 9 x 2,000/300/60, two processes: Vite edit 0.649/0.550 vs 0.424/0.406; nuxt 4.426/4.517 vs 4.118/4.487; route import 16.79/18.05 vs 15.91/16.70; parse within ~27%. Cold import 74.76 vs 60.45 ms, first edit 99.34 vs 84.94. Bundle gzip 127,685 vs 160,904 B (recast kept esprima); install 6,012 vs 8,200 KB. Output: magicast +3/-5 lines, import at line 1, newline dropped; recast +4/-4, byte-identical round trip. Function config: addVitePlugin returned true, plugins unchanged. Rerun: MagicastError. Code 4 vs 10 lines. Not tested: loadFile/writeFile, jscodeshift, Bun/Deno. No affiliate.