# How small and fast can a TypeScript CLI get?

*Can a TypeScript CLI match the binary size and startup speed of one written in Zig, Rust, or Go?*

Engineering | 2026-09-21T14:34:20.953Z

I wanted to know whether a CLI written in TypeScript can be as small and as quick to start as one written in Zig, Rust, or Go, without the "for JavaScript" qualifier\. A binary you could ship next to gh or fx without apologising for it\.

The conventional answer is no\. A TypeScript CLI either needs Node on the machine, like Wrangler and the published vercel package, or carries a JavaScript engine inside the binary, like Claude Code and OpenCode with bun build \-\-compile\. When teams want to escape both, they rewrite: OpenAI moved Codex CLI from TypeScript to Rust, and Vercel's fx agent is written in Zig\.

[Scriptc](https://github.com/vercel-labs/scriptc) suggested a fourth answer\. It compiles ordinary TypeScript to a native executable with no JavaScript engine in the output; on my machine, with version 0\.0\.36, a hello world comes out at 70 KB\. I wanted to know what that costs for a CLI with OAuth, a system keychain, an interactive picker, and error reporting, so I built one: inth, the CLI for Inth's organizations, projects, Code Audit, and Inbox\. It is a production CLI on a 0\.0\.x compiler, and this post is the accounting: what it cost, and what would make us take it back\.

## Against CLIs you already have installed

Here is \-\-version, the cheapest thing a CLI can do, across whatever was on my machine on 21 September: an Apple M5 Pro on macOS 27\.0, Node 24\.21, Bun 1\.3\.11\. Thirty interleaved runs after ten warmups, CI=1, telemetry disabled wherever there is a switch\. With 30 samples the p95 is roughly the second\-slowest run, so read it as a tail check rather than a statistic\. The first three rows are empty programs that print a string and exit, one per runtime\. Sizes are decimal megabytes\.

| Program | Runtime | Binary | Median | p95 |
| --- | --- | --- | --- | --- |
| hello world | Scriptc 0\.0\.36 | 70 KB | 1\.8 ms | 2\.5 ms |
| hello world | Bun 1\.3\.11, \-\-compile | 61 MB | 8\.2 ms | 9\.9 ms |
| hello world | Node 24\.21 | needs Node | 22\.8 ms | 25\.2 ms |
| fx 0\.0\.8 | Zig | 6\.3 MB | 2\.2 ms | 2\.5 ms |
| inth | Scriptc | 2\.0 MB | 3\.6 ms | 4\.5 ms |
| unkey 2\.0\.150, bare binary | Go | 10 MB | 4\.3 ms | 6\.3 ms |
| claude 2\.1\.278 | Bun 1\.4\.3 | 218 MB | 6\.8 ms | 7\.5 ms |
| codex 0\.155\.1, bare binary | Rust | 229 MB | 8\.1 ms | 9\.1 ms |
| inth, source under Bun | Bun 1\.3\.11 | needs Bun | 12\.0 ms | 14\.3 ms |
| inth, \-\-compile \-\-bytecode | Bun 1\.3\.11 | 68 MB | 19\.3 ms | 23\.9 ms |
| vercel 59\.16\.0 | Node | needs Node | 25\.8 ms | 28\.6 ms |
| inth, \-\-compile | Bun 1\.3\.11 | 63 MB | 26\.6 ms | 31\.7 ms |
| inth, via npm launcher | Node, then Scriptc | needs Node | 27\.7 ms | 35\.0 ms |
| codex, via npm shim | Node, then Rust | needs Node | 32\.3 ms | 36\.3 ms |
| gh 2\.101\.0 | Go | 40 MB | 33\.4 ms | 37\.3 ms |
| unkey, via npm launcher | Node, then Go | needs Node | 50\.3 ms | 63\.8 ms |
| wrangler 4\.131\.1 | Node | needs Node | 267\.4 ms | 280\.5 ms |

The bare inth binary is 2 MB and prints its version in 4 ms, between fx and unkey\. Through the npm launcher that users currently run, it takes 28 ms\.

This is a comparison of specific executables, not of languages\. inth got a hand\-written C layer for its OS adapters and a startup\-profiling pass, and I do not know what the other CLIs did or did not do\. The hello\-world rows are context rather than a floor to subtract: Claude Code ships Bun 1\.4\.3 and my empty program was built with 1\.3\.11, so the two are not directly comparable\. The 1\.8 ms for an empty Scriptc program is mostly the cost of spawning any process from a Node parent\. Wrangler's 267 ms and gh's 33 ms are what I observed; I have not profiled either, so I can report the numbers but not the causes\.

The three Bun rows for inth span 12 to 27 ms for identical source, so how you build for Bun matters more than that you build for Bun\. More on that below\.

Three CLIs in the table are native binaries distributed through npm: inth, Codex, and unkey\. Each one appears twice, once as the bare binary and once through the Node script that npm install \-g puts on your PATH\. The launcher costs inth 24 ms, Codex 24 ms, and unkey 46 ms, on top of binaries that take 4 to 8 ms\. I did not know inth had this problem until the benchmark harness told me\. Fixing it is next, and the launcher row stays in pnpm bench so it cannot hide again\. The harness for this table is pnpm bench:cross in the repo; it measures whichever of these CLIs it finds on the machine and skips the rest\.

## The control group

The cross\-CLI table compares programs that do different things\. To compare like with like, the repo builds the same CLI several ways from one source tree:

- The production binary, compiled statically by Scriptc from src/inth\.ts\.
- A Node reference in experiments/node/, the same shared code with ordinary Node adapters\.
- That reference under Bun three ways: run from source, compiled with bun build \-\-compile, and compiled with \-\-compile \-\-bytecode \-\-format=esm\. Bun ran the Node reference unchanged\.
- A yao\-pkg build, which packages the reference with an embedded Node 24 runtime, the way vc\-native does\.
- The published npm launcher, run\-published\.js, staged the way npm install \-g @inth/cli lays it out, in front of the production binary\.

They share argument parsing, the OAuth flow, organization selection, resource formatting, and help text\. The runtime adapters differ: the Node and Bun builds use Clack, Zod, and a NAPI keyring addon, and the Scriptc build uses C I wrote\. The Node reference also lacks the production build's MCP and telemetry integration\. So this compares the shared command paths of one program rather than feature\-identical binaries, and it does not isolate the compiler\.

Fresh\-process \-\-help on an Apple M5 Pro, 100 interleaved runs with warm caches, from pnpm bench:

| Target | Binary | Median | p95 |
| --- | --- | --- | --- |
| Scriptc | 2\.0 MB | 5\.0 ms | 5\.7 ms |
| Bun 1\.3\.11, running the source | needs Bun | 13\.0 ms | 14\.4 ms |
| Bun, \-\-compile \-\-bytecode | 68 MB | 20\.8 ms | 23\.5 ms |
| Node 24\.21 | needs Node | 27\.2 ms | 29\.9 ms |
| Bun, \-\-compile | 63 MB | 27\.6 ms | 29\.6 ms |
| Scriptc, via npm launcher | needs Node | 28\.9 ms | 31\.7 ms |
| yao\-pkg 6\.22, embedded Node | 123 MB | 32\.0 ms | 34\.6 ms |

Compiled with defaults, Bun is no faster than Node, and running the same source with bun inth\.js is twice as fast as the compiled binary\. \-\-help from source loads a dozen small modules; the compiled binary embeds one 1 MB bundle containing Zod, Clack, and every command\. Running that bundle as a plain file under Bun reproduced the slowdown, so the difference follows the bundled program rather than the executable packaging\. I have not separated parsing cost from module initialization\. Bytecode, which skips parsing, recovers about half of the gap but does not get back to running from source\. yao\-pkg is slower than Node, and I have not profiled that either\.

Two workload benchmarks in the repo exercise more of the program than help does\. Both are fixtures: the auth benchmark replays a recorded OAuth exchange from memory, with real macOS Keychain entries and the production credential locks but no network; the picker benchmark renders the organization picker in a PTY from fixture data, with authentication and HTTP removed\. They predate the Bun targets, so they cover the three original builds:

| Target | Auth cycle | Launch to picker |
| --- | --- | --- |
| Scriptc | 35\.7 ms | 5\.5 ms |
| Node | 92\.4 ms | 54\.8 ms |
| yao\-pkg | 65\.0 ms | 63\.9 ms |

For scale, the production binary reaches the same picker in 537 ms median when it runs for real, with credential lookup, token refresh, and the organizations request in front of it\.

My native terminal adapter has a faster median redraw after an arrow key than Clack's but a slower tail, 3\.2 ms p95 against 1\.5\. It reads input through a bounded poll\(\) loop rather than keypress events, which is where I would look first, but I have not traced the delay\. Ten times faster to appear, occasionally a couple of milliseconds slower to respond\.

## What static compilation actually demands

Scriptc compiles statically by default\. It type\-checks with the real TypeScript compiler, then lowers what it understands to native code\. Anything it cannot lower is a compile error with an SC\-prefixed code\. For npm packages' shipped JavaScript and any\-typed code there is \-\-dynamic, which embeds a 620 KB QuickJS engine to run them\.

I built without \-\-dynamic, because an engine in the binary was the thing I was trying to avoid\. The dependencies I wanted did not compile unchanged in static mode, so I replaced their production usage, adapted upstream source, or delegated to an external process\. The production binary contains no JavaScript from npm\. What each library became:

- Zod became checked JSON casts\. Scriptc validates JSON\.parse\(text\) as Credentials against the record's field types at runtime, as a compiler feature\. I added semantic checks on top, abridged here:

```ts
export const parseCredentials = (text: string): Credentials => {
  // SAFETY: Scriptc validates the exact record's field types at this checked JSON boundary.
  const value = JSON.parse(text) as Credentials;
  if (
    typeof value.access_token !== "string" || !value.access_token ||
    typeof value.refresh_token !== "string" || !value.refresh_token ||
    !positive(value.expires_at)
  ) {
    throw new Error("Invalid saved credentials.");
  }
  return value;
};
```

- Clack became native\-terminal\.c: termios raw mode, poll\(\) with bounded waits, and signal handlers that restore the terminal and re\-raise so Scriptc's own cancellation handler still runs\.
- The NAPI keyring addon became native\-secret\-macos\.c on Security\.framework, native\-secret\-linux\.c on libsecret, and Credential Manager on Windows\. Credential bytes cross the FFI boundary through a call\-scoped callback rather than a returned buffer\.
- proper\-lockfile became a nonblocking flock on POSIX and LockFileEx on Windows, rejecting symlinks and unsafe permissions\.

The first\-party C is about 1,300 lines across eleven files, compiled with \-Wall \-Wextra \-Werror, and declared to Scriptc through a generated ffi\.json manifest of 21 functions\. The binary also carries vendored C that is not mine: a 2,400\-line TOML parser \(tomlc99\) for MCP config files, and Sentry's C SDK, which gets its own section\. On the TypeScript side each native function is a bare declaration, and calling it looks like calling anything else \(abridged\):

```ts
// native-bindings.ts
export declare function secretRead(
  service: string,
  account: string,
  receive: (value: string) => void
): number;

// native-keychain.ts
const status = secretRead(this.service, this.account, (received) => {
  value = received;
});
if (status === -25_300) return null;
```

Most of this C bridges OS APIs that Scriptc's Node compatibility layer does not expose: Security\.framework, libsecret, Credential Manager, termios, flock\. It exists for compatibility; I have not shown that writing these adapters in C makes them faster\. A few smaller files, for HTTP date parsing, URL validation, and TOML checks, are there because it was quicker than fighting the compiler\.

Zod's replacement is TypeScript, because validation does not need the OS\. Had a patched Zod compiled, it would have been native code too, and I have no reason to think it would have been slower than my hand\-written checks\. Where I can measure it, the C terminal adapter is slower in the tail than Clack, because it polls\.

## Vendoring with inrepo, and what it could not fix

Rewriting every dependency does not scale, so I spent real time on the alternative: vendoring upstream source and compiling it as part of the program\.

That is what [inrepo](https://github.com/inthhq/inrepo) is for\. It pins an upstream git repository to a commit, applies your committed patches on top, and regenerates a local package with inrepo sync; inrepo verify catches drift\. There are no submodules or forks to maintain\. Scriptc has experimental flags for compiling shipped npm JavaScript directly, but for anything that needs changes, vendoring the TypeScript source and patching it is the workable path, and inrepo is how the patches stay reviewable\. I first tried it on Commander and Picocolors\. Seven patches later, including removing some of Commander's API surface, the result is a working static binary, and that is now inrepo's example\.

Then I ran three assessments against dependencies I actually wanted\. Each is a script you can rerun, and each writes an assessment\.json with the compiler's diagnostics\. None of them ended in a patch\.

Zod fails on a circular import between two of its core files\. That is the kind of thing a two\-file patch fixes, and I probably could have\. But it was the first diagnostic, not the last, and the checked casts already did the job, so a Zod port became optional work with an unknown price\.

The skills CLI \(vercel\-labs/skills\) fails harder: hundreds of diagnostics across the installer and several of its dependencies, from prompt libraries to Node APIs that have no lowering yet\. Patching that is a fork, so inth skills keeps a native picker for the catalog and shells out to a pinned npx \-\-yes skills@1\.5\.25, with INTH\_TOKEN stripped from the child's environment\.

## Sentry without TypeScript stack traces

I vendored @sentry/core 10\.73\.0 and its dependency @sentry/conventions\. The source verified and typechecked, and the compiler rejected it three ways\. It could not resolve the @sentry/conventions/attributes import through the vendored alias, which is probably configuration\. The SDK's utilities reference window, Element, and Window, because Sentry's core is shared between browser and Node and detects its environment with typeof window \!== 'undefined'; Scriptc has no window to be undefined, so each of those checks is a compile error\. And a one\-line probe that reads error\.stack fails with SC2020: "stack traces are not captured \(frames would need runtime bookkeeping\)"\. That last one settled it\. A TypeScript SDK cannot report a stack the runtime never recorded, so a perfectly compiling @sentry/core would send events with no frames\.

So the CLI uses Sentry's C SDK, sentry\-native, through Scriptc's typed FFI, pinned to 0\.16\.2 and built as a static archive with crash handlers and the SDK's own networking disabled\. It initializes only after an unexpected error, so most invocations never touch it\. C builds the envelope and hands the bytes to TypeScript, which sends them with fetch under a one\-second timeout\. A scrub\_event hook in C rebuilds the event from an allowlist, and reports carry a fixed diagnostic code rather than the original error text, because error messages can contain tokens and paths\. The details are in native\-sentry\.c\.

The stack problem does not go away\. The native frames show where I called sentry\_capture, not where the TypeScript threw\. Every event carries stack\_origin: report\_site and original\_stack\_available: false in its cli context, and grouping uses command, exception type, diagnostic code, and last recorded operation instead of the stack\. It is a weaker signal than a throw site and far better than nothing\.

## The compiler's other opinions

The rest of the compiler's opinions, as of Scriptc 0\.0\.36, are all over the source as lint\-disable comments explaining why the obvious code does not work:

- Error\.cause does not compile, so sanitized HTTP details travel in the message\.
- Named regex capture groups are unsupported\. Indexed captures are fine\.
- array\.push\(a, \.\.\.rest\) cannot mix positional and spread arguments\.
- SC2002 rejects a class instance where a record shape is expected\. Two methods exist only to convert an instance to a plain record, each with a one\-line comment so nobody simplifies them away\.
- Completed timeout signals are retained, so the CLI calls process\.exit explicitly after awaited work instead of letting the event loop drain\.

None of these blocked anything; each cost a small, local workaround\.

One thing that was not a compiler quirk but felt like one: help\.ts built the entire help text at module load, on every invocation\. Making it lazy cut \-\-version from 8\.0 to 6\.1 ms on the build I had then, nearly a quarter of its previous runtime\. When the whole process is a few milliseconds, module\-level work that Node habits treat as free is a large share of the budget\.

## What it cost, and what would make us switch

The ecosystem gap is real\. cargo add keyring sentry clap toml would have replaced most of the first\-party C in this repo, and every OS boundary I wrote by hand has a maintained library in Rust or Go\. If your CLI is going to grow a lot of OS\-facing surface, that is the strongest argument against Scriptc today\.

npm is the toolchain and the distribution channel\. TypeScript, esbuild, Vitest, Playwright, and Scriptc itself arrive through it, and the binary is installed with npm install \-g via per\-platform optional dependencies\. But no JavaScript from npm ends up in the binary, and the three assessments show that bringing a package in is real work every time\. If you pick Scriptc expecting to npm install your way out of problems, you will be disappointed\.

The cheaper exit is partly built\. The shared TypeScript already runs under Node and Bun, the 600 Vitest tests run on plain Node, and the Bun binaries in the tables are that exit as it stands today\. What is not built is the product around it: the Node reference has its own credential store, no MCP or telemetry integration, and its adapters are tested as experiments rather than as production code\. If Scriptc stalled before 1\.0, we would finish that work and lose the 2 MB and the 4 ms, and keep the product\.

The expensive exit is a rewrite\. That happens if the OS\-facing surface outgrows what a small C layer can carry, or if a runtime gap that no adapter can fake, like stack traces, stops being tolerable\. The shared TypeScript does not carry over to a systems language and most of the C adapters do not either\. We would pick the language when we got there, based on whose libraries cover the gap at the time\.

For a CLI of this shape, the trade was a 2 MB binary that starts alongside the Zig and Go ones, for about 1,300 lines of first\-party C, two vendored C libraries, and errors with no original stack trace\. Everything in this post is in the repo at [inthhq/inth](https://github.com/inthhq/inth): the builds, pnpm bench with its results, the auth and picker benchmarks with their scripts, and the three vendoring assessments\. The cross\-CLI \-\-version table is a one\-off run; the versions and flags are listed so you can repeat it on your own machine\.