Best Practices for Polyfilling Node.js Modules in Cloudflare Workers
This guide is part of Polyfill Strategies for Node.js APIs at the Edge. It focuses on the Cloudflare Workers workflow: enabling nodejs_compat, auditing dependencies, and reproducing the failures that local mode hides. For the two APIs that need bespoke handling, follow polyfilling Node crypto in edge runtimes and replacing Node Buffer with Uint8Array at the edge.
Identifying Polyfill Failures
Polyfill failures in Cloudflare Workers manifest as runtime exceptions that pass build-time validation. Common error signatures:
ReferenceError: process is not defined— a dependency readsprocess.envorprocess.platformat module initialization.TypeError: crypto.createHash is not a function— the Node.jscryptomodule is not the same asglobalThis.crypto(WebCrypto).Error: Module not found: stream— a transitive dependency imports Node’sstreammodule.
These errors occur because Wrangler successfully bundles the code (resolving imports to stubs or the package’s CJS version), but the V8 isolate runtime does not expose the expected globals during execution. Build success does not guarantee isolate compatibility.
Root Causes
1. Missing or Outdated nodejs_compat Flag
Without nodejs_compat in wrangler.toml, the Workers runtime defaults to strict Web API compliance. Node built-ins (Buffer, process, stream, crypto module) are completely absent. The fix is one line:
# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2025-09-23"
compatibility_flags = ["nodejs_compat"]
Use a recent compatibility_date. The Workers team regularly adds Node API coverage; a date from 2023 may miss APIs added in 2024–2025. Check the Cloudflare Workers compatibility-dates documentation to see what each date enables.
2. Bundle Size Exceeding 1 MB
Cloudflare Workers enforce a 1 MB uncompressed script size limit. Full Node.js polyfill suites (e.g., node-stdlib-browser) frequently exceed this and will cause deployment rejection. The error appears as Script too large during wrangler deploy.
3. CPU Budget Exhaustion from Polyfill Initialization
CPU budget is 10 ms (free tier) or up to 30 s CPU (paid) for synchronous execution. Heavy polyfill initialization—parsing large shims, running crypto polyfill self-tests—consumes this budget before your handler code runs, compounding the cold start penalty. The Worker returns error code 1101.
4. Environment Variable Access
process.env is not available in production Workers even with nodejs_compat. Environment variables are accessed via the env parameter passed to the fetch handler:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const apiKey = env.API_KEY; // Correct
// const apiKey = process.env.API_KEY; // Undefined in production
return new Response(`ok`);
},
};
Define variables in wrangler.toml for non-sensitive values, or use Cloudflare’s secret management for sensitive ones:
[vars]
NODE_ENV = "production"
API_BASE_URL = "https://api.example.com"
For secrets: wrangler secret put API_KEY
Step-by-Step Implementation
Step 1: Enable nodejs_compat
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2025-09-23"
compatibility_flags = ["nodejs_compat"]
With this flag, Buffer, process, stream, path, url, util, and a subset of node:crypto are available. Do not bundle additional polyfills for these modules—you will get duplicate implementations that increase bundle size.
Step 2: Audit Your Dependency Tree
Identify which third-party packages require Node built-ins:
npm ls | grep -E "aws-sdk|pg|mysql|axios|node-fetch"
npx depcheck --ignores="@types/*" 2>/dev/null | head -40
For each dependency, check whether it has an edge-compatible variant (e.g., @aws-sdk/client-s3 v3 vs AWS SDK v2) or whether an HTTP API call can replace the library entirely.
Step 3: Apply Targeted Bundler Configuration
Only polyfill what nodejs_compat does not cover and what your code actually uses. For a Vite-based build targeting Cloudflare:
// 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', 'node:child_process'],
},
},
plugins: [
// Only add if nodejs_compat doesn't cover what you need
nodePolyfills({ include: ['events'], globals: { global: true } }),
],
});
Step 4: Add Runtime Guards for process and global
Some dependencies check for process or global at runtime even when nodejs_compat is active. Add minimal guards at the top of your entry point:
// src/polyfills.ts — import this first in your entry point
if (typeof global === 'undefined') {
(globalThis as unknown as { global: typeof globalThis }).global = globalThis;
}
// Do NOT attempt to import from 'fs/promises' or 'fs' even with nodejs_compat
// Those APIs are not available in Workers; use KV, R2, or fetch instead
Step 5: Validate Bundle Size and Tree-Shaking
# Dry run to check uncompressed size
wrangler deploy --dry-run --outdir=dist
# Check size against 1 MB limit
wc -c dist/worker.js
# Visualize what's in the bundle
npx source-map-explorer dist/worker.js --html bundle-report.html
Local vs Production Debugging
wrangler dev (local mode) runs your Worker inside a Node.js process. This masks several failure modes:
process.envreads succeed locally because Node.js provides it.fsmodule imports resolve locally because Node.js provides it.- Bundle size limits are not enforced locally.
To reproduce production behavior:
# Run against real Cloudflare infrastructure (requires authentication)
wrangler dev --remote
# Stream production logs
wrangler tail --format pretty
wrangler dev --remote is the most reliable way to catch polyfill failures before production deployment.
| Behavior | Local (wrangler dev) |
Production / --remote |
|---|---|---|
process.env reads |
Succeed (Node provides it) | Undefined; use the env arg |
node:fs import |
Resolves under Node | Throws; not in the isolate |
| Bundle size limit | Not enforced | 1 MB uncompressed, hard reject |
globalThis.crypto |
Present | Present (WebCrypto) |
Node crypto module |
May resolve to CJS | Absent without nodejs_compat |
Validate with a Vitest Smoke Test
Run a smoke test under the Workers pool so the assertions execute inside a real isolate, not Node:
// tests/worker-polyfill.test.ts
import { describe, it, expect } from 'vitest';
describe('Cloudflare Worker polyfill surface', () => {
it('exposes WebCrypto rather than the Node crypto module', () => {
expect(typeof globalThis.crypto?.subtle?.digest).toBe('function');
expect(typeof globalThis.crypto?.getRandomValues).toBe('function');
});
it('reads config from the env binding, not process.env', () => {
const env = { API_BASE_URL: 'https://api.example.com' };
expect(env.API_BASE_URL).toBe('https://api.example.com');
});
it('round-trips base64 without Buffer', () => {
const bytes = Uint8Array.from(atob('aGVsbG8='), (c) => c.charCodeAt(0));
expect(new TextDecoder().decode(bytes)).toBe('hello');
});
});
Cache Headers for Polyfill-Heavy Routes
Polyfill initialization adds fixed overhead to every cold-start execution. Mitigate the impact by ensuring responses from polyfill-heavy routes are cached at the edge. If a route’s response is deterministic given the request, mark it cacheable:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const response = await handleRequest(request, env);
// Ensure stateless polyfill logic doesn't force cache bypass
const headers = new Headers(response.headers);
if (!headers.has('Cache-Control')) {
headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
headers.set('Vary', 'Accept-Encoding');
}
return new Response(response.body, { status: response.status, headers });
},
};
Check CF-Cache-Status: HIT in production response headers to confirm the route is being served from cache rather than re-executing polyfill initialization on every request.
Named Pitfalls
- Trusting a green build. Wrangler bundles successfully even when the isolate lacks the global. Fix: gate on
wrangler dev --remote, not the build exit code. - Stale
compatibility_date. A 2023 date misses Node coverage added later. Fix: bump to a recent date and re-test. - Double-shimming. Bundling
node-stdlib-browseralongsidenodejs_compatduplicates implementations and inflates the bundle. Fix: drop manual shims for anything the flag already covers. - Reading
process.env. Undefined in production. Fix: read from theenvargument; usewrangler secret putfor secrets. - Assuming
globalThis.cryptois the Node module. It is WebCrypto. Fix: migrate tocrypto.subtleper the crypto guide.
Production Deployment Checklist
-
compatibility_flags = ["nodejs_compat"]set with a recentcompatibility_date - No manual polyfill duplicates anything
nodejs_compat - All environment access goes through the
envargument; secrets stored viawrangler secret put -
wrangler deploy --dry-run - Polyfill behavior verified with
wrangler dev --remote -
wrangler tailwatched for1101andReferenceError
Frequently Asked Questions
Does nodejs_compat make every Node module available?
No. It covers common modules such as Buffer, process, stream, path, url, util, and a subset of node:crypto. OS-bound modules like fs, net, tls, and child_process remain unavailable—they have no meaning inside a V8 isolate and must be replaced with KV, R2, or fetch.
Why does my Worker return error code 1101?
Error 1101 signals an uncaught exception during execution, frequently a missing global referenced by a polyfill or a dependency. Check wrangler tail for the stack trace, confirm nodejs_compat is active, and look for code reading process or node:fs at module initialization.
What compatibility_date should I use?
Use a recent date. The Workers team adds Node API coverage continuously, so a date from 2023 may miss APIs your dependencies expect. Set it to a current date and re-run wrangler dev --remote to confirm the surface you need is present.
Can I bundle node-stdlib-browser alongside nodejs_compat?
Avoid it. Combining a full polyfill suite with the compatibility flag produces duplicate implementations, inflates the bundle past the 1 MB limit, and adds initialization cost. Use the flag for covered modules and add a single targeted shim only for the rest.
How do I keep polyfill initialization off the hot path?
Cache deterministic responses at the edge so polyfill-heavy routes re-execute only on a miss. Set Cache-Control and Vary: Accept-Encoding, then confirm CF-Cache-Status: HIT in production headers.
Related
- Polyfill strategies for Node.js APIs at the edge
- Polyfilling Node crypto in edge runtimes
- Replacing Node Buffer with Uint8Array at the edge
- Optimizing bundle size for edge runtime deployment
Conclusion
The fastest path to a working Cloudflare Worker with Node.js dependencies is enabling nodejs_compat and using a recent compatibility_date. This covers the majority of what third-party packages need without any bundler changes. Add targeted bundler polyfills only for APIs not covered by the compatibility flag, and always validate with wrangler dev --remote before deploying—local mode masks the failures that matter most.