Polyfill Strategies for Node.js APIs at the Edge

Edge runtimes execute within a V8 isolate that exposes only WHATWG-compliant browser APIs. Node.js built-ins—fs, path, net, tls, crypto (the Node module), Buffer, process—are either absent or available only through explicit compatibility flags. When dependencies use these APIs, you have three options: replace the dependency, polyfill the specific API at build time, or use a provider’s native compatibility layer.

The cost of polyfilling is real: each shim adds to bundle size and the initialization work that drives cold start latency. The goal is the minimum set of polyfills required to make your actual dependencies work, not a blanket Node.js compatibility layer.

This guide covers the decision model and the cross-provider mapping. For the Cloudflare-specific workflow, follow polyfilling Node.js modules in Cloudflare Workers. Two APIs need dedicated treatment because their Web replacements diverge most from the Node originals: hashing and signing, walked through in polyfilling Node crypto in edge runtimes, and binary buffers, covered in replacing Node Buffer with Uint8Array at the edge.

Polyfill decision flow A Node API need is resolved by first trying a native Web API, then the provider compatibility layer, and only then a targeted build-time shim. Dependency needs Node API 1. Native Web API exists? fetch, crypto.subtle, Uint8Array 2. Provider compat layer? nodejs_compat, partial shims 3. Targeted build-time shim one module, never the suite Ship, no shim no no
Resolve each Node API need top-down: prefer a native Web API, then the provider compatibility layer, and only then a single targeted shim.

Choosing an Injection Strategy

Build-Time Static Polyfills (esbuild / Vite)

Statically bundled polyfills are available on first request without dynamic import overhead. The risk is including shims for modules you do not actually use. Restrict to specific globals:

// vite.config.ts
import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';

export default defineConfig({
  build: {
    target: 'esnext',
    rollupOptions: {
      external: ['node:fs', 'node:net', 'node:tls'], // Never polyfill OS-bound APIs
    },
  },
  plugins: [
    nodePolyfills({
      include: ['buffer', 'process', 'util'], // Only what you actually need
      globals: { Buffer: true, process: true, global: true },
    }),
  ],
});

Use esbuild --analyze or rollup-plugin-visualizer to verify the polyfills you added are the ones actually included in the final bundle.

Runtime Feature Detection with Lazy Fallback

Prefer native Web APIs when available; fall back to a Node shim only when running in an environment that lacks the native API. This approach avoids bundling shims for platforms that do not need them:

// utils/crypto-polyfill.ts
export async function getSecureRandomBytes(length: number): Promise<Uint8Array> {
  // All modern edge runtimes expose this natively
  if (typeof globalThis.crypto?.getRandomValues === 'function') {
    const buffer = new Uint8Array(length);
    globalThis.crypto.getRandomValues(buffer);
    return buffer;
  }

  // Fallback: only reached in environments without WebCrypto (e.g., some test runners)
  try {
    const { randomBytes } = await import('node:crypto');
    return new Uint8Array(randomBytes(length));
  } catch {
    throw new Error('No secure random source available in this runtime');
  }
}

Provider-Native Compatibility Flags

Using a platform’s built-in compatibility layer is preferable to bundling your own shims—it keeps bundle size down and is maintained by the provider.

Cloudflare Workers — enable nodejs_compat in wrangler.toml:

# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2025-09-23"
compatibility_flags = ["nodejs_compat"]

With nodejs_compat, Buffer, process, stream, path, url, util, and a subset of crypto become available without any bundler changes. This is the recommended approach for Cloudflare. Do not manually bundle Node shims alongside this flag—you will get duplicate implementations.

Vercel Edge Middleware — the @vercel/edge runtime exposes path, url, and util as partial shims. Avoid node:fs and node:net even if they appear resolvable locally; they will fail in the deployed edge runtime.

Netlify Edge Functions (Deno runtime) — use npm: or node: specifiers in imports. There is no auto-polyfill; explicit bundler configuration is required:

# esbuild for Netlify Edge Functions
npx esbuild src/handler.ts \
  --bundle \
  --platform=neutral \
  --target=es2022 \
  --outfile=dist/handler.js

Provider Compatibility Matrix

Provider Runtime Compatibility Strategy Config
Cloudflare Workers V8 Isolate nodejs_compat flag (recommended) wrangler.toml: compatibility_flags = ["nodejs_compat"]
Vercel Edge Middleware V8 + @vercel/edge Manual polyfills; partial Node shims available Avoid node: prefixed imports; use Web APIs
Netlify Edge Functions Deno No auto-polyfill; explicit bundler mapping esbuild with platform: 'neutral'; npm: specifiers

Replacing Common Node APIs

The most common Node APIs needed in edge code, and their Web API replacements:

Node API Edge Replacement
crypto.createHash('sha256') crypto.subtle.digest('SHA-256', data)
crypto.randomBytes(n) crypto.getRandomValues(new Uint8Array(n))
Buffer.from(str, 'base64') Uint8Array.from(atob(str), c => c.charCodeAt(0))
fs.createReadStream(path) ReadableStream from a fetch or KV/Blob API
path.join(a, b) URL manipulation or string concatenation
util.promisify(fn) Wrap in a new Promise() directly

The two trickiest replacements—crypto and Buffer—have subtle behavioral differences worth handling carefully. The Node crypto module is synchronous and hash-oriented, whereas crypto.subtle is promise-based and operates over ArrayBuffer; the migration path is detailed in polyfilling Node crypto in edge runtimes. Buffer carries Node-specific methods (toString('hex'), readUInt32BE) with no direct Uint8Array equivalent, so each call site needs a deliberate rewrite—see replacing Node Buffer with Uint8Array at the edge.

Streaming: Replacing fs.createReadStream

fs.createReadStream does not exist at the edge. If you need to stream data from storage, use the platform’s native binding:

// utils/stream-from-bytes.ts
export function createEdgeReadableStream(source: Uint8Array): ReadableStream<Uint8Array> {
  const CHUNK_SIZE = 16 * 1024; // 16 KB chunks

  return new ReadableStream({
    start(controller) {
      if (source.length === 0) {
        controller.close();
        return;
      }

      let offset = 0;

      function push() {
        if (offset >= source.length) {
          controller.close();
          return;
        }
        controller.enqueue(source.subarray(offset, offset + CHUNK_SIZE));
        offset += CHUNK_SIZE;

        // Yield to event loop; avoids blocking the isolate on large payloads
        if (controller.desiredSize !== null && controller.desiredSize <= 0) {
          // Backpressure: wait for downstream to drain
          return;
        }
        setTimeout(push, 0);
      }

      push();
    },
  });
}

Bundle Size Targets

Metric Target
Polyfill payload (gzipped) < 50 KB
Total bundle (uncompressed, Cloudflare) < 1 MB
Cold start latency (p95) < 50 ms
Memory per invocation < 80 MB (128 MB cap)

Validation Smoke Tests

Run this suite in CI against the actual edge environment or a local emulator with strict runtime flags:

// tests/edge-polyfill-smoke.test.ts
import { describe, it, expect } from 'vitest';

describe('Edge Runtime Polyfill Validation', () => {
  it('resolves required globals without shim fallback', () => {
    const required = ['fetch', 'Headers', 'URL', 'crypto', 'btoa', 'atob', 'TextEncoder'];
    for (const name of required) {
      expect((globalThis as Record<string, unknown>)[name], `${name} missing`).toBeDefined();
    }
  });

  it('produces secure random bytes', async () => {
    const { getSecureRandomBytes } = await import('../utils/crypto-polyfill');
    const bytes = await getSecureRandomBytes(32);
    expect(bytes).toBeInstanceOf(Uint8Array);
    expect(bytes.length).toBe(32);
  });

  it('streams empty source without hanging', async () => {
    const { createEdgeReadableStream } = await import('../utils/stream-from-bytes');
    const stream = createEdgeReadableStream(new Uint8Array(0));
    const reader = stream.getReader();
    const result = await reader.read();
    expect(result).toEqual({ done: true, value: undefined });
  });
});

For Cloudflare-specific polyfill debugging workflows, see best practices for polyfilling Node.js modules in Cloudflare Workers.

Common Pitfalls

Symptom Cause Fix
Build passes, runtime throws process is not defined Dependency reads process.env at module init; no shim active Enable nodejs_compat or inject a minimal process guard; read env from the handler env arg
Script too large on deploy A full polyfill suite (e.g. node-stdlib-browser) bundled the whole standard library Scope include to the specific modules used; externalize OS-bound APIs
crypto.createHash is not a function Code reached globalThis.crypto (WebCrypto), not the Node crypto module Switch to crypto.subtle.digest per the crypto migration guide
Hex/readUInt32BE calls fail on a buffer A Buffer value was replaced with a raw Uint8Array lacking Node methods Add the specific helper (hex encode, DataView) rather than reintroducing Buffer
Works in wrangler dev, fails in production Local mode runs under Node and provides absent globals Validate with wrangler dev --remote before deploying

Runtime-Constraints Checklist

  • OS-bound modules (fs, net, tls, dns, child_process
  • Environment values come from the handler env argument, not process.env

Frequently Asked Questions

Should I just enable a full Node.js polyfill suite to be safe?

No. Full suites like node-stdlib-browser routinely exceed the 1 MB Cloudflare and Vercel bundle limits and add cold-start latency for modules you never call. Scope polyfills to the exact APIs your dependencies use, and prefer native Web APIs first.

Why does my code work in wrangler dev but fail in production?

Local wrangler dev runs the Worker inside a Node.js process, which provides process, fs, and other globals that the production V8 isolate does not. Always validate with wrangler dev --remote to run against real Cloudflare infrastructure.

Can I read process.env at the edge?

Not reliably. process.env is undefined in production Workers and Vercel Edge even with compatibility flags. Read environment values from the env argument passed to the fetch handler, and store secrets through the provider’s secret manager.

Is the Node crypto module the same as globalThis.crypto?

No. globalThis.crypto is WebCrypto (crypto.subtle, getRandomValues), which is promise-based and operates on ArrayBuffer. The Node crypto module is synchronous and hash-oriented. Migrate hashing and signing to crypto.subtle rather than shimming the Node module.

Can I replace Buffer with Uint8Array everywhere?

Mostly, but not blindly. Uint8Array covers raw bytes, but Buffer adds methods like toString('hex') and readUInt32BE that have no direct equivalent. Each call site needs a deliberate rewrite using TextDecoder, DataView, or a small hex helper.

Conclusion

Polyfills are a bridge, not a destination. Every shim you add increases bundle size, initialization latency, and long-term maintenance burden. The priority order: (1) replace the Node.js API with a native Web API, (2) use the provider’s built-in compatibility layer, (3) add a targeted build-time shim for the specific API that has no Web equivalent. Audit polyfill usage regularly—edge runtimes gain new native APIs with each platform update, often making existing shims redundant.