You ever migrate a Node.js service to an alternative JavaScript runtime, watch most of your tests pass, then spend an entire afternoon hunting down the handful that fail? I have. Three times this year.
Here's the thing about runtime compatibility numbers — they sound great in headlines. "99.8% Node.js compatibility" is a real flex. But when you're the dev whose login flow lives in the 0.2%, that number suddenly feels useless.
This post walks through how I debug compatibility failures when running existing Node code on alternative runtimes. The approach is the same regardless of which runtime you're targeting.
The Problem
You've got an existing Node.js codebase. It works fine on Node 20. You decide to try a faster runtime — maybe for cold start improvements, maybe just to benchmark. You install it, point it at your entry file, and...
$ alt-runtime run server.js
TypeError: process.binding is not a function
at requireBuiltin (internal/util.js:42:18)
at Module._compile (...)
Or worse — it starts. Tests pass. Production breaks two days later because some edge case in crypto.createHash returns a slightly different object shape.
These failures look random. They aren't. They cluster around a few predictable categories.
Root Cause: Where Compatibility Actually Breaks
Most "Node-compatible" runtimes implement the public node:* API surface. The trouble is that "Node compatible" is a fuzzy claim, and the gaps usually fall into four buckets:
1. Internal APIs
Stuff like process.binding, internalBinding, or anything from node:internal/*. These are explicitly private, but plenty of npm packages rely on them. If a package was last updated in 2017, there's a decent chance it's reaching into Node internals you didn't know about.
2. Behavioral differences in public APIs
The function signature matches Node. The return type matches. But the behavior is subtly different — different error codes, different event ordering, different timing for setImmediate vs process.nextTick.
3. Missing modules
Whole modules sometimes aren't implemented. node:vm, node:cluster, and node:worker_threads are the usual suspects, depending on the runtime.
4. Native addons
If your dependency tree pulls in anything that compiles a .node file, alternative runtimes often can't load it without a workaround. N-API support varies in maturity.
Step-by-Step Debugging
Here's the workflow I run through every time. It usually finds the issue in 15-30 minutes.
Step 1: Get a clean reproduction
Don't debug inside your full app. Strip it down. I keep a repro/ directory in every project for exactly this:
// repro/test-crypto.js
// Minimal repro for the hash mismatch I saw in auth.js
const crypto = require('node:crypto');
const h1 = crypto.createHash('sha256');
h1.update('test');
console.log('first digest:', h1.digest('hex'));
// Calling digest() twice — does this throw or return empty?
console.log('second digest:', h1.digest('hex'));
Run the same file under Node and the alternative runtime. If the output differs, you've localized the failure.
Step 2: Find which API is implicated
When the stack trace is unhelpful, instrument the suspect module. I use a tiny tracing Proxy:
// trace.js — wraps a module and logs every call shape
function trace(mod, name) {
return new Proxy(mod, {
get(target, prop) {
const value = target[prop];
if (typeof value !== 'function') return value;
return (...args) => {
// Log call shape so I can diff runtimes side-by-side
console.error(`[${name}.${String(prop)}]`, args.map(a => typeof a));
return value.apply(target, args);
};
}
});
}
const fs = trace(require('node:fs'), 'fs');
// now use `fs` as normal — every call gets logged with arg types
Run this on both runtimes and diff the output. The first diverging line is almost always your culprit.
Step 3: Check the runtime's compatibility tracker
Every serious alternative runtime publishes a known-incompatibilities list. Find it in their official docs before you start writing workarounds — odds are someone already filed your issue and there's a documented workaround.
A quick search of the runtime's GitHub issues with is:issue node compat <module-name> is also worth thirty seconds.
Step 4: Apply the right kind of fix
Once you know what's broken, the fix usually falls into one of three patterns:
// Pattern A: feature-detect and branch
const hasFeature = typeof process.someAPI === 'function';
const result = hasFeature
? process.someAPI(input)
: fallbackImplementation(input); // pure-JS fallback
// Pattern B: pin a userland polyfill instead of the runtime built-in
// e.g. use a pure-JS hashing lib for one specific call site
// where the native crypto behavior diverges
// Pattern C: isolate the bad path behind a runtime check
const runtime =
typeof globalThis.Bun !== 'undefined' ? 'bun' :
typeof globalThis.Deno !== 'undefined' ? 'deno' :
'node';
module.exports = runtime === 'node'
? require('./node-impl')
: require('./portable-impl');
Pattern A is the cleanest. Pattern C is the ugliest, but sometimes you have no choice — especially with native addons.
Prevention: Stop Hitting These in the First Place
A few habits that have saved me real time:
- Run your full test suite on the alt runtime in CI from day one. Not just unit tests — the integration tests that exercise weird APIs. A green build today doesn't mean green tomorrow when you bump a dep.
-
Audit your dependency tree for native addons.
npm ls --allor look forbinding.gypfiles innode_modules. Native addons are where most of my migration pain comes from. -
Avoid undocumented Node APIs in your own code. If it's not in the official Node API docs, it's not portable.
process.binding,_extend, anything starting with an underscore — pretend they don't exist. - Watch the runtime's release notes for "Node compat" entries. Every release usually moves the line. Knowing what just got fixed saves you from working around something that no longer needs working around.
The Honest Take
The 99.8% number isn't lying. It's just that "passing the test suite" and "running my specific production workload" are different problems. Test suites cover documented APIs and well-trodden paths. Your production code does whatever your dependencies decided to do five years ago.
The good news: if you adopt the debugging workflow above, the 0.2% becomes tractable. Most of the failures I've hit have a 30-minute fix once I stop guessing and start tracing.
Pick a runtime, run your tests, and when something breaks — don't panic, instrument it.
Top comments (0)