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)
Stalled-connection timeouts by default
UDP datagram sockets
UDP multicast join/leave
Connected UDP sockets
Temporal API
WebAssembly
.wasm ES module imports
WASI preview 1
Built-in SQLite
Built-in Postgres / MySQL client
Built-in Redis client
Runtime-provided database driver kit
Node.js compatibility (node: builtins)
Capability sandbox (deny by default)
Runtime permission flags (allow/deny)
Workers / multi-thread
Per-worker memory limit
Per-worker capabilities
worker.unref()
Worker queue depth (backpressure signal)
FFI (dlopen)
Native addons (N-API)
Package installer
Bundler / test runner

Legend: Supported · Partial / flagged / experimental / dev-binary only · Not supported

On the TypeScript, JSX, CommonJS and toolchain rows. esrun does none of them, deliberately: it is the binary that runs in production, and a transform, a watcher or a test discoverer in there is surface that cannot pay for itself. esdev — a second binary, for your machine only — runs .ts and .tsx directly, bundles (which is how a CommonJS dependency becomes runnable), runs tests, watches, and debugs over the Chrome DevTools Protocol. It shares every line that decides how a run behaves, so a program cannot behave one way there and another in production. Marked rather than because the answer is "in the toolchain, not in the runtime", which is a real difference from the other three.

On the UDP rows. Deno is because Deno.listenDatagram is still behind --unstable-net — the API exists and works, it is simply not stable — and it has no connected-socket mode, so a client cannot make the OS filter replies or report an ICMP port-unreachable. Bun's surface is the widest of the four (source-specific membership, batch sendMany); ours is the only one where binding and sending are separate grants, which is what makes a receive-only service expressible. See the UDP guide for the call-by-call mapping, and the benchmarks for the two UDP rows.

On the database client rows. esrun is because the drivers are first-party packages rather than runtime API — @opentf/esrun-postgres and @opentf/esrun-redis, both entirely JavaScript over runtime:net, adding no native code to the runtime. That is the point of the row below them: Bun ships a driver per backend as runtime API and its internals are not exported, so a third party's second backend starts from nothing, while esrun's ship on the same public driver tier anyone else would use. Node and Deno leave both to npm.

Redis clients, feature by feature

The Redis guide has the same table alongside the usage it describes. Every cell was produced by running the client, not by reading its documentation. Node and Deno have no built-in Redis, so they are shown on both npm clients: redis (node-redis, the official one, maintained in redis/node-redis) and ioredis (about twice the weekly downloads, and now under the same organisation). They differ enough that showing only one would misreport Node.

esrun
esrun-redis
Node/Deno
node-redis 6
Node/Deno
ioredis 6
Bun
built-in
Ships with the runtime
RESP3 negotiated by default
Exact 64-bit integers
Binary-safe values
Pub/sub
Pipeline builder
MULTI/EXEC API
WATCH
Cluster
Sentinel
Streams & consumer groups
TLS with a private CA
Auto-reconnect
Unbounded-block guard
Portable error codes
Scoped by a capability flagDeno onlyDeno only

Exact 64-bit integers is the one difference that changes an answer rather than an ergonomic. INCRBY k 9007199254740993 returns 9007199254740993n here and 9007199254740992 — a number, off by one — on all three others. Redis integers are signed 64-bit, so a counter past 2⁵³ is not exotic. EXISTS is a smaller case of the same thing: Redis returns a count, which is what exists("a", "b") answers here, where Bun's returns a boolean that cannot express the multi-key form.

RESP3 is now universal, which was not true a version ago — ioredis 5 negotiated RESP2 even when asked for 3, and ioredis 6 negotiates 3 by default. Worth stating because it is the row most likely to be out of date in anything written earlier, including an earlier draft of this page.

The cells. node-redis and Bun have no pipeline builder; both pipeline commands issued together, so Promise.all is the idiom rather than a missing feature. Bun's stream support is send() rather than typed methods, and its client has no multi, watch, cluster or Sentinel at all.

Auto-reconnect is for esrun because it is off by default — a deliberate disagreement rather than a gap: turning it on changes what a thrown error means, and a pool already replaces dead connections. The other three reconnect out of the box, verified by killing connections server-side.

The last three rows are esrun's own: a blocking command with no timeout is refused before it can hold a connection for the life of the process; failures carry runtime:db's portable codes, so an application can branch on what happened without knowing which database said it; and the connection is scoped by --allow-net like every other socket. Deno can scope the socket too, but neither npm client has a portable error vocabulary to map onto.

Speed

Full numbers in Benchmarks. The short version: Bun's native client leads everything by 1.3× on round trips and 3–5× on decoding. Among the JavaScript clients esrun is the fastest where the round trip dominates, in a third of the memory, and ahead of the official node-redis on batches and list scans — but twice as slow on repeated HGETALL, which is a map-decoding cost specific to this driver and fixable.

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 stalled-connection row. Measured, not read from docs: a socket that connects and then sends nothing is closed by esrun after 10s, by Bun after 13s and by Node after 88s (a 60s headersTimeout polled on a 30s interval, hence the ), while Deno was still holding it open past 150s. An idle keep-alive connection goes at 6s on Node, 12s on Bun and 30s on esrun; Deno again held past 150s. Versions: esrun 0.15.0, Node 24.14.0, Bun 1.4.0 (canary), Deno 2.8.3, on Linux, 2026-08-03.

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 and Deno both deny by default and grant 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. esrun app.js holds nothing; --allow-<name> names what a run may reach, so a deployment cannot forget to sandbox — there is nothing to forget. esdev, the development binary, is the one that grants everything, so an inner loop needs no flags. See Permission models.

Permission models

esrunNode.jsBunDeno
Restricted by default
Turned on byalways on (-A off)--permissionalways on (-A off)
Directionallow-list, or --allow-all + --deny-*allow-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 default 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 nine denial names — read, write, imports, net, listen, env, run, signals, workers — are also what permissions.has() takes, and what new Worker(url, { permissions }) 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 is also the one place esrun's node_modules walk differs from theirs: Node, Deno and Bun walk up to the filesystem root, esrun stops at the directory you run in — and there is no flag to move it, because a boundary an argument can widen is not one.

  • 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