Supported Web APIs in Edge Runtimes
This guide is part of Edge Runtime Fundamentals & Platform Constraints.
Edge runtimes deliberately restrict the API surface to WHATWG-compliant browser primitives. The rationale is portability and determinism: by running on a curated subset of the same APIs browsers expose, isolates can be pre-compiled, snapshotted, and deployed globally without OS-level dependencies. What you gain in cold-start speed and geographic distribution, you pay for in API surface reduction. Because each platform builds its V8 isolate on a different base — V8 directly for Cloudflare and Vercel, Deno for Netlify — the available surface diverges in ways that break code which passes locally but fails in production.
This guide maps what is and is not available across the three major edge platforms, with accurate constraint figures as of mid-2026.
Universal API Surface
All major edge runtimes (Cloudflare Workers, Vercel Edge Middleware, Netlify Edge Functions) support:
| API | Notes |
|---|---|
fetch, Request, Response, Headers |
WHATWG Fetch; streaming body via ReadableStream |
URL, URLSearchParams |
Full WHATWG URL; URLPattern varies by platform |
ReadableStream, WritableStream, TransformStream |
Web Streams API; backpressure supported |
crypto.subtle |
WebCrypto: AES-GCM, HMAC, RSA, ECDSA, SHA family |
crypto.randomUUID() |
Cryptographically secure |
TextEncoder, TextDecoder |
UTF-8; TextDecoder supports additional encodings |
atob, btoa |
Base64 encode/decode |
setTimeout, clearTimeout |
Within the execution window only |
performance.now() |
High-resolution timer; available everywhere |
console.log/warn/error |
Routed to platform log aggregation |
structuredClone() |
Deep copy; available in all modern runtimes |
Absent everywhere: fs, net, tls, child_process, path, os, http (Node.js module), Buffer (Node.js), process.exit, synchronous I/O, raw TCP sockets, WebAssembly compilation at request time (pre-compiled WASM modules can be instantiated).
Provider-Specific Constraints
Runtime Constraint Thresholds
| Constraint | Cloudflare Workers | Vercel Edge Middleware | Netlify Edge Functions |
|---|---|---|---|
| Memory | 128 MB | 128 MB | 512 MB |
| CPU budget | 10 ms (free) / 30 s default, up to 5 min (paid) synchronous | No separate CPU limit | No separate CPU limit |
| Wall-clock timeout | 30 s | 1000 ms (middleware) | 50 s |
| Bundle size | 10 MB gzipped (paid) / 3 MB (free) | 1 MB compressed | 20 MB |
| Filesystem | None (use KV / R2) | None (use Vercel Blob) | None (use Netlify Blobs) |
| WebCrypto | Full: RSA, EC, AES-GCM, HMAC, SHA | Full + limited node:crypto shim |
Full via Deno crypto |
| Node.js compat | nodejs_compat flag (opt-in) |
Partial shim via @vercel/edge |
Deno + explicit import maps |
Provider Callouts
Cloudflare Workers: Zero Node.js globals by default. Enable nodejs_compat in wrangler.toml to access Buffer, process, stream, and a subset of crypto. Database drivers must use HTTP-based or native bindings (D1, Hyperdrive). The Cache API (caches.default) is fully functional. URLPattern is supported.
Vercel Edge Middleware: Runs inside the Next.js edge runtime. cookies() and headers() helper functions from next/headers are available only in Server Components and Route Handlers, not in middleware.ts. In middleware.ts use req.cookies and direct header manipulation. The runtime exposes path and url from Node.js as partial shims; avoid fs and net even if they appear resolvable locally.
Netlify Edge Functions: Deno-based runtime. Imports use URL-style or npm: specifiers. The context.next() and context.rewrite() methods are Netlify-specific extensions. Automatic redirect handling in fetch is enabled by default. The Deno standard library is accessible but adds to bundle size.
How the Runtime Decides What Exists
The useful mental model is that an edge runtime is not Node with modules removed. It is a fresh global scope assembled from a fixed list of constructors and then frozen into a snapshot that gets cloned per request. Nothing resolves an identifier by walking a filesystem at request time, so there is no lazy loading and no module cache to patch after the fact. Either a global was installed when the isolate was built, or it does not exist at all. That is why the failure mode is an abrupt ReferenceError: Buffer is not defined, or a module-resolution error thrown during script evaluation before your handler ever runs, rather than a graceful undefined you could feature-detect around at leisure.
Two knobs move that boundary, and both are set outside your application code. On Cloudflare, compatibility_date and compatibility_flags in wrangler.toml select which snapshot your script runs against; adding nodejs_compat installs a curated subset of node: modules into the global scope before the snapshot is taken. On Vercel, the shim layer is fixed per runtime version and cannot be extended from your project — the surface you get is whatever the deployment ships with. Netlify resolves node: specifiers through Deno’s own compatibility layer, which is why an import that throws on Workers can succeed there unchanged, and why “it works on Netlify” is not evidence that a dependency is edge-safe.
The awkward part is that none of this is visible to your bundler. esbuild and webpack resolve node:crypto happily, and depending on platform and conditions settings they may substitute a browser shim, drop the import entirely, or inline a polyfill that silently produces wrong output — a randomBytes shim backed by Math.random() is the classic example, and it produces predictable “random” tokens with no error anywhere. The build stays green, the upload succeeds, and the first production request is where the gap surfaces.
The most reliable probe is a typeof check executed inside the deployed isolate — not in your editor, and not in local dev. A throwaway diagnostic route that returns typeof URLPattern, typeof Buffer, typeof process, and the runtime’s navigator.userAgent for the exact deployment you are debugging settles compatibility arguments in seconds, and it is the only answer that accounts for your compatibility date, your flags, and your bundler’s substitutions at the same time.
Streaming Responses Without Buffering
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const externalRes = await fetch('https://api.example.com/data-stream');
if (!externalRes.ok || !externalRes.body) {
return new Response('Upstream failed', { status: 502 });
}
// Stream body directly; inject cache headers
const headers = new Headers(externalRes.headers);
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=600');
headers.delete('Content-Length'); // Required when streaming
return new Response(externalRes.body, {
status: externalRes.status,
headers,
});
}
Never buffer an entire response body into memory when you can stream it. Buffering a 50 MB response on a platform with a 128 MB memory cap will trigger an OOM kill.
Cryptographic Operations
The Node.js crypto module is absent. Use crypto.subtle for all signing, verification, and hashing operations:
async function verifyPayloadSignature(
payload: ArrayBuffer,
signature: Uint8Array,
key: CryptoKey
): Promise<boolean> {
try {
return await crypto.subtle.verify(
{ name: 'HMAC', hash: { name: 'SHA-256' } },
key,
signature,
payload
);
} catch (err) {
// Fail closed: abort on any verification error
throw new Error('Signature verification failed', { cause: err });
}
}
Import CryptoKey objects via crypto.subtle.importKey; do not pass raw strings as secrets. Use crypto.subtle.digest for hashing and crypto.subtle.sign/verify for HMAC and RSA operations.
verify compares internally in constant time, routing both the false branch and the throw branch to the same rejection removes the timing side channel a hand-rolled digest comparison would introduce.Worked Example: Porting a Signed Webhook Handler
A webhook receiver is the densest concentration of Node-only APIs in a typical codebase, which makes it the clearest port to walk through. The Node version usually reaches for four things the edge does not have: crypto.createHmac to recompute the signature, Buffer.from(sig, 'hex') to decode the header, crypto.timingSafeEqual for the comparison, and often fs.readFileSync or process.env loaded from a .env file to obtain the shared secret. Every one of them has an edge-native counterpart, and the result is shorter than the original.
// Edge-native webhook verification — no node: imports
const HEX = /^[0-9a-f]+$/i;
function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0 || !HEX.test(hex)) throw new Error('malformed signature');
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return out;
}
export async function handleWebhook(req: Request, secret: string): Promise<Response> {
const header = req.headers.get('x-signature');
if (!header) return new Response('missing signature', { status: 401 });
// Read the body exactly once, as bytes — not text, to avoid re-encoding drift
const body = await req.arrayBuffer();
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false, // non-extractable: the key cannot be read back out
['verify']
);
let ok = false;
try {
ok = await crypto.subtle.verify('HMAC', key, hexToBytes(header), body);
} catch {
ok = false; // malformed hex is an invalid signature, not a 500
}
if (!ok) return new Response('invalid signature', { status: 401 });
return new Response(null, { status: 202 });
}
Three details in that port are easy to get wrong. First, the body must be read as an ArrayBuffer, not with .text() — decoding to a JS string and re-encoding it changes the bytes whenever the payload contains characters outside the ASCII range or a lone surrogate, and the HMAC then fails for a subset of legitimate requests that is maddening to reproduce. Second, crypto.subtle.verify already compares in constant time, so timingSafeEqual has no replacement because it has no job; if you ever do need to compare two digests yourself, XOR-accumulate across the full length and check the accumulator once rather than returning early on the first mismatched byte. Third, importKey is not free — it costs real CPU on every invocation, and on Cloudflare that lands inside the synchronous CPU budget. Hoist it to module scope keyed by secret only if the secret is genuinely static for the isolate’s lifetime, and remember that module scope is shared across requests in the same isolate, so never cache anything request-specific alongside it.
The secret itself arrives through a platform binding — env.WEBHOOK_SECRET on Cloudflare, process.env.WEBHOOK_SECRET inlined at build time on Vercel — and never from disk. This is one of the places where the missing filesystem is a feature: there is no code path that can accidentally read a secret out of a bundled file that shipped to a PoP.
Structured Logging & Correlation IDs
Edge environments typically strip stack traces. Use structured JSON with a correlation ID generated at request entry:
export async function middleware(request: Request) {
const correlationId = crypto.randomUUID();
const start = performance.now();
try {
const response = await handleRequest(request);
console.log(JSON.stringify({
level: 'info',
correlationId,
path: new URL(request.url).pathname,
latencyMs: (performance.now() - start).toFixed(2),
status: response.status,
}));
return response;
} catch (err) {
console.error(JSON.stringify({
level: 'error',
correlationId,
message: err instanceof Error ? err.message : 'unknown',
}));
return new Response('Service Unavailable', { status: 503 });
}
}
CI Compatibility Testing
Run a matrix against all target runtimes as part of CI. The most practical check is a static bundle audit that flags node: prefixed imports and globals (process, Buffer, require) that edge runtimes do not expose:
# Audit bundle for edge-incompatible imports
npx esbuild src/edge-handler.ts --bundle --analyze --outfile=/dev/null 2>&1 | grep -E "(node:|Buffer|process\.env)"
For per-provider deployment configuration and polyfill management, see Polyfill Strategies for Node.js APIs at the Edge and Managing Cold Starts in Serverless Environments.
Edge Cases That Only Surface in Production
A handler can use nothing but supported APIs and still misbehave, because several of those APIs behave differently inside an isolate than they do in a browser or in Node. These are the ones that reach production most often.
The clock does not advance during synchronous work. Cloudflare Workers hold Date.now() — and the coarsened performance.now() — steady until the isolate performs I/O, as a timing side-channel mitigation. A loop that measures its own duration reports 0. The structured-logging example above works only because the measurement spans an await on a real network call; a purely computational span will always read zero, and CPU time has to come from platform observability rather than from inside the handler.
Work you do not await is cancelled. Returning a Response ends the request. A fetch to an analytics endpoint fired without await, or a setTimeout scheduled just before the return, is discarded when the invocation completes — usually silently, which is why “our edge logs only capture some events” is such a common report. Every platform exposes the same escape hatch under a different name: ctx.waitUntil() on Cloudflare, event.waitUntil() on Vercel, context.waitUntil() on Netlify. The promise you hand it keeps the invocation alive after the response has been flushed to the client.
Request and response bodies are single-use. await req.text() consumes the stream; passing that same req to fetch afterwards throws. Call req.clone() before reading if the handler both inspects and forwards a body, and be aware that cloning forces the runtime to buffer whatever the slower consumer has not read yet, which puts the memory cap back in play for large uploads.
atob and btoa operate on binary strings, not bytes. Round-tripping UTF-8 through them corrupts any payload containing non-ASCII characters. Decode with atob into a Uint8Array by char code, then hand that array to TextDecoder; going straight from atob to a string is the source of most mangled JWT payload claims at the edge.
Response headers from fetch may be immutable. Mutating response.headers directly on an upstream response throws in several runtimes. Re-wrap instead — new Response(res.body, res) gives you a mutable Headers instance over the same stream, which is exactly what the streaming example above relies on.
Subrequest budgets are finite. Each invocation is allowed a bounded number of outbound fetch calls (50 on Cloudflare’s free plan, 1000 on paid), and a fan-out loop over an array of IDs will exhaust that budget long before it exhausts the wall clock. Batch upstream reads, or move the fan-out to a serverless function where the limits are different.
Auditing API Compatibility Before Deploy
Run this workflow to confirm a handler stays inside the supported surface before it ships:
- Inventory the dependency tree. Resolve every transitive import and flag any package that pulls in
node:-prefixed modules or referencesprocess,Buffer, orrequire. - Map each flagged API to a Web equivalent. Replace
crypto.createHashwithcrypto.subtle.digest,BufferwithUint8Array+TextEncoder, andnode-fetchwith nativefetch. - Run a static bundle audit in CI. Use
esbuild --analyzeand grep for incompatible tokens so the build fails fast rather than at the first production request. - Validate streaming paths. For any response over a few kilobytes, confirm the body is piped through
ReadableStreamrather than buffered into memory.
# Audit bundle for edge-incompatible imports
npx esbuild src/edge-handler.ts --bundle --analyze --outfile=/dev/null 2>&1 | grep -E "(node:|Buffer|process\.env)"
Runtime Compatibility Checklist
Frequently Asked Questions
Is the Node.js crypto module available in any edge runtime?
Not natively. The standards-based replacement is crypto.subtle (WebCrypto), available on all three providers for hashing, HMAC, RSA, ECDSA, and AES-GCM. Cloudflare’s nodejs_compat flag and Vercel’s partial node:crypto shim expose a subset of the Node API, but portable code should target WebCrypto directly. The polyfill strategies guide covers bridging the gap.
Why does my code work locally but fail when deployed to the edge?
Local development servers (vercel dev, wrangler dev without --remote) often run on a Node.js emulation layer that resolves modules the production isolate does not expose. A path or fs import can appear to work locally and then throw at the edge. Always run a static bundle audit and test against real infrastructure with wrangler dev --remote or a preview deployment.
Can I compile WebAssembly at request time in an edge function?
No. Runtime WASM compilation is disallowed because it would defeat the snapshotting that keeps isolates fast to start. Pre-compiled WASM modules can be instantiated, so compile WASM at build time and ship the binary as part of the bundle, mindful of the platform bundle-size cap.
Is URLPattern available everywhere?
URLPattern is supported on Cloudflare Workers and Deno-based Netlify Edge Functions, and is available in current Vercel Edge runtimes, but it is the most likely of the routing primitives to vary by version. Gate it behind a typeof URLPattern !== 'undefined' check, or fall back to URL plus manual segment matching for maximum portability.
How do I handle large request or response bodies within a 128 MB cap?
Stream them. Pass the upstream Response.body (a ReadableStream) straight through rather than calling .text() or .arrayBuffer(), which buffers the whole payload into memory. Delete the Content-Length header when forwarding a stream, and offload anything above ~1 MB to a serverless function.
Why does Date.now() return the same value throughout my Worker?
Cloudflare Workers hold the clock steady until the isolate performs I/O, as a timing side-channel mitigation, and performance.now() is coarsened for the same reason. Any span that contains only synchronous computation therefore measures zero. Measure across an await on a real network or storage call if you need in-handler timing, and take actual CPU figures from platform observability rather than from inside the isolate.
What happens to work I start but do not await before returning a response?
It is cancelled. Returning a Response completes the invocation, and any pending promise — an unawaited analytics fetch, a setTimeout callback — is discarded, usually with no error recorded anywhere. Register it instead with ctx.waitUntil() on Cloudflare, event.waitUntil() on Vercel, or context.waitUntil() on Netlify, which keeps the invocation alive after the response has been flushed.
Can I read a request body more than once at the edge?
Not from the same object. Request and Response bodies are single-use streams, so await req.text() followed by fetch(req) throws. Call req.clone() before the first read when the handler needs to both inspect and forward the body, and remember that cloning makes the runtime buffer whatever the slower consumer has not yet drained — which puts the 128 MB cap back in play for large uploads.
Conclusion
The API surface restriction is the defining constraint of edge runtimes. Before writing any edge function, audit your dependency tree for Node.js built-ins, identify which operations require WebCrypto equivalents, and validate streaming compatibility for any payload above a few kilobytes. The payoff—sub-10 ms isolate initialization and global PoP distribution—is only realized when the code respects the boundaries the platform was built around.