MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Deno 2.1 and the Convergence of JavaScript Runtimes

Deno, Node.js, and Bun converging on APIs: WinterCG, Node.js compatibility, and the future.

DenoNode.jsBunRuntime

By MinhVo

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.

JavaScript runtime convergence

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:

  1. Web API Standardization: Ensuring that APIs like fetch(), Request, Response, URL, TextEncoder, crypto.randomUUID(), and structuredClone() behave identically across all runtimes.

  2. Minimum Common API: Defining the baseline set of APIs that every server-side runtime must implement to be considered "WinterCG-compliant."

  3. 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.

  4. 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:test runner, --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.

Web API standardization

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 (.node files) require NAPI compatibility
  • vm module has partial support (sandboxing semantics differ)
  • cluster module is emulated but not fully equivalent
  • async_hooks has 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"
  }
}

Cross-runtime development

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

  1. Target WinterCG APIs first: When building new code, prefer fetch(), Response.json(), crypto.randomUUID(), and TextEncoder over runtime-specific equivalents. This ensures maximum portability.

  2. Use conditional imports for runtime-specific features: When you need Deno.readFile or node:fs, use dynamic imports with runtime detection rather than conditional compilation.

  3. 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.

  4. Pin runtime versions in CI: Don't test against "latest"β€”pin specific versions to catch compatibility regressions early.

  5. Use engines field in package.json: Specify supported runtime versions so users get clear error messages on incompatible environments.

  6. Leverage TypeScript project references: For cross-runtime libraries, use TypeScript project references to build different entry points for different module systems (ESM, CJS, Deno).

  7. 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.

  8. Use import maps for dependency management: Both Deno's imports in deno.json and Node.js's imports in package.json support import maps. Use them to control dependency resolution across runtimes.

Common Pitfalls and Solutions

PitfallImpactSolution
Using Deno.serve() in Node.jsReferenceError in non-Deno runtimesUse createServer from node:http or a framework like Hono that abstracts runtime differences
Relying on process.env without importWorks in Node.js/Bun, fails in Deno ESMImport process from node:process explicitly
Assuming fetch() behavior is identicalSubtle differences in redirect handling, cookie managementTest HTTP edge cases; set explicit redirect and cookie options
Using Node.js stream vs Web StreamsDifferent APIs, different behaviorPrefer Web Streams (ReadableStream, WritableStream) for new code
node_modules resolution differencesPackages may resolve differentlyUse package.json exports field and test resolution on each runtime
File path separator differencesWindows vs Unix behaviorAlways 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

AspectDeno 2.1Node.js 22 LTSBun 1.x
WinterCG ComplianceFullFullFull
npm CompatibilityExcellentNativeGood
Native TypeScriptYesExperimental (--experimental-strip-types)Yes
Permission ModelFullExperimentalNo
Built-in Test Runnerdeno testnode:testbun test
Built-in Formatterdeno fmtNoNo
Built-in Bundlerdeno bundleNobun build
Hot Reloaddeno run --watch--watch flagbun --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:

  1. WinterCG APIs are the common foundation β€” fetch(), crypto, URL, and streams work identically across Deno, Node.js, and Bun
  2. Node.js compatibility in Deno is production-ready β€” npm packages, package.json, and CommonJS modules all work
  3. The convergence benefits everyone β€” library authors can target one API surface, developers can switch runtimes freely
  4. Runtime-specific features still matter β€” Deno's permissions, Bun's speed, Node.js's ecosystem depth remain differentiators
  5. 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.