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.
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
envargument, notprocess.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.
Related
- Best practices for polyfilling Node.js modules in Cloudflare Workers
- Polyfilling Node crypto in edge runtimes
- Replacing Node Buffer with Uint8Array at the edge
- Supported Web APIs in edge runtimes
- Optimizing bundle size for edge runtime deployment
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.