Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Compiling a real-world CLI tool to native with ...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

Compiling a real-world CLI tool to native with Static Hermes

Most solutions that "compile" JavaScript actually package an application together with a JavaScript engine. The code is still interpreted or JIT-compiled at runtime. Static Hermes takes a different approach: it ahead-of-time compiles the application itself into native machine code. Using the Flow bundler as a case study—a substantial Node.js application that relies on Node APIs, Babel, and even a WebAssembly module—we'll explore how a real-world JavaScript application can be transformed into a single native executable. Along the way, we'll discuss the technical challenges, the tradeoffs, and what this means for the future of JavaScript tooling and deployment.

Avatar for Tzvetan Mikov

Tzvetan Mikov

October 09, 2026

More Decks by Tzvetan Mikov

Other Decks in Programming

Transcript

  1. Compiling a real-world CLI tool to native with Static Hermes

    Tzvetan Mikov · creator of Hermes, Meta next.app devCon 2026 · Berlin
  2. Why do this at all? Take an unmodified Node app

    and compile it to native code, like Go or Swift. Because it's fun. And to close the gap between JavaScript and native languages: What if we had JavaScript's ergonomics and native performance? What if we could choose native or interpreted per piece of code, and interpret only where we need to, like OTA updates?
  3. What “compiling JavaScript” usually means prebuilt runtime binary + your

    app as a blob → “compiled” app Node SEA: a blob injected into a copy of node, optionally with a V8 code cache or a heap snapshot Deno compile: your module graph embedded in denort, a stripped runtime Bun --compile: a bundle in a copy of bun; with --bytecode, JavaScriptCore bytecode beside the source All three run your code on the same interpreter and JIT. Where there is bytecode, it is a startup cache tied to the engine version.
  4. The goal Run real, unmodified Node programs on Hermes. Compile

    them ahead of time, to bytecode and then to native machine code. Ship one binary with nothing beside it. All of it applies to React Native, and bringing it there is the goal.
  5. The road ahead Feature What it makes possible N-API Node's

    own code runs Compile cache fast restarts Bundle ship one file Linked executable ship one binary Wasm, cached and baked modern dependencies without the startup cost x86-64 and specializing JIT fast dynamic code on every platform build-native JS as machine code Native Wasm nothing left to interpret Throughout, no new APIs, so existing Node code runs as it is.
  6. The elephant in the room: AI Built with AI AI

    agents implemented much of hermes-node, under my direction. Built for AI hermes-node adds no new APIs. It behaves like Node and runs Node's own code. Code that AI writes from the Node ecosystem runs unchanged.
  7. How I work with AI Claude Code Linux servers and

    no orchestrators on top agents run there, not on my laptop Spec → plan → code → review for every feature Codex Worktrees One agent per feature adversarial review after each step independent features run in parallel unless Claude decides on its own to split the work
  8. What it costs, and where it stops Agents are slow,

    so I juggle several features to avoid waiting. Context switching is the hard part. Sometimes I just can't face the switch, and I procrastinate. I understand every design decision, and that gets harder as the number of parallel features grows. Today's models copy, adapt and fix well. Genuinely new work still needs a detailed design and plan from me. I expect that to change.
  9. Engines and runtimes Engine runs the language. Runtime = engine

    + APIs + event loop. Runtime Engine Chrome V8 Node, Deno V8 Bun, Safari JavaScriptCore React Native Hermes hermes-node Hermes Node's APIs are the de facto standard outside the browser: require/import resolution, fs, process, Buffer, streams, plus web standards like URL, fetch, TextEncoder, AbortController. Deno and Bun both ended up implementing Node compatibility. Static Hermes: Hermes's ahead-of-time compiler. It compiles JavaScript, typed or untyped, through C to native code.
  10. The problem We want Node's own libraries running on Hermes.

    lib/*.js → internalBinding() → src/node_*.cc → The options Reimplement Node's APIs from scratch Emulate the V8 API on Hermes (node-chakracore took this route) Rewrite the bindings in JSI Port the bindings to N-API V8 C++ API
  11. JSI and N-API JSI N-API C++, React Native's engine interface

    C ABI, Node's native addon interface auto add = jsi::Function::createFromHostFunction( rt, jsi::PropNameID::forAscii(rt, "add"), 2, [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, size_t) { return jsi::Value( args[0].asNumber() + args[1].asNumber()); }); static napi_value add(napi_env env, napi_callback_info info) { size_t argc = 2; napi_value args[2]; napi_get_cb_info(env, info, &argc, args, NULL, NULL); double a, b; napi_get_value_double(env, args[0], &a); napi_get_value_double(env, args[1], &b); napi_value result; napi_create_double(env, a + b, &result); return result; }
  12. The choice: N-API V8 · Node's src/node_os.cc (abridged) static void

    GetHostname( const FunctionCallbackInfo<Value>& args) { Environment* env = Environment::GetCurrent(args); char buf[UV_MAXHOSTNAMESIZE]; size_t size = sizeof(buf); int r = uv_os_gethostname(buf, &size); if (r != 0) { /* CollectUVExceptionInfo */ return; } Local<Value> ret; if (String::NewFromUtf8(env->isolate(), buf) .ToLocal(&ret)) args.GetReturnValue().Set(ret); } N-API · hermes-node lib/bindings/node_os.cpp static napi_value getHostname(napi_env env, napi_callback_info info) { char buf[UV_MAXHOSTNAMESIZE]; size_t size = sizeof(buf); int r = uv_os_gethostname(buf, &size); if (r != 0) { /* set ctx error */ napi_value u; napi_get_undefined(env, &u); return u; } napi_value result; napi_create_string_utf8(env, buf, size, &result); return result; } npm's native addons are built against N-API, so a Node-compatible runtime needs N-API no matter what. Node's own bindings port to it mechanically: Local<Value> → napi_value · FunctionCallbackInfo → napi_callback_info · handle scopes → handle scopes Built for hermes-node and upstreamed into Hermes. One API serves Node's bindings and npm's addons.
  13. The Flow bundler Bundles a program's modules into one file

    and keeps the type annotations. Other bundlers strip types first, and Static Hermes needs them to generate typed code. What makes it hard to run Flow-typed ESM, loaded through a Babel register hook About 1,500 JS modules: Babel, prettier, hermes-transform and more Parses with hermes-parser, a WebAssembly module 2 fixture apps ↓ Flow bundler ↓ 6 bundles each app unmodified, Flow-stripped, and stripped and lowered to ES5 classes · byte-identical to upstream
  14. ditz2 An issue tracker that keeps issues as Markdown files

    in your repo About 6,200 lines of TypeScript, ESM Full-screen TUI built with Ink and React, on yoga's WebAssembly layout engine The UI alone pulls in 38 packages, about 23 MB
  15. What real code uncovered Gaps the tests missed fs.rmSync({force}) crypto.randomUUID

    No ESM loader yet: both programs recompiled to CommonJS, no source patched No Wasm yet ditz2's UI couldn't run The Flow bundler's parser is a Wasm module, so I built a native NAPI addon of the parser instead. Only the CLI ran. Ink needed: Surprise: under V8, the wasm32 parser was slightly faster than native. 32-bit pointers: smaller data, better cache locality. yoga's Wasm the regex v flag and toplevel await Intl.Segmenter, queueMicrotask, performance
  16. Don't compile twice Hermes compiles ahead of time, optimizer included.

    Built-ins are compiled at build time, but your code and node_modules were compiled again on every launch. Flow bundler, about 1,500 files first run every run after Keep the compiled bytecode on disk. no cache (unoptimized) 6.0 s 6.0 s Keyed by the module's path, checked against the source's length and checksum: an edited file is a miss, never stale code. cache, optimizer off 4.5 s 2.8 s cache, optimizer on 7.4 s 2.05 s Optimizing pays only when the result is kept, so the optimizer is on with the cache and off without it. tsc, the TypeScript compiler from npm, is one 6.2 MB file. tsc --version is nearly all startup (macOS arm64): 553 ms with no cache · 5,674 ms filling the cache · 23 ms after that
  17. The bytecode bundle The cache only helps the machine it

    was filled on. Elsewhere you need hermes-node, the source and node_modules, and the first run compiles everything. Compile the whole program ahead of time into one file. --build-bundle walks the require() graph, compiles every module, and writes one file. --bundle runs it, with nothing compiled at startup and no source tree. ditz2: 540 KB, 140 modules tsc, the TypeScript compiler type-checking a small .ts file, the same program run three ways (macOS arm64) 944 ms 946 ms 2,643 ms bundle, first run, no cache warm cache no cache
  18. The closed world A bundle contains only what the build

    can find. Anything loaded dynamically is missing at run time. The scanner follows require("literal") and recognizes require by its binding, whatever it is named. What it can't see: require('./widgets/' + name) import(UI_PACKAGE) require.extensions hooks Whatever it can't discover, you name: --include=<module> packages it, along with everything it requires. A module that fails to parse is packaged as a stub that throws if required.
  19. The executable: link instead of inject A bundle still needs

    hermes-node installed. Users want a single executable. SEA, Deno, Bun --build-exe patch a copy of their runtime links a new binary from a kit: a static archive plus a manifest On macOS, appending to a Mach-O breaks its signature, and on Apple Silicon an invalid signature gets the process killed. ld64 signs what it links (adhoc,linker-signed) with no extra step. ditz2's CLI: 12.5 MB, runs with no node_modules, no source and no hermes-node.
  20. Wasm in Hermes npm packages increasingly ship WebAssembly, like parsers

    and layout engines. Both test programs need it, and they were a large part of why Hermes got Wasm support. Hermes compiles a Wasm module to Hermes bytecode when it's loaded, before any of it runs. No Wasm interpreter, no Wasm JIT. With Wasm on, the Flow bundler went back to its real parser. The native and Wasm parsers produce the same six bundles.
  21. Wasm and JS, same bytecode The same function in WebAssembly

    and in JavaScript, compiled by hermesc -O add.wat add.js (func (export "add") (param i32 i32) (result i32) (i32.add (local.get 0) (local.get 1))) LoadParam LoadParam AddN ToInt32 Ret r1, 2 r0, 1 r0, r0, r1 r0, r0 r0 function add(a, b) { return (a + b) | 0; } LoadParam LoadParam Add ToInt32 Ret r2, 2 r1, 1 r1, r1, r2 r0, r1 r0 One opcode differs: Wasm knows its inputs are numbers, so it gets AddN. JS has to allow for strings, so it gets the generic Add.
  22. The startup cliff Hermes compiles a whole Wasm module to

    bytecode before its first export can run, and nothing kept the result. hermes-parser, a 665 KB Wasm module node (V8) hermes-node first parse() steady parse() 58 ms 3-4 ms 4,725 ms 26 ms Same fix as for JavaScript: cache the compiled .hbc on disk. Keyed by a SHA-256 of the module's bytes, so the same module hits from any path. Parsing one file: cold 5.03 s, warm 0.07 s. For shipping: --record-wasm notes which modules a run compiles, and --bake-wasm puts their compiled .hbc inside the bundle. Baked in: ditz2's yoga (257 KB). The Flow bundler as a bundle: 6.0 s with its parser compiled at launch, 1.3 s baked.
  23. The JIT on x86-64 AOT can't specialize hot dynamic code,

    and Hermes's JIT only ran on arm64, so on x86-64 everything was interpreted. A full x86-64 backend for the JIT. Every platform hermes-node ships on gets machine code for hot functions. It covers code that AOT never sees: eval, new Function, code built at run time.
  24. Specializing, not speculative baseline JIT → → record types and

    shapes seen recompile with fast paths One property access, o.x, in each design Hermes, specializing if (o.shape == S) else v = o.slot[k] v = getById(o, "x") // fast path, from feedback // slow path, still there Speculative JIT guard o.shape == S v = o.slot[k] else → deoptimize // bail out to the previous tier // and the rest of the function assumes S when the assumption fails specializing speculative correctness unaffected needs deoptimization back to the previous tier cost slower path, same code rebuild the frame, switch tiers complexity / ceiling far simpler, lower ceiling much more complex, higher ceiling
  25. The ladder and its last rung 4 · Bytecode executable

    3 · Bytecode bundle 2 · Compile cache 1 · Source, compiled every run 5 · Native executable build-native
  26. From bytecode to C What shermes does with untyped JavaScript:

    each bytecode-level operation becomes C. function getX(p) { return p.x + 1; } Bytecode (hermesc -O) Generated C (shermes -O) LoadParam r1, 1 locals.t0 = _sh_ljs_param(frame, 1); GetByIdShort r1, r1, 0, "x" locals.t0 = _sh_ljs_get_by_id_rjs_inline(shr, &locals.t0, LoadConstUInt8 r0, 1 Add Ret r1, r1, r0 r1 get_symbols(shUnit)[0] /*x*/, get_read_prop_cache(shUnit) + 7); np0 = _sh_ljs_double(1); locals.t0 = _sh_ljs_add_rjs_inline(shr, &locals.t0, &np0); _sh_leave(shr, &locals.head, frame); return locals.t0;
  27. What the C compiler sees The runtime helpers are static

    inline (from static_h.h), so the C compiler sees their fast paths. // p.x if (SH_LIKELY(_sh_ljs_is_object(*source))) { SHJSObject *obj = (SHJSObject *)_sh_ljs_get_pointer(*source); if (SH_LIKELY(cacheEntry->clazz == obj->clazz)) // shape check against this site's cache return _sh_prload_inline(shr, *source, cacheEntry->slot); } return _sh_ljs_get_by_id_rjs(shr, source, symID, cacheEntry); // slow path // a + b if (SH_LIKELY(_sh_ljs_are_both_non_nan_numbers(*a, *b))) return _sh_ljs_double(_sh_ljs_get_double(*a) + _sh_ljs_get_double(*b)); return _sh_ljs_add_rjs(shr, a, b); // slow path Each property access has its own cache entry, in a static array in the unit. cc inlines the fast paths and optimizes across instruction boundaries.
  28. Results Three untyped programs: bytecode executable → native executable tsc

    ditz2 Flow bundler platform macOS arm64 Linux x86-64 Linux x86-64 run time 940 → 505 ms (−46%) −11% to −19% 1.27 → 1.20 s (−6%) size 16.8 → 44.0 MB 16.9 → 34.6 MB 26.3 → 69.8 MB build 0.1 s link → 103 s seconds → ~2 min 0.4 s link → 53 s tsc on node (V8): 354 ms. But native tsc starts in 18 ms, against node's 75 ms. Node's built-in modules compile natively too (187 of 187). The only JavaScript bytecode left is Hermes's own 1,680-byte extensions unit, plus any baked Wasm. Native doesn't change startup. The startup wins came from the cache, the bundle and baking the Wasm.
  29. The Flow bundler as one native binary 1,229 modules of

    Babel, hermes-transform and the bundler, compiled natively 69.8 MB one executable, run alone: no node_modules, no source, no config file 6 of 6 bundles byte for byte What it took The require hook's work moves to build time: Babel transpiles the bundler's sources first, with the hook's own options. A staging node_modules of symlinks points hermes-parser at its Wasm build, so the native addon never gets packaged. Prettier was half the binary and never called. Stubbed out: 122.7 → 69.8 MB, and a 3.6 min native compile → 53 s.
  30. Wasm compiled to machine code With JS native, baked Wasm

    bytecode was the last code still interpreted. Wasm now compiles to native code, linked into the same binary. The Flow bundler ditz2 hermes-parser's Wasm runs as machine code. The same six bundles. yoga's Wasm runs as machine code, under the full Ink and React UI. One native binary each, with nothing left to interpret.
  31. Two engines in one AOT and the JIT cover different

    code, and a program needs both. AOT native JIT best for startup, shipping, typed code, Wasm hot, dynamic, untyped code sees the whole closed world what actually ran covers everything known at build time eval, new Function, run-time code
  32. The payoff ahead In typed mode, the call to getX(new

    Point(41, 0)) is inlined away: what's left is 41 + 1, and the Point is never allocated. Array loops use direct fast-array loads and compare doubles with a plain <. Against the untyped version of the same functions, the C and the object code are about half the size: 8.6 vs 15.7 KB of C, 7.0 vs 13.1 KB of object code. Typed JS and Wasm gain the most from native compilation.
  33. Thank you! JavaScript can be compiled to machine code ahead

    of time, and the goal is to bring all of this to React Native. github.com/tmikov/hermes-node github.com/tmikov/ditz2 github.com/facebook/hermes