esrun vs Node.js · Bun · Deno

Node.js, Bun, and Deno are general-purpose runtimes with broad toolchains and (varying degrees of) Node.js compatibility. esrun is intentionally smaller: a sandboxed, standards-only execution core you embed or run directly. The table makes the trade-offs explicit.

CapabilityesrunNode.jsBunDeno
ES Modules
CommonJS (require)
TypeScript (built-in)
JSX (built-in)
JSON module imports
Web APIs (fetch/URL/streams/WebCrypto)
Built-in HTTP server
HTTP/2 server (cleartext h2c + ALPN)
HTTP/3 server (QUIC)
Temporal API
WebAssembly
.wasm ES module imports
WASI preview 1
Node.js compatibility (node: builtins)
Capability sandbox (deny by default)
Runtime permission flags (allow/deny)
Workers / multi-thread
FFI (dlopen)
Native addons (N-API)
Package installer
Bundler / test runner
Embeddable as a library

Legend: Supported · Partial / flagged / experimental · Not supported

On the HTTP/2 row. esrun and Deno detect the version per connection on the server they already have, so one serve() answers both. Node's h2c is a separate API (node:http2); node:http's server is HTTP/1.1-only. Bun is because it implements node:http2 — which does serve h2c — while its own Bun.serve is HTTP/1.1-only, so the flagship API and the HTTP/2 one are not the same server. Measured throughput for all four: Benchmarks.

On the HTTP/3 row. Bun leads here and esrun does not have it. Bun.serve({ http3: true }) serves real HTTP/3 over TLS — verified against curl --http3-only, which reports version 3 — on an embedded lsquic stack; it also has an HTTP/3 fetch client behind --experimental-http3-fetch, which is the half still marked experimental. Node has no node:quic in the current release line. Deno ships unstable QUIC primitives (Deno.connectQuic behind --unstable-net, with no matching listener), which is a transport with nothing speaking HTTP above it.

HTTP/3 is not the cheap follow-on that HTTP/2 was, which is why the two rows differ: HTTP/2 was another version negotiated on a connection serve() already accepts, whereas QUIC is a UDP transport with its own TLS integration and congestion control — a new listener and a new dependency, not a flag on the existing one. Bun's answer to that was to embed a C QUIC library; that is a real option and the row is here to say so plainly rather than to imply the field has no answer.

On the permission row. It is about the CLI, and is a different question from the sandbox row above it, which is about the default: esrun grants everything by default and restricts on request, where Deno denies by default and grants on request. Both have flags that allow or deny a capability, scopes that narrow one to particular paths or hosts, and a way to ask from JS — the Permission models table below breaks down how differently they get there, and where esrun goes further (an always-on filesystem jail, and a policy for what the module loader may resolve).


Note on the esrun sandbox column. The answer differs by entry point, which is why it is rather than a single icon. The embeddable library is deny-by-default in the strongest sense available here: a Runtime starts with an empty capability set and a host grants each one explicitly, so an embedder cannot forget to sandbox — there is nothing to forget. The CLI inverts that: it grants everything and restricts on request, because a runner you have to configure before it runs anything is not a runner. --deny-all takes it back to the library's posture; see Permission models.

Permission models

esrunNode.jsBunDeno
Restricted by default
Turned on by--deny-all--permissionalways on (-A off)
Directiondeny-list, or --deny-all + allow-listallow-listallow-list + --deny-*
Scoped to paths / hosts
Interactive prompt
Entry file readable
Local imports readable--allow-imports
Allow/deny which modules load--import-policy file
Query from JSpermissions.has()process.permission.has()Deno.permissions.query()
Drop privileges at runtime.drop().revoke()
Filesystem root jailalways on
Statusalphastable (v22.13+)proposedstable

Rows 6–7 are about the module graph: under esrun's --deny-all and Node's --permission, the entry file still runs but a second file needs a grant. Deno reads statically analysable imports without one.

Row 8 is a different question again — not may the loader run but what may it resolve. esrun answers it outside the permission model, with a JSON --import-policy file of allow/deny package names and paths. Deno's similarly-named --allow-import is not this: it names which remote hosts code may be fetched from, which esrun has no equivalent of because it refuses remote modules outright. Node's module policy mechanism was deprecated and removed.

esrun's eight denial names — read, write, imports, net, listen, env, run, signals — are also what permissions.has() takes. See Security model.

Where the API surface differs

  • No Node.js API. Where Node.js, Bun, and Deno all expose node:fs, Buffer, and a global process, esrun exposes host functionality only through capability-gated runtime: modules.

  • Globals are not host APIs. Like Deno, esrun keeps the global scope close to the Web platform — but it does not put filesystem or process access on a global either.

  • Always-on filesystem jail. File access — including module resolution — is confined to a project root that can't be escaped, even via symlink, in both the library and the CLI, with no flag to turn it off. No other runtime here has an equivalent; their permission flags are the only confinement.

  • It embeds. esrun is a Rust library with a driven event loop and no owned thread, designed to run inside a host application — a use case the others do not target.

  • WASI is enforced, not advisory. Node's own docs say its node:wasi threat model "does not provide secure sandboxing". Here a guest's file access passes three independent checks — preopen, capability, root jail. See WebAssembly & WASI.

Status reflects general availability at the time of writing and is a summary, not an exhaustive audit. See Benchmarks for measured performance.

Last updated on
Edit this page