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, url and the URL/URLSearchParams globals all run Node's own logic and are checked against real Node's output.
  • The real filesystem. fs and fs/promises run against the host filesystem, not a stand-in. fs.watch() (inotify, recursive: true included) and fs.watchFile() both work.
  • require() and import() - both module loaders are Node's own code, including the node_modules resolution walk, package.json "exports", and .json files. require() needs a runtime that allows compiling code from a string; nstd does. .mjs source text compiles too - nstd supplies the module compiler from QuickJS-NG, a piece no standard JavaScript API exposes on its own.
  • Randomness, WebCrypto, and hashing. crypto.randomBytes, randomUUID, randomInt, timingSafeEqual and the whole of crypto.subtle run on the host's native implementation. createHash(), createHmac() and crypto.hash() also run for real, on mbedTLS, which nstd compiles in.
  • DNS - dns.lookup and the resolve* family return real answers.
  • Networking and TLS, both directions. net.connect(), http.request(), fetch() and https.request() open real connections and complete real TLS handshakes. Servers work too: net, http and https can listen() and accept connections, over TCP and Unix domain sockets.
  • child_process and cluster. spawn, exec, execFile, fork and cluster all start real child processes.
  • os, from the real machine. hostname(), platform(), arch(), homedir(), tmpdir(), totalmem(), freemem(), uptime(), userInfo() and networkInterfaces() all answer from the machine nstd is 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 nstd does not embed. getCiphers() honestly answers []. Hashing does not fall in this group - see above.
  • A TLS server. tls.connect() and https.request() work as a client, but tls.createServer() and https.createServer() are refused by name: every session nstd opens 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 identity encoding works:
    fetch("http://example.com/", { headers: { "accept-encoding": "identity" } });
  • Passing a handle over IPC. subprocess.send(message, handle) needs a mechanism (SCM_RIGHTS) nstd does not implement, and is refused by name. Everything else about child processes works.
  • worker_threads. Unlike child_process, there is no thread substitute here; new Worker() throws.
  • os.cpus() and os.loadavg(). Absent rather than faked, unlike the rest of os, which reads the real machine.
  • A filename encoded as anything other than UTF-8. fs.watch() and fs.readdir() refuse a buffer/hex/latin1/base64 encoding by name, rather than silently handing back a string where a Buffer was asked for.
  • The identity of a rejected Error. An unhandled rejection is fatal, and process.on('unhandledRejection', ...) fires, as in real Node - but the underlying JavaScript engine keeps an Error'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 rejected Error gets reported wrapped in an UnhandledPromiseRejection object 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/parallel suite, thousands of files that are the most complete existing statement of what a node: builtin is supposed to do, run against nstd directly - a realm with no Node underneath it at all.
  • Differential tests, which run the same program under real Node and under nstd and 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.