Introduction
The JavaScript runtime wars are endingβnot with a winner, but with convergence. In 2025, Deno 2.1, Node.js 22 LTS, and Bun 1.x are increasingly adopting the same APIs, the same module resolution strategies, and the same interoperability standards. This convergence is driven by WinterCG (Web-interoperable Runtimes Community Group), a W3C community group that includes engineers from all three runtime teams working to standardize server-side JavaScript APIs.
Deno 2.1 exemplifies this trend. It ships with near-complete Node.js compatibility, supports package.json and node_modules natively, and implements Web Platform APIs that are becoming the lingua franca of server-side JavaScript. Meanwhile, Node.js is adopting features that were once Deno-exclusiveβbuilt-in test runners, permission models (experimentally), and native TypeScript execution. Bun continues to push performance boundaries while maintaining compatibility with both Node.js and Deno APIs.
This article explores how this convergence works technically, what it means for developers, and where the runtimes are heading.
Understanding the Convergence: Core Concepts
What is WinterCG?
WinterCG was formed in 2022 as a W3C community group with a specific mission: define a common API baseline for non-browser JavaScript runtimes. The founding members include engineers from Deno, Node.js, Bun, Cloudflare Workers, and Vercel Edge Functions.
The group's work focuses on:
-
Web API Standardization: Ensuring that APIs like
fetch(),Request,Response,URL,TextEncoder,crypto.randomUUID(), andstructuredClone()behave identically across all runtimes. -
Minimum Common API: Defining the baseline set of APIs that every server-side runtime must implement to be considered "WinterCG-compliant."
-
Node.js API Harmonization: Working with the Node.js team to document and standardize the built-in module APIs (
fs,path,http, etc.) so other runtimes can implement compatible versions. -
Web Streams and Encoding: Standardizing
ReadableStream,WritableStream,TransformStream, and encoding APIs across runtimes.
The Three-Way Convergence Pattern
The convergence follows a specific pattern:
- Deno adopts Node.js APIs:
package.json,node_modules, CommonJS, built-in module polyfills - Node.js adopts Deno features:
node:testrunner,--experimental-permission, native TypeScript (via--experimental-strip-types) - Bun adopts both: npm compatibility, Deno-style APIs, Web Platform APIs
- All three adopt Web APIs:
fetch(),crypto,URL,TextEncoder, streams
This creates a Venn diagram where the overlapping centerβWinterCG-compliant APIsβgrows larger with every release.
Architecture and Design Patterns
Web API as the Common Layer
The convergence architecture has three layers:
βββββββββββββββββββββββββββββββββββββββββββββββ
β Application Code β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β WinterCG Common APIs (fetch, crypto, β
β URL, TextEncoder, streams, etc.) β
ββββββββββββ¬βββββββββββββββ¬ββββββββββββββββββββ€
β Deno β Node.js β Bun β
β APIs β APIs β APIs β
ββββββββββββ΄βββββββββββββββ΄ββββββββββββββββββββ
When you write code that only uses WinterCG APIs, it runs on all three runtimes without modification. The key APIs in this common layer include:
// All of these work identically in Deno 2.1, Node.js 22, and Bun 1.x
// HTTP Fetch
const response = await fetch("https://api.example.com/data");
const data = await response.json();
// URL parsing
const url = new URL("https://example.com/path?query=value");
console.log(url.searchParams.get("query")); // "value"
// Crypto
const id = crypto.randomUUID();
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode("hello"));
// Text encoding
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const bytes = encoder.encode("Hello, World!");
const text = decoder.decode(bytes);
// Streams
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("chunk 1"));
controller.enqueue(new TextEncoder().encode("chunk 2"));
controller.close();
}
});
// structuredClone
const original = { nested: { value: 42 } };
const cloned = structuredClone(original);Node.js API Compatibility Matrix
Deno 2.1's compatibility with Node.js built-in modules is extensive:
// These all work in Deno 2.1
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import path from "node:path";
import { EventEmitter } from "node:events";
import { Readable, Writable, Transform } from "node:stream";
import http from "node:http";
import https from "node:https";
import crypto from "node:crypto";
import os from "node:os";
import child_process from "node:child_process";
import { Worker } from "node:worker_threads";
import { createReadStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createHash } from "node:crypto";The few remaining gaps are in edge cases:
- Native addons (
.nodefiles) require NAPI compatibility vmmodule has partial support (sandboxing semantics differ)clustermodule is emulated but not fully equivalentasync_hookshas limited support
Runtime Detection Pattern
A common pattern in cross-runtime code is detecting which runtime you're executing on:
// Runtime detection utility
export interface RuntimeInfo {
name: "deno" | "node" | "bun" | "unknown";
version: string;
supportsNodeModules: boolean;
supportsPermissions: boolean;
}
export function detectRuntime(): RuntimeInfo {
// Deno detection
if ("Deno" in globalThis && typeof globalThis.Deno?.version?.deno === "string") {
return {
name: "deno",
version: globalThis.Deno.version.deno,
supportsNodeModules: true,
supportsPermissions: true,
};
}
// Bun detection
if ("Bun" in globalThis && typeof globalThis.Bun?.version === "string") {
return {
name: "bun",
version: globalThis.Bun.version,
supportsNodeModules: true,
supportsPermissions: false,
};
}
// Node.js detection
if (typeof globalThis.process?.versions?.node === "string") {
return {
name: "node",
version: globalThis.process.versions.node,
supportsNodeModules: true,
supportsPermissions: globalThis.process.permission !== undefined,
};
}
return { name: "unknown", version: "0.0.0", supportsNodeModules: false, supportsPermissions: false };
}Step-by-Step Implementation
Building a Cross-Runtime Library
Let's build a configuration library that works across Deno, Node.js, and Bun:
// lib/config.ts
import { z } from "zod";
// Schema definition (zod works everywhere)
const ConfigSchema = z.object({
port: z.coerce.number().default(3000),
database: z.object({
host: z.string().default("localhost"),
port: z.coerce.number().default(5432),
name: z.string(),
password: z.string(),
}),
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
type Config = z.infer<typeof ConfigSchema>;
export async function loadConfig(envPrefix = "APP"): Promise<Config> {
const raw: Record<string, string | undefined> = {};
// Read environment variables (compatible across runtimes)
for (const key of Object.keys(process.env)) {
if (key.startsWith(envPrefix + "_")) {
const configKey = key.slice(envPrefix.length + 1).toLowerCase();
raw[configKey] = process.env[key];
}
}
// Load from file if exists
try {
let configContent: string;
const configPath = "./config.json";
if ("Deno" in globalThis) {
configContent = await Deno.readTextFile(configPath);
} else {
const fs = await import("node:fs/promises");
configContent = await fs.readFile(configPath, "utf-8");
}
const fileConfig = JSON.parse(configContent);
Object.assign(raw, fileConfig);
} catch {
// Config file is optional
}
return ConfigSchema.parse(raw);
}Using the Web Streams API Across Runtimes
// lib/transform.ts
// Web Streams work identically across all three runtimes
export function createCsvParser(): TransformStream<string, Record<string, string>> {
let headers: string[] = [];
let buffer = "";
return new TransformStream({
transform(chunk: string, controller) {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const values = line.split(",").map((v) => v.trim());
if (headers.length === 0) {
headers = values;
continue;
}
const record: Record<string, string> = {};
headers.forEach((header, i) => {
record[header] = values[i] ?? "";
});
controller.enqueue(record);
}
},
flush(controller) {
if (buffer && headers.length > 0) {
const values = buffer.split(",").map((v) => v.trim());
const record: Record<string, string> = {};
headers.forEach((header, i) => {
record[header] = values[i] ?? "";
});
controller.enqueue(record);
}
},
});
}
// Usage (works in Deno, Node.js, and Bun)
const csvStream = new ReadableStream({
start(controller) {
controller.enqueue("name,age,email\n");
controller.enqueue("Alice,30,alice@example.com\n");
controller.enqueue("Bob,25,bob@example.com\n");
controller.close();
},
});
const parser = createCsvParser();
const readable = csvStream.pipeThrough(parser);
for await (const record of readable) {
console.log(record);
}Setting Up a Cross-Runtime Project
// deno.json
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"lib": ["ESNext", "DOM"]
},
"tasks": {
"dev": "deno run --watch --allow-net --allow-read --allow-env src/main.ts",
"test": "deno test --allow-net --allow-read --allow-env",
"check": "deno check src/**/*.ts"
},
"imports": {
"zod": "npm:zod@3.23.0"
}
}// package.json (for Node.js and Bun)
{
"name": "cross-runtime-app",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"test": "vitest run",
"check": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.0"
},
"devDependencies": {
"tsx": "^4.7.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}Real-World Use Cases
Use Case 1: Universal HTTP Server
// src/server.ts - Works in Deno, Node.js 22+, and Bun
const port = parseInt(Deno.env.get("PORT") ?? "3000");
// Use WinterCG-compatible server APIs
Deno.serve({ port }, async (req: Request) => {
const url = new URL(req.url);
if (url.pathname === "/api/users" && req.method === "GET") {
return Response.json([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]);
}
if (url.pathname === "/api/users" && req.method === "POST") {
const body = await req.json();
return Response.json({ id: crypto.randomUUID(), ...body }, { status: 201 });
}
return new Response("Not Found", { status: 404 });
});Use Case 2: Cross-Runtime Database Client
// src/db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
// Drizzle ORM works across all three runtimes
const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Connection setup adapts to runtime
const db = drizzle(process.env.DATABASE_URL!);
export { db, users };Use Case 3: Edge Function Deployment
// Deploy to Deno Deploy, Cloudflare Workers, or Vercel Edge
// All use WinterCG-compatible APIs
export default {
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
// KV storage works across edge platforms
const cacheKey = `page:${url.pathname}`;
const cached = await KV.get(cacheKey);
if (cached) {
return new Response(cached, {
headers: { "content-type": "text/html", "x-cache": "HIT" },
});
}
const html = await generatePage(url.pathname);
await KV.put(cacheKey, html, { expirationTtl: 3600 });
return new Response(html, {
headers: { "content-type": "text/html", "x-cache": "MISS" },
});
},
};Best Practices for Production
-
Target WinterCG APIs first: When building new code, prefer
fetch(),Response.json(),crypto.randomUUID(), andTextEncoderover runtime-specific equivalents. This ensures maximum portability. -
Use conditional imports for runtime-specific features: When you need
Deno.readFileornode:fs, use dynamic imports with runtime detection rather than conditional compilation. -
Test on all target runtimes: Use GitHub Actions matrix testing to run your test suite on Deno, Node.js, and Bun. The convergence is strong but not perfect.
-
Pin runtime versions in CI: Don't test against "latest"βpin specific versions to catch compatibility regressions early.
-
Use
enginesfield in package.json: Specify supported runtime versions so users get clear error messages on incompatible environments. -
Leverage TypeScript project references: For cross-runtime libraries, use TypeScript project references to build different entry points for different module systems (ESM, CJS, Deno).
-
Document runtime-specific behavior: Even with convergence, some behaviors differ. Document these in your READMEβparticularly around file system permissions, environment variable access, and startup timing.
-
Use import maps for dependency management: Both Deno's
importsindeno.jsonand Node.js'simportsinpackage.jsonsupport import maps. Use them to control dependency resolution across runtimes.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Using Deno.serve() in Node.js | ReferenceError in non-Deno runtimes | Use createServer from node:http or a framework like Hono that abstracts runtime differences |
Relying on process.env without import | Works in Node.js/Bun, fails in Deno ESM | Import process from node:process explicitly |
Assuming fetch() behavior is identical | Subtle differences in redirect handling, cookie management | Test HTTP edge cases; set explicit redirect and cookie options |
Using Node.js stream vs Web Streams | Different APIs, different behavior | Prefer Web Streams (ReadableStream, WritableStream) for new code |
node_modules resolution differences | Packages may resolve differently | Use package.json exports field and test resolution on each runtime |
| File path separator differences | Windows vs Unix behavior | Always use path.join() and path.resolve() |
Performance Optimization
// Use runtime-specific optimizations when needed
// Deno: Use Deno.serve for maximum throughput
if ("Deno" in globalThis) {
Deno.serve({ port: 3000 }, handler);
}
// Node.js: Use native http.createServer
else if ("process" in globalThis) {
const http = await import("node:http");
const server = http.createServer((req, res) => {
handler(new Request(`http://localhost${req.url}`, {
method: req.method,
headers: Object.fromEntries(Object.entries(req.headers) as [string, string][]),
})).then((response) => {
res.writeHead(response.status, Object.fromEntries(response.headers));
response.body?.pipeTo(new WritableStream({
write(chunk) { res.write(chunk); },
close() { res.end(); },
}));
});
});
server.listen(3000);
}
// Bun: Use Bun.serve for maximum throughput
if ("Bun" in globalThis) {
Bun.serve({ port: 3000, fetch: handler });
}// Memory-efficient streaming across runtimes
async function streamLargeFile(readable: ReadableStream, writable: WritableStream) {
const reader = readable.getReader();
const writer = writable.getWriter();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writer.write(value);
}
} finally {
reader.releaseLock();
writer.releaseLock();
}
}Comparison with Alternatives
| Aspect | Deno 2.1 | Node.js 22 LTS | Bun 1.x |
|---|---|---|---|
| WinterCG Compliance | Full | Full | Full |
| npm Compatibility | Excellent | Native | Good |
| Native TypeScript | Yes | Experimental (--experimental-strip-types) | Yes |
| Permission Model | Full | Experimental | No |
| Built-in Test Runner | deno test | node:test | bun test |
| Built-in Formatter | deno fmt | No | No |
| Built-in Bundler | deno bundle | No | bun build |
| Hot Reload | deno run --watch | --watch flag | bun --hot |
| Server Performance | ~80k req/s | ~70k req/s | ~100k req/s |
Advanced Patterns
Unified Build System
// build.ts - Cross-runtime build script
const runtime = detectRuntime();
const buildTools: Record<string, () => Promise<void>> = {
deno: async () => {
// Deno: Use deno_emit or esbuild
const { bundle } = await import("https://deno.land/x/emit/mod.ts");
const result = await bundle("./src/main.ts");
await Deno.writeTextFile("./dist/main.js", result.code);
},
node: async () => {
// Node.js: Use esbuild
const esbuild = await import("esbuild");
await esbuild.build({
entryPoints: ["./src/main.ts"],
bundle: true,
outdir: "./dist",
platform: "node",
format: "esm",
});
},
bun: async () => {
// Bun: Use native bundler
const result = await Bun.build({
entrypoints: ["./src/main.ts"],
outdir: "./dist",
});
console.log(`Built ${result.outputs.length} files`);
},
};
await buildTools[runtime.name]();Cross-Runtime Testing Framework
// test/utils.ts
import { assertEquals, assertRejects } from "https://deno.land/std/assert/mod.ts";
export function createTestSuite(name: string) {
const tests: Array<{ name: string; fn: () => Promise<void> }> = [];
return {
test(testName: string, fn: () => Promise<void>) {
tests.push({ name: testName, fn });
},
async run() {
console.log(`\n=== ${name} ===`);
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
await test.fn();
console.log(` β ${test.name}`);
passed++;
} catch (err) {
console.log(` β ${test.name}`);
console.log(` ${err}`);
failed++;
}
}
console.log(`\n ${passed} passed, ${failed} failed`);
return failed === 0;
},
};
}Future Outlook
The convergence trajectory is clear. By 2026, we can expect:
- WinterCG v1 specification ratified as a formal W3C standard
- Native TypeScript as a first-class feature in all three runtimes
- Unified permission model standardized across Deno, Node.js, and Bun
- Common addon API allowing native modules to work across runtimes
- Shared test infrastructure enabling one test suite to run on all platforms
The endgame is a JavaScript ecosystem where the runtime is an implementation detailβlike choosing between Chrome and Firefox for browser code. You write WinterCG-compliant code, test it once, and deploy it anywhere.
Conclusion
Deno 2.1 represents the convergence of JavaScript runtimes in action. By adopting Node.js compatibility, implementing WinterCG APIs, and maintaining its distinctive features, Deno proves that runtimes can evolve toward interoperability without sacrificing their identity.
Key takeaways:
- WinterCG APIs are the common foundation β
fetch(),crypto,URL, and streams work identically across Deno, Node.js, and Bun - Node.js compatibility in Deno is production-ready β npm packages,
package.json, and CommonJS modules all work - The convergence benefits everyone β library authors can target one API surface, developers can switch runtimes freely
- Runtime-specific features still matter β Deno's permissions, Bun's speed, Node.js's ecosystem depth remain differentiators
- Cross-runtime development is practical today β conditional imports and runtime detection enable code that works everywhere
The JavaScript runtime landscape in 2025 is not about choosing sidesβit's about building for the web platform. The convergence is real, it's accelerating, and it's making JavaScript development better for everyone.