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.
| Capability | esrun | Node.js | Bun | Deno |
|---|---|---|---|---|
| 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:
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
On the UDP rows. Deno is 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 @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.
esrunesrun-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 flag | Deno only | Deno 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 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
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 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
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.jsholds 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
| esrun | Node.js | Bun | Deno | |
|---|---|---|---|---|
| Restricted by default | ||||
| Turned on by | always on (-A off) | --permission | — | always on (-A off) |
| Direction | allow-list, or --allow-all + --deny-* | allow-list | — | allow-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 JS | permissions.has() | process.permission.has() | — | Deno.permissions.query() |
| Drop privileges at runtime | .drop() | — | .revoke() | |
| Filesystem root jail | always on | |||
| Status | alpha | stable (v22.13+) | proposed | stable |
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 globalprocess, esrun exposes host functionality only through capability-gatedruntime: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_moduleswalk 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:wasithreat 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.