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.
Auditing Which Node APIs Your Bundle Actually Pulls In
The node: import that breaks production is rarely one you wrote. It sits two or three levels down the dependency graph — a logging helper that reads process.hrtime, a validation library that calls Buffer.byteLength, an HTTP client that imports node:tls inside a branch that never executes at the edge but still gets bundled. Reading your own source tells you nothing about this; you have to inspect what the bundler emitted.
Bundlers disagree about what to do when they meet a Node built-in, and the disagreement is the reason the same code fails differently on each provider. Webpack 4 silently injected browser shims, webpack 5 stopped doing that and errors instead, and esbuild refuses to resolve node: specifiers unless you mark them external or supply an alias. The loud build-time failure is the one you want. A silent substitution produces a bundle that deploys cleanly and then throws on the first request that reaches the shimmed branch.
Build with a metafile and read the import graph directly rather than guessing:
// scripts/audit-node-imports.mjs — list every node: specifier and who imports it
import { readFile } from 'node:fs/promises';
const meta = JSON.parse(await readFile('meta.json', 'utf8'));
const offenders = new Map();
for (const [file, input] of Object.entries(meta.inputs)) {
for (const imported of input.imports ?? []) {
if (!imported.path.startsWith('node:')) continue;
const importers = offenders.get(imported.path) ?? [];
importers.push(file);
offenders.set(imported.path, importers);
}
}
for (const [specifier, importers] of offenders) {
console.log(`${specifier} <- ${importers.slice(0, 3).join(', ')}`);
}
Generate meta.json with esbuild src/index.ts --bundle --platform=neutral --metafile=meta.json. The neutral platform is deliberate: it applies no Node and no browser assumptions, so anything unresolvable surfaces as a build error instead of a runtime surprise. Run the audit in CI and fail the job when a specifier outside your approved list appears — the list should be short enough to review by eye.
Two blind spots remain. Dynamic requires (require('node:' + name)) defeat static analysis entirely, so they never appear in the metafile and only fail at runtime; grep for string concatenation near require if a module still misbehaves after a clean audit. And bare specifiers without the node: prefix — plain import path from 'path' — resolve through your node_modules if a userland package named path happens to be installed, which quietly changes behavior between machines. Normalize to prefixed specifiers so the intent is unambiguous.
Worked Example: Replacing a JWT Dependency on Vercel Edge
The audit output on a typical middleware bundle reads something like node:crypto <- node_modules/jwa/index.js. That is jsonwebtoken reaching for createHmac through its signature-algorithm helper. On Cloudflare, nodejs_compat covers this and you are done. On Vercel Edge Middleware there is no equivalent flag, so the choice is a shim or a rewrite — and for HS256 verification the rewrite is roughly twenty lines:
// utils/verify-hs256.ts — replaces jsonwebtoken on Vercel Edge
const encoder = new TextEncoder();
function base64UrlToBytes(part: string): Uint8Array {
const padded = part.replace(/-/g, '+').replace(/_/g, '/');
const full = padded.padEnd(Math.ceil(padded.length / 4) * 4, '=');
return Uint8Array.from(atob(full), (c) => c.charCodeAt(0));
}
export async function verifyHs256(token: string, secret: string): Promise<boolean> {
const [header, payload, signature] = token.split('.');
if (!header || !payload || !signature) return false;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
return crypto.subtle.verify(
'HMAC',
key,
base64UrlToBytes(signature),
encoder.encode(`${header}.${payload}`),
);
}
Three things changed, and each one ripples outward. Verification became asynchronous, so every call site turns into an await and any synchronous guard clause in your middleware has to move inside an async function. The signature bytes arrive as a Uint8Array built from atob, not a Buffer, so nothing downstream can call .toString('hex') on them. And the comparison is performed by crypto.subtle.verify itself, which is constant-time — do not be tempted to recompute the signature and compare with ===, because that reintroduces a timing side channel the Node library was protecting you from.
The payoff is measurable: the shimmed path costs roughly 150–200 KB of bundled crypto, stream, and buffer code that is parsed on every cold start, while the WebCrypto version adds nothing beyond your own function. If you verify on most requests, hoist the importKey call into a module-scope promise so the key material is derived once per isolate rather than once per request — the same instantiate-once pattern that WASM modules use.
Streaming: Replacing fs.createReadStream
fs.createReadStream does not exist at the edge, and no shim can conjure one — there is no filesystem behind it to read. Data comes from a platform binding (KV, R2, a Blob store, or an upstream fetch), and turning those bytes into a stream is something you build rather than import. The pump is three moving parts: fixed-size views over the source, a backpressure check before scheduling more work, and a close when the offset passes the end.
The binding supplies the bytes; this helper turns them into a stream the runtime can hand straight to a Response:
// 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) |
Edge Cases That Survive a Green Build
The failures that reach production are the ones no build step can see. Four recur often enough to check for deliberately.
Export conditions decide which branch you get. Modern packages ship an exports map with branches keyed on conditions — node, worker, workerd, edge-light, deno, browser, default — and each provider’s bundler matches them in its own order. Cloudflare’s toolchain prefers the workerd and worker branches, Vercel’s edge builder looks for edge-light, and Netlify’s Deno-based bundler resolves deno before falling through to branches written for Node. The package author cannot control which one a provider picks, so an identical package.json dependency can resolve to a WebCrypto implementation on one platform and a node:crypto implementation on another. Pin the conditions explicitly in your bundler configuration instead of trusting defaults.
Module-init side effects fail before your handler exists. A dependency that reads process.env.API_URL at module top level throws while the isolate is being constructed, not while a request is being served. You get a 500 with no route, no request id, and a stack trace pointing at a file you have never opened. Anything that must read configuration should do it inside the handler, from the env argument, so the failure is attributable to a request.
A shim that lies is worse than a missing one. Injecting process = { env: {} } to silence a build error turns process.env.API_KEY from a loud ReferenceError into a quiet undefined, and the first symptom is an unauthenticated upstream call in production. If you must inject a process object, make its env a Proxy that throws on unknown reads, so the failure keeps its original volume.
Compatibility flags move with your compatibility date. On Cloudflare, nodejs_compat is not a fixed contract — which built-ins it exposes, and how faithfully, is tied to the compatibility_date in wrangler.toml. Bumping that date to pick up an unrelated fix can change the shape of the Node surface underneath you. Treat a date bump as a deploy that needs the smoke tests below, not a routine version bump. The related trap is the dual-package hazard: bundling both the CJS and ESM copies of a shim gives you two Buffer constructors, and value instanceof Buffer starts returning false for values that plainly are buffers.
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
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.
How do I find which dependency imports a Node built-in?
Build with esbuild --bundle --platform=neutral --metafile=meta.json and walk meta.inputs, collecting every import whose path starts with node: along with the file that imported it. That maps each specifier back to the exact module in node_modules responsible for it. Dynamic requires built from string concatenation do not appear in the metafile, so grep for those separately.
Why does the same package work on Cloudflare but break on Netlify?
Package exports maps branch on conditions, and each provider matches them in its own order. Cloudflare prefers workerd/worker, Vercel looks for edge-light, and Netlify’s Deno bundler can fall through to the node branch that the other two never select. Pin the condition list in your bundler configuration so every platform resolves the same file.
Why does a polyfill error appear before my handler runs?
Because the offending code executes at module initialization, not per request. A dependency reading process.env at top level throws while the isolate is being constructed, which surfaces as a 500 with no route or request id attached. Move configuration reads inside the handler and take values from the env argument so failures stay attributable to a request.
Can bumping my compatibility_date change what nodejs_compat provides?
Yes. The flag is not a fixed contract — which built-ins it exposes, and how closely they match Node, is tied to the compatibility_date in wrangler.toml. A date bump taken to pick up an unrelated fix can change the Node surface underneath your code, so run the polyfill smoke tests against wrangler dev --remote after changing it.
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.