Skip to main content

Running Node's real lib/ in a browser tab

· 6 min read

There are two ways to give a browser a Node-compatible runtime, and for a long time we were confidently building the wrong one.

Path A is the obvious one: hand-write the core modules. Implement fs on top of your virtual filesystem, implement path as string manipulation, implement events as a small emitter, and keep going. It feels productive immediately. path takes an afternoon. events takes a morning. fs takes a week and mostly works.

Then you reach stream, and progress stops.

Where Path A dies

stream is not a big module because Node's authors were verbose. It is big because backpressure is genuinely hard, and because fifteen years of packages have come to depend on the exact observable behaviour of that difficulty.

You can write something called Readable in a day. Making it emit 'readable' at the right moments, respect highWaterMark, handle a pipe target that returns false from write, unpipe cleanly on error, support both flowing and paused modes, implement readableEnded versus readableFinished, and behave correctly when a subclass calls the constructor without new: that is not a day. And when you get one of those wrong, you do not get a clean error. You get a dev server that hangs at 40% of a build, in a package four levels deep in someone's dependency tree.

http is worse, because it is a protocol parser plus a connection agent plus a stream implementation. crypto is worse still, because it is an ABI over OpenSSL. zlib needs a compression codec.

We had a runtime that could run a small Express app and could not run anything real, with a queue of modules ahead of us that each represented months of work and would still be, at the end of all that effort, an imitation.

Taking apart the competition

StackBlitz's WebContainer had clearly solved this. So we did the obvious thing and read their bundle, a ~2.1 MB file called builtins.2896b7f3.js.

The finding reframed the entire project:

They do not hand-write Node's core modules. They ship Node's actual lib/ JavaScript.

The evidence is not subtle once you look. The bundle exports an object with roughly 300 keys, and those keys are not a curated public API. They are Node's internal module tree. Alongside fs, http, stream, crypto, zlib, net, tls and worker_threads sit internal/streams/readable, _http_agent, internal/crypto/*, and internal/bootstrap/realm. Every module is wrapped in a function taking (exports, require, module, process, internalBinding, primordials).

internalBinding and primordials are internal-only Node machinery. You do not end up with those identifiers by writing a compatibility layer. You end up with them by shipping Node's source.

The seam

Once you see it, the architecture of real Node becomes the architecture of the solution.

Node is two layers. On top is lib/*.js, tens of thousands of lines of JavaScript implementing streams, HTTP, crypto, the module loader, everything a package actually touches. Underneath is C++, reached through exactly one door:

const binding = internalBinding("fs");

lib/fs.js does not know what happens inside internalBinding('fs'). It knows the shape of what comes back. That is a seam, and a seam is something you can cut along.

So: keep Node's JavaScript layer verbatim, and replace the C++ layer underneath with your own implementation. When lib/fs.js calls internalBinding('fs'), it gets an object backed by a Rust/Wasm virtual filesystem and a synchronous shared-memory bridge instead of libuv.

The economics are what make this decisive. internal/bootstrap/realm lists the bindings a running Node needs, and the list is short:

buffer, cares_wrap, config, constants, contextify, fs, fs_event_wrap,
icu, inspector, js_stream, os, pipe_wrap, process_wrap, spawn_sync,
stream_wrap, tcp_wrap, tls_wrap, tty_wrap, udp_wrap, uv, zlib
(+ async_wrap, crypto, http_parser, signal_wrap, url, v8)

Roughly twenty-five bindings, against hundreds of JavaScript modules. Everything above the line is Node's own code, already correct, already battle-tested against the entire npm ecosystem. All the remaining work is below the line.

That is the whole trade. Path A means writing hundreds of modules and getting them approximately right. Path B means writing twenty-five bindings and getting them exactly right, because Node's internal callers are unforgiving about shapes. Harder in a smaller place.

Here is the result. Every module below is Node's own source, unmodified, executing in this page:

Node's own core modulesOpen in Studio ↗

Why the foundation survived the pivot

The uncomfortable question when you decide to throw away months of work is how much of the rest goes with it. In this case, almost none, and understanding why is the most useful part of the story.

The realisation was that our existing fs-client.js, the thing user code called to reach the virtual filesystem, was already an internalBinding('fs') in everything but name. It took a syscall opcode and arguments, packed them into shared memory, parked the thread, and returned bytes or an errno. That is precisely the contract Node's C++ fs binding fulfils.

The same held everywhere. The Rust virtual filesystem is what internalBinding('fs') needs to sit on. The PID table and process supervisor are what process_wrap and spawn_sync need. The virtual network is what tcp_wrap needs. The Atomics bridge is what makes any of them able to be synchronous.

So Path B was a pivot in the upper layers, not a rewrite. Everything below the binding line, the part that had been genuinely hard to build, was the part worth keeping. Path A had not been wasted either: writing the hand-rolled builtins is how we learned what the binding contract actually needed to be.

What it costs

Being honest about the other side of the ledger:

The internal ABI is undocumented and unstable. You are writing against Node's private contract. Nobody upstream owes you compatibility, and pinning to a Node version is not optional.

Failures are opaque. When a binding returns a subtly wrong shape, the error surfaces somewhere in internal/streams/* with a stack trace full of Node internals and no mention of your code. Debugging means reading Node's source, which is a genuine skill investment.

Delivery gets heavier. Node's lib/ is a lot of JavaScript to ship into a tab, which pushes you into lazy loading and compression decisions you would rather not think about.

Against that: http, stream and crypto work, correctly, for real packages, today. That trade is not close.

The real test

Compatibility claims are cheap. The honest test of whether you have a Node runtime is not whether hello world prints. It is whether software written by people who assumed a real Node install runs unmodified.

The hardest such software is the package managers. npm, yarn and pnpm are large, old, gnarly programs that touch every corner of the runtime and were absolutely not written with charity toward reimplementations. Getting them to run is the subject of the next post, and each one broke the runtime in a different, instructive way.