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.

Edge Web API surface A shared WHATWG core of fetch, URL, streams, WebCrypto, and encoding sits inside the supported surface, while Node built-ins fall outside it and provider shims sit at the boundary. Supported edge surface (all three providers) fetch / Request / Response URL / URLSearchParams ReadableStream / Transform crypto.subtle / randomUUID TextEncoder / TextDecoder structuredClone / atob Shims: node:crypto (Vercel) / nodejs_compat (CF) fs / net / tls child_process / path Buffer / raw TCP Absent (Node only)
A shared WHATWG core is available everywhere; Node-only built-ins fall outside the surface, and per-provider shims bridge a narrow band at the boundary.

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.

Detection points for an unsupported API Two incompatible constructs pass every pre-deploy stage and only fail on the first production request. A CI bundle audit at the bundling stage catches both before they ship. Where an edge-incompatible construct is actually caught type-check bundle local dev deploy first request import "node:fs" passes shimmed resolves uploads throws Buffer, no compat flag typed ok inlined works uploads ReferenceError with a CI bundle audit unchanged flagged build fails never runs never ships Every pre-deploy stage runs on Node or a Node emulation layer, so each one is capable of resolving a module the production isolate never installed. The audit does not add a check — it moves an existing failure four stages to the left.
The only stage that disagrees with the others is the one running in a real isolate, which is why a static audit at bundle time pays for itself immediately.

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.

From raw secret to verified signature A secret is encoded to bytes, imported as a non-extractable CryptoKey, and passed to crypto.subtle.verify. The verify result branches to either serving the request or a fail-closed rejection. The secret is never compared as a string — it becomes a key object first env binding secret as string TextEncoder → Uint8Array subtle.importKey raw, HMAC, SHA-256 CryptoKey extractable: false subtle.verify(key, sig, body) constant time, no === on digests key reused per request true → handle the request false or throw → reject A thrown DOMException and a false result are the same outcome: fail closed.
Because 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.

Four ambiguous behaviours across the three runtimes Four behaviours that are supported everywhere but implemented differently are compared across Cloudflare Workers, Vercel Edge and Netlify Edge Functions. Each cell names the runtime-specific form the behaviour takes. Supported everywhere, identical nowhere behaviour Cloudflare Workers Vercel Edge Netlify Edge Date.now() during sync work frozen until I/O advances advances work after the response ctx.waitUntil() event.waitUntil() context.waitUntil() URLPattern available version-dependent available via Deno reaching Node built-ins nodejs_compat flag fixed partial shim node: via import map Portable code either avoids these four entirely or wraps each one behind a per-platform adapter.
The rows that break portability are rarely the missing APIs — they are the present ones whose semantics or spelling shift by one word per platform.

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:

  1. Inventory the dependency tree. Resolve every transitive import and flag any package that pulls in node:-prefixed modules or references process, Buffer, or require.
  2. Map each flagged API to a Web equivalent. Replace crypto.createHash with crypto.subtle.digest, Buffer with Uint8Array + TextEncoder, and node-fetch with native fetch.
  3. Run a static bundle audit in CI. Use esbuild --analyze and grep for incompatible tokens so the build fails fast rather than at the first production request.
  4. Validate streaming paths. For any response over a few kilobytes, confirm the body is piped through ReadableStream rather 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.