Node.js compatibility in Workers: one year on

Over the past year, the Cloudflare Workers team has been systematically expanding Node.js compatibility. The goal is straightforward: developers should be able to take existing npm packages and run them in Workers without modification. This effort has paid off — a large number of popular modules now work as-is, including frameworks like Express and Koa that were previously difficult to support due to their deep reliance on Node.js internals.

Rather than reimplementing every Node.js API from scratch, the team focused on the most commonly used modules. Where full Node.js behavior cannot be replicated exactly, the implementations throw explicit errors rather than silently failing. This approach means packages that merely check for the presence of an API won't break, even if the actual functionality isn't available in the Workers environment.

Module API documentation
node:console https://nodejs.org/docs/latest/api/console.html
node:crypto https://nodejs.org/docs/latest/api/crypto.html
node:dns https://nodejs.org/docs/latest/api/dns.html
node:fs https://nodejs.org/docs/latest/api/fs.html
node:http https://nodejs.org/docs/latest/api/http.html
node:https https://nodejs.org/docs/latest/api/https.html
node:net https://nodejs.org/docs/latest/api/net.html
node:process https://nodejs.org/docs/latest/api/process.html
node:timers https://nodejs.org/docs/latest/api/timers.html
node:tls https://nodejs.org/docs/latest/api/tls.html
node:zlib https://nodejs.org/docs/latest/api/zlib.html

Several of these modules required building new runtime capabilities. For node:fs, a virtual file system was added to the Workers runtime. For networking modules like node:net, node:tls, and node:http, the implementations wrap existing Workers features such as the Sockets API and fetch.

A key design decision: these are native implementations written in TypeScript and C++, not the polyfills and shims that earlier compatibility efforts relied on. That approach had Wrangler inject compatibility code at deployment time. The current direction moves toward having the APIs available natively in future Workers, improving both performance and behavioral fidelity.

Networking: HTTP, DNS, and sockets

The networking stack was a high priority. Workers don't have access to raw kernel-level sockets, so these APIs are built on top of the managed Sockets API and fetch. This approach enables many popular packages that depend on networking APIs to work seamlessly.

Client and server HTTP support

The node:http and node:https modules provide both client and server APIs. The HTTP client implementation, built on the Fetch API, supports http.request(). The server side uses the Workers runtime's existing request handling to support http.createServer(). This opens the door for frameworks like Express and Koa to run inside Workers.

import http from 'node:http';

export default {
  async fetch(request) {
    return new Promise((resolve, reject) => {
      const req = http.request('http://example.com', (res) => {
        let data = '';
        res.setEncoding('utf8');
        res.on('data', (chunk) => {
          data += chunk;
        });
        res.on('end', () => {
          resolve(new Response(data));
        });
      });
      req.on('error', (err) => {
        reject(err);
      });
      req.end();
    });
  }
}

Connecting a Node.js-style HTTP server to a Worker's fetch event is handled through the cloudflare:node module's httpServerHandler() function.

import { createServer } from "node:http";
import { httpServerHandler } from "cloudflare:node";

const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello from Node.js HTTP server!");
});

export default httpServerHandler(server);

DNS resolution via DoH

The node:dns module lets developers perform DNS queries. Cloudflare's implementation makes a subrequest to 1.1.1.1 using DNS-over-HTTPS, so users don't need to configure DNS servers manually — queries simply resolve.

TCP and TLS sockets

The node:net and node:tls modules provide APIs for TCP and secure TLS sockets. Both are built on the Workers Sockets API. Not everything is available — creating a TCP server via net.createServer() isn't supported yet — but enough of these APIs are implemented for many popular dependent packages to function.

import net from 'node:net';
import tls from 'node:tls';

export default {
  async fetch(request) {
    const { promise, resolve } = Promise.withResolvers();
    const socket = net.connect({ host: 'example.com', port: 80 },
        () => {
      let buf = '';
      socket.setEncoding('utf8')
      socket.on('data', (chunk) => buf += chunk);
      socket.on('end', () => resolve(new Response('ok'));
      socket.end();
    });
    return promise;
  }
}

Virtual file system for node:fs

Filesystem APIs in a serverless environment raise an obvious question: where do the files live? Workers don't run on a single machine; a single request can execute on any server across Cloudflare's network. That makes traditional shared filesystem access impractical. However, many applications and modules use files for configuration and temporary data storage.

The solution is a virtual file system that is in-memory and scoped per Worker. In a stateless Worker, files created during one request don't persist to the next. In a Durable Object, the temporary file space can be shared across multiple requests from multiple users. The file system is ephemeral — it doesn't survive Worker restarts or redeployments — so it complements rather than replaces Durable Object Storage. That said, it significantly expands what Durable Objects can do.

The node:fs module implements a broad set of file operations:

import fs from 'node:fs';

export default {
  async fetch(request) {
    // Write a temporary file
    await fs.promises.writeFile('/tmp/hello.txt', 'Hello, world!');

    // Read the file
    const data = await fs.promises.readFile('/tmp/hello.txt', 'utf-8');

    return new Response(`File contents: ${data}`);
  }
}

Beyond basic file I/O, the virtual file system supports directories, file descriptors, symbolic links, streams, and the standard process.stdin, process.stdout, and process.stderr streams.

Currently the file system is in-memory only. Persistent storage is being explored, potentially linked to Cloudflare R2 or Durable Objects. Developers who need durability now can build their own file system abstraction on top of Durable Objects' SQLite-backed storage and JavaScript RPC.

Crypto: reusing Node.js internals

The node:crypto module is fully implemented in Workers. Because Workers runs BoringSSL while Node.js uses OpenSSL, there are behavioral differences, but the compatibility surface is broad.

A significant part of the effort involved working within the Node.js project to extract its core crypto functionality into a separate dependency called ncrypto. This library is used by both Workers and Bun, running the exact same code that Node.js runs.

import crypto from 'node:crypto';

export default {
  async fetch(request) {
    const hash = crypto.createHash('sha256');
    hash.update('Hello, world!');
    const digest = hash.digest('hex');

    return new Response(`SHA-256 hash: ${digest}`);
  }
}

The module supports a wide range of functionality:

  • Hashing algorithms including SHA-256 and SHA-512
  • HMAC
  • Symmetric and asymmetric encryption/decryption
  • Digital signatures
  • Key generation and management
  • Random byte generation
  • Key derivation functions, including PBKDF2 and scrypt
  • Cipher, decipher, sign, and verify streams
  • KeyObject class for key management
  • X.509 certificate handling
  • PEM, DER, and base64 encoding support

The process global and its place in Workers

The node:process module is foundational to Node.js, offering access to environment variables, command-line arguments, and the current working directory. Many packages assume its presence implicitly. In Workers, certain process details—such as process IDs and user/group IDs—are tied to the traditional server OS/process model and have no equivalent. When nodejs_compat is enabled, the process global is available to Worker scripts, or it can be imported directly via import process from 'node:process'. Without the flag, process is undefined and the import throws.

Process.env

Workers have long supported environment variables, but previously they were only reachable through the env argument passed to the Worker function—not at the top-level scope:

export default {
  async fetch(request, env) {
    const config = env.MY_ENVIRONMENT_VARIABLE;
    // ...
  }
}

The new process.env implementation now enables access to environment variables in the familiar Node.js style at any scope, including the Worker's top level:

import process from 'node:process';
const config = process.env.MY_ENVIRONMENT_VARIABLE;

export default {
  async fetch(request, env) {
    // You can still access env here if you need to
    const configFromEnv = env.MY_ENVIRONMENT_VARIABLE;
    // ...
  }
}

Configuration remains as before—via wrangler.toml, wrangler.jsonc, the dashboard, or API—with values set as simple key-value pairs or JSON objects:

{
  "name": "my-worker-dev",
  "main": "src/index.js",
  "compatibility_date": "2025-09-15",
  "compatibility_flags": [
    "nodejs_compat"
  ],
  "vars": {
    "API_HOST": "example.com",
    "API_ACCOUNT_ID": "example_user",
    "SERVICE_X_DATA": {
      "URL": "service-x-api.dev.example",
      "MY_ID": 123
    }
  }
}

As in Node.js, all values accessed through process.env are strings. Since process.env is global, environment variables become visible in third-party libraries as well—consistent with Node.js behavior, but worth noting for security and configuration management. The Cloudflare Secrets Store is an alternative for secret handling within Workers.

Importing env and waitUntil without nodejs_compat

Beyond Node.js compatibility, Cloudflare made the env object and the waitUntil mechanism importable as modules when nodejs_compat is not in use. This avoids threading the env argument through multiple layers of function calls:

import { env, waitUntil } from 'cloudflare:workers';

const config = env.MY_ENVIRONMENT_VARIABLE;

export default {
  async fetch(request) {
    // You can still access env here if you need to
    const configFromEnv = env.MY_ENVIRONMENT_VARIABLE;
    // ...
  }
}

function doSomething() {
  // Bindings and waitUntil can now be accessed without
  // passing the env and ctx through every function call.
  waitUntil(env.RPC.doSomethingRemote());
}

One caveat: mutations to process.env are not mirrored back to the env argument, and vice versa. process.env is populated once at the start of Worker execution and does not update dynamically. This intentional design mirrors Node.js behavior and helps prevent third-party libraries from inadvertently altering the environment assumed by the rest of the Worker code.

stdin, stdout, stderr

Workers lack traditional process I/O streams. Instead, process.stdin, process.stdout, and process.stderr are implemented as stream-like objects. They aren't connected to a real process's stdin/stdout, but output written to them is captured in Workers Logs just like console.log. process.stdout and process.stderr are Node.js writable streams:

import process from 'node:process';

export default {
  async fetch(request) {
    process.stdout.write('This will appear in the Worker logs\n');
    process.stderr.write('This will also appear in the Worker logs\n');
    return new Response('Hello, world!');
  }
}

These streams integrate with the virtual file system, so node:fs APIs can write to file descriptors 0, 1, and 2 (stdin, stdout, and stderr respectively):

import fs from 'node:fs';
import process from 'node:process';

export default {
  async fetch(request) {
    // Write to stdout
    fs.writeSync(process.stdout.fd, 'Hello, stdout!\n');
    // Write to stderr
    fs.writeSync(process.stderr.fd, 'Hello, stderr!\n');

    return new Response('Check the logs for stdout and stderr output!');
  }
}

Other selected process APIs

  • process.nextTick(fn) schedules a callback after the current execution context completes. The implementation uses the same microtask queue as promises, so it behaves exactly like queueMicrotask(fn).
  • process.cwd() and process.chdir() get and change the virtual current working directory, initialized to /bundle. Each request gets an isolated view; changing the directory in one request does not affect others.
  • process.exit() in Workers terminates only the current request and returns an error response—unlike Node.js, where it would terminate the entire process.

Compression with node:zlib

node:zlib brings familiar compression and decompression APIs—gzip, deflate, and brotli—to Workers:

import zlib from 'node:zlib';

export default {
  async fetch(request) {
    const input = 'Hello, world! Hello, world! Hello, world!';
    const compressed = zlib.gzipSync(input);
    const decompressed = zlib.gunzipSync(compressed).toString('utf-8');

    return new Response(`Decompressed data: ${decompressed}`);
  }
}

Workers already supported gzip and deflate via the Web Platform Standard Compression API, but node:zlib extends that coverage with brotli and a more familiar API for Node.js developers.

Timers and console

The node:timers API set has also been implemented in the runtime:

import timers from 'node:timers';

export default {
  async fetch(request) {
    timers.setInterval(() => {
      console.log('This will log every half-second');
    }, 500);

    timers.setImmediate(() => {
      console.log('This will log immediately after the current event loop');
    });

    return new Promise((resolve) => {
      timers.setTimeout(() => {
        resolve(new Response('Hello after 1 second!'));
      }, 1000);
    });
  }
}

The Node.js timers APIs closely follow the Web Platform equivalents, with one key distinction: they return Timeout objects for managing timers post-creation. The Timeout class has been implemented in Workers, enabling clearing or re-firing as needed. node:console is implemented as a thin wrapper around the existing globalThis.console in Workers.

Enabling and controlling Node.js features

The nodejs_compat compatibility flag in wrangler.jsonc or wrangler.toml enables all Node.js compatibility features at once—also settable via the dashboard or API:

{
  "name": "my-worker",
  "main": "src/index.js",
  "compatibility_date": "2025-09-21",
  "compatibility_flags": [
    // Get everything Node.js compatibility related
    "nodejs_compat",
  ]
}

The compatibility date is the important part here—use the most current date to get the latest features.

The nodejs_compat flag is the umbrella enabler and the recommended approach. However, individual APIs can be toggled via their own compatibility flags for more granular control:

Module Enable Flag (default) Disable Flag
node:console enable_nodejs_console_module disable_nodejs_console_module
node:fs enable_nodejs_fs_module disable_nodejs_fs_module
node:http (client) enable_nodejs_http_modules disable_nodejs_http_modules
node:http (server) enable_nodejs_http_server_modules disable_nodejs_http_server_modules
node:os enable_nodejs_os_module disable_nodejs_os_module
node:process enable_nodejs_process_v2
node:zlib nodejs_zlib no_nodejs_zlib
process.env nodejs_compat_populate_process_env nodejs_compat_do_not_populate_process_env

The team initially rolled features out under the single flag but quickly found that some users perform feature detection based on module/API presence. Enabling everything at once risked breaking existing Workers. Users who check for API existence manually can opt out of specific ones:

{
  "name": "my-worker",
  "main": "src/index.js",
  "compatibility_date": "2025-09-15",
  "compatibility_flags": [
    // Get everything Node.js compatibility related
    "nodejs_compat",
    // But disable the `node:zlib` module if necessary
    "no_nodejs_zlib",
  ]
}

Holding the nodejs_compat flag enables everything without performance penalty; individuals can disable specific features later if needed.

End-of-life API handling

A fundamental difference between Node.js and Workers: Node.js has a defined LTS schedule permitting breaking changes at determined points, including removing end-of-life (EOL) APIs. Workers, by contrast, guarantee deployed Workers will run indefinitely unchanged absent a compatibility date change. Therefore, new flags handle Node.js EOL APIs without breaking existing deployments:

The remove_nodejs_compat_eol flag removes APIs that have reached EOL up to the current compatibility date:

{
  "name": "my-worker",
  "main": "src/index.js",
  "compatibility_date": "2025-09-15",
  "compatibility_flags": [
    // Get everything Node.js compatibility related
    "nodejs_compat",
    // Remove Node.js APIs that have reached EOL up to your
    // current compatibility date
    "remove_nodejs_compat_eol",
  ]
}
  • remove_nodejs_compat_eol_v22 removes APIs that reached EOL in Node.js v22. remove_nodejs_compat_eol auto-enables it when the compatibility date passes Node.js v22's EOL date (April 30, 2027).
  • remove_nodejs_compat_eol_v23 removes APIs EOL'd in Node.js v23, with remove_nodejs_compat_eol auto-enabling past April 30, 2028.
  • remove_nodejs_compat_eol_v24 removes APIs EOL'd in Node.js v24, and remove_nodejs_compat_eol auto-enables it past the same April 30, 2028 date.

The shared date for the v23 and v24 flags isn't a typo. Node.js v23, not an LTS release, had a brief support window—released October 2023, EOL May 2024. Non-LTS releases are grouped into the next LTS release for EOL handling. Setting a compatibility date past Node.js v24's EOL date also opts out of v23's EOL'd APIs. Reverse compatibility flags like add_nodejs_compat_eol_v24 allow Workers to continue using older APIs indefinitely, and these removal flags won't auto-enable until the compatibility date passes the relevant EOL date—giving existing Workers generous time to migrate.

Contributing back to Node.js

Alongside the compatibility work, Cloudflare has been increasing its contributions to the Node.js ecosystem itself. Five members of the Workers runtime team—plus one summer intern—now actively contribute to the Node.js project on GitHub, with two serving on its Technical Steering Committee. Contributions include new features like the Web Platform Standard URLPattern API and an improved implementation of crypto operations, but the team’s main focus has been on helping other runtimes interoperate with Node.js, fixing critical bugs, and improving performance.

Aaron Snell 2025 Summer Intern, Cloudflare Containers
Node.js Web Infrastructure Team
Image
Image flakey5
Dario Piotrowicz Senior System Engineer#15C
Node.js Collaborator
Image
Image dario-piotrowicz
Guy Bedford Principal Systems Engineer
Node.js Collaborator
Image
Image guybedford
James Snell Principal Systems Engineer
Node.js TSC
Image jasnell
Nicholas Paun Systems Engineer
Node.js Contributor
Image npaun
Yagiz Nizipli Principal Systems Engineer
Node.js TSC
Image anonrig

Cloudflare also continues its strategic partnership with the OpenJS Foundation, supporting Node.js infrastructure with free access to Workers, R2, DNS, and other services.

Trying it out

The goal for Node.js compatibility in Workers goes beyond individual APIs: it’s about building a platform where existing Node.js code runs seamlessly. That means not only implementing the APIs but also making sure they work together and integrate cleanly with Workers-specific features.

For APIs like node:fs and node:crypto, this required building entirely new capabilities at the native runtime level—capabilities that were not previously available in Workers. Implementing them natively allows the team to tailor behavior to the Workers environment while maintaining performance and security.

Work is ongoing. The team continues to add more Node.js APIs and improve the performance and compatibility of what already exists. Community feedback drives prioritization; developers can request support for specific APIs or npm packages or report bugs on the workerd GitHub repository. While not every Node.js API may be implemented or behave exactly as it does in Node.js, the commitment is to a robust, comprehensive compatibility layer that serves real developer needs.

All the compatibility features described here are available now. Enable the nodejs_compat flag in your wrangler.toml, wrangler.jsonc, or via the Cloudflare dashboard or API, and start using Node.js APIs in Workers immediately.