Back to blog

Porting to JS/TS, Part 2: Node.js or Bun as the landing runtime

The last post in this series looked at why JavaScript beat Go, Rust, and Python as Pext's target language. That question settles the language; it does not settle the runtime. JS and TS both run under Node and under Bun, and a team planning a migration still has to land somewhere.

The case for Node

Node is still the default assumption. Every major cloud platform ships first-class Node support, and the surrounding tooling, buildpacks, base container images, CI templates, treats Node as the reference JS runtime rather than an option among equals. If a migrating team has not evaluated the question yet, Node is what "run this in production" means to most of the infrastructure they already touch.

The ecosystem depth follows from that default status. A decade of production-hardened packages exist for Node specifically, and the observability and APM tooling built around it, Datadog and New Relic agents included, instruments Node natively rather than through a newer, thinner compatibility layer. For anything in the long tail of infrastructure integrations, Node is more likely to already have a first-party answer.

The strongest argument, though, is organizational. A team whose ops org has years of Node runbooks, deployment pipelines, and incident playbooks built around it gets to land a migration inside operational muscle memory it already has. That matters more than any benchmark when the team is also absorbing a language port at the same time.

The case for Bun

Bun's headline advantage is cold start time, and it is not a marginal one. That matters directly for serverless and edge deployments, where a cold process is on the critical path of the first request it serves, and it matters for the shared-nothing, per-request model that Pext mirrors by default when it targets PHP's own execution shape.

Bun also folds tooling together that Node leaves to the ecosystem. A built-in test runner, a built-in bundler, and native TypeScript execution with no separate build step mean fewer moving parts in a project's toolchain. Its package manager is a drop-in npm replacement, so existing package.json files and lockfile workflows carry over without rewriting them.

This lands well specifically for a team replicating PHP's per-request lifecycle. PHP starts fresh per request by default, with no long-lived process retaining state between requests unless the team built one, so "how fast does this process boot" is already a familiar axis a PHP shop has spent years optimizing. Bun's cold-start advantage is a continuation of that instinct rather than a new one to learn.

Why Pext does not force the choice

Bun became a supported runtime for Pext-converted code in late June 2026, covered in the week-in-review post from that week. The blocker had been native Node bindings: a native oniguruma binding for PCRE, and libxmljs2 for LibXML. Swapping both onto WASM-based packages removed the dependency on NODE_MODULE_VERSION that had tied the runtime to Node specifically. That same post covered a JIT inlining difference between Bun's JavaScriptCore and V8 that had briefly broken reflection's caller-frame discovery, fixed with a try/throw/catch pattern the inliner leaves alone under both engines.

The reason that swap was possible at all is that the transpiler itself never took a side. Pext emits plain JS or TS, selected via config.target, with no Node-specific or Bun-specific API surface baked into the generated application code by default. The layer that actually talks to the OS, the network, and the filesystem is the runtime modules, and that is the layer that had to be made dual-compatible. The generated code sitting on top of it did not need to change at all.

Practically, that means the Node-versus-Bun decision does not have to be made up front. Start a migration targeting Node for the safety margin, benchmark Bun on the actual workload once the conversion is stable, and switch later if the numbers justify it. Nothing about the conversion output locks that decision in early.

Part 3 continues this series with TypeScript's type system: what pext.json:"target": "ts" actually buys a migrating team, and where it does not.