Node.js Compatibility
See which Node.js features nstd supports, what is missing, and how compatibility is tested.
nstd runs Node programs without Node installed. This page explains how
closely it tracks real Node, what works, what does not, and what happens when
your program asks for something it cannot do.
#Which Node version
nstd is built from Node's own standard library source, pinned to the
v26.7.0 release tag. That means the JavaScript behind fs, path,
stream, url and the rest is not a reimplementation. It is Node's own code,
rewritten to run without Node underneath it. When a module's logic matches
v26.7.0's, it matches because it is the same code.
It does not mean nstd behaves like v26.7.0 in every respect. Node's
JavaScript standard library calls down into C++ for anything that touches the
operating system - the filesystem, sockets, OpenSSL, processes - and nstd
answers those calls with its own host, written in Zig, over a different set of
libraries (QuickJS-NG instead of V8, mbedTLS instead of OpenSSL). Where that
host provides a capability, the JavaScript above it runs unmodified. Where it
does not, the module still loads, but the call that needs the missing piece
fails - see what does not work below.
#What works today
A large part of the standard library behaves like the real thing, filesystem
and network access included - nstd is not limited to the pure-JavaScript
half:
- Core utilities -
path,util,events,stream,buffer,string_decoder,querystring,punycode,assert,urland theURL/URLSearchParamsglobals all run Node's own logic and are checked against real Node's output. - The real filesystem.
fsandfs/promisesrun against the host filesystem, not a stand-in.fs.watch()(inotify,recursive: trueincluded) andfs.watchFile()both work. require()andimport()- both module loaders are Node's own code, including thenode_modulesresolution walk,package.json"exports", and.jsonfiles.require()needs a runtime that allows compiling code from a string;nstddoes..mjssource text compiles too -nstdsupplies the module compiler from QuickJS-NG, a piece no standard JavaScript API exposes on its own.- Randomness, WebCrypto, and hashing.
crypto.randomBytes,randomUUID,randomInt,timingSafeEqualand the whole ofcrypto.subtlerun on the host's native implementation.createHash(),createHmac()andcrypto.hash()also run for real, on mbedTLS, whichnstdcompiles in. - DNS -
dns.lookupand theresolve*family return real answers. - Networking and TLS, both directions.
net.connect(),http.request(),fetch()andhttps.request()open real connections and complete real TLS handshakes. Servers work too:net,httpandhttpscanlisten()and accept connections, over TCP and Unix domain sockets. child_processandcluster.spawn,exec,execFile,forkandclusterall start real child processes.os, from the real machine.hostname(),platform(),arch(),homedir(),tmpdir(),totalmem(),freemem(),uptime(),userInfo()andnetworkInterfaces()all answer from the machinenstdis running on.
#What does not work
Some capabilities have no runtime-agnostic substitute, or are simply not built yet:
- OpenSSL-only crypto. Ciphers, key objects, signatures, Diffie-Hellman
and X.509 certificate handling need OpenSSL specifically, which
nstddoes not embed.getCiphers()honestly answers[]. Hashing does not fall in this group - see above. - A TLS server.
tls.connect()andhttps.request()work as a client, buttls.createServer()andhttps.createServer()are refused by name: every sessionnstdopens is a client. Session resumption is absent for the same reason - nothing here ever hands out a session. - A compressed HTTP response body. No compression binding is compiled in.
The default HTTP client asks a server for a gzipped response and then fails
while decoding it, so a request against a real server on the open internet
can fail where the same request over plain
identityencoding works:fetch("http://example.com/", { headers: { "accept-encoding": "identity" } }); - Passing a handle over IPC.
subprocess.send(message, handle)needs a mechanism (SCM_RIGHTS)nstddoes not implement, and is refused by name. Everything else about child processes works. worker_threads. Unlikechild_process, there is no thread substitute here;new Worker()throws.os.cpus()andos.loadavg(). Absent rather than faked, unlike the rest ofos, which reads the real machine.- A filename encoded as anything other than UTF-8.
fs.watch()andfs.readdir()refuse abuffer/hex/latin1/base64encoding by name, rather than silently handing back a string where aBufferwas asked for. - The identity of a rejected
Error. An unhandled rejection is fatal, andprocess.on('unhandledRejection', ...)fires, as in real Node - but the underlying JavaScript engine keeps anError's stack in a different place than the one Node's own code checks to decide whether a rejection reason is an error. The practical effect: a rejectedErrorgets reported wrapped in anUnhandledPromiseRejectionobject rather than as the error itself. The exit code and the report are right; only the identity is not.
For the full picture, module by module, see the API support matrix.
#How a missing capability behaves
This is the part that matters most for debugging. When you call something
nstd cannot back - new Worker(), say, since there is no thread substitute -
it does not return undefined and it does not fail silently. It throws an
error naming exactly what is missing, with error.code set to
ERR_NSTDLIB_NO_BINDING:
Error: [nstdlib] internalBinding('worker').Worker() is not implemented: this
runtime has no such binding, and no host implementation is installed on
`globalThis.node_binding_worker_Worker`.A capability that refuses specifically because it is TLS server surface - a
SecureContext method only a server calls, say - throws the same way with a
different code, ERR_NSTDLIB_NO_OPENSSL, so you can tell one reason for a
refusal apart from another.
Earlier versions of this project answered undefined instead, and it was
worse than it sounds. A silent undefined looks like a real answer, so code
built on top of it did the wrong thing quietly: if (timingSafeEqual(a, b))
treated every comparison as unequal, so a check meant to reject bad input let
everything through, and dns.lookup() looked like it was still working while
the callback just never ran, so the request hung forever with no error.
A thrown, named error turns each of those into something you can see
immediately: a stack trace at the call site, not a hang or a wrong answer
three functions away. If you are debugging why your program does not behave,
this is why - a capability nstd does not have, named honestly, is far easier
to work around than one it silently gets wrong.
#How compatibility is measured
Two independent checks back the claims on this page, both run against Node's own test suite rather than against a description of one:
- Node's own
test/parallelsuite, thousands of files that are the most complete existing statement of what anode:builtin is supposed to do, run againstnstddirectly - a realm with no Node underneath it at all. - Differential tests, which run the same program under real Node and
under
nstdand compare the two transcripts byte for byte. These cover URL parsing, networking, the HTTP parser, TLS, process spawning, the filesystem, and more - anywhere the fix for "does this match Node" is to ask Node directly rather than trust a description of the behavior.
Both suites are large and their numbers move as the project changes, so this
page does not repeat them. One figure is worth checking yourself, since it
costs nothing to reproduce: nstd --print-realm prints how many builtin
modules registered at startup and how many did not. For the current
per-module status - which modules are fully verified against real Node, which
are API-only with calls that throw, and which are untested - see the
API support matrix.