Netlify Edge Functions are small TypeScript or JavaScript handlers that run in a Deno-based runtime at the Netlify edge location closest to the visitor. They sit in front of your site's static files and serverless functions, see every matching request first, and can answer it, rewrite it, redirect it or pass it on and modify the response on the way back. The point is to put request-time logic such as authentication gates, localisation, A/B assignment and header rewriting where the latency is lowest, without giving up a mostly static site.
This page explains the request chain that decides when your function runs, the declaration and ordering rules that trip people up, the context API, response caching, the hard limits, and the failure modes that show up in production. Every API name and limit below was checked against Netlify's documentation on 2026-10-04; if you read this much later, recheck the limits page. For the general model of isolate-based edge runtimes, see cloud edge compute, and for the closest competing product, Vercel Edge Functions.
The request chain
Think of each request as walking a chain. Matching edge functions run first, in a defined order. Each one can return a Response, which ends the chain immediately; return a URL object, which rewrites to another path on the same site with a 200 status; or return undefined, or call context.next(), which hands the request to the next link. After the edge functions come redirects and rewrites, static files from the CDN, and serverless Netlify Functions. Because an edge function that returns a response ends the chain, your declared redirects do not apply to that request.
Two consequences follow. First, the edge function runs on every matching request unless you cache its response, so a function declared on /* sits on the critical path of every image and stylesheet. Second, an edge function that calls fetch() on its own site starts a brand-new request chain, in which matching edge functions run again. Use context.next() to continue the current chain instead; it is cheaper and cannot loop.
Anatomy of an edge function
An edge function is a file in netlify/edge-functions under your base directory, or another directory set with edge_functions in the [build] section of netlify.toml. Keep that directory outside the publish directory so the source is not deployed as static files. The file default-exports an async handler that receives a standard Request and a Netlify Context, and usually exports a config object declaring where it runs.
// netlify/edge-functions/geo-banner.ts
import type { Config, Context } from "@netlify/edge-functions";
export default async (request: Request, context: Context) => {
const country = context.geo?.country?.code ?? "XX";
const response = await context.next(); // fetch the static page
const type = response.headers.get("content-type") ?? "";
if (!type.includes("text/html")) return response; // leave assets alone
const page = await response.text();
const banner = country === "DE"
? '<div class="banner">Versand innerhalb Deutschlands kostenlos</div>'
: "";
const out = new Response(page.replace("<!--BANNER-->", banner), response);
out.headers.set("x-geo-country", country);
return out;
};
export const config: Config = {
path: "/shop/*",
excludedPath: ["/shop/*.css", "/shop/*.js", "/shop/img/*"],
};The handler above is middleware: it lets the static page through, rewrites a marker in the body and adds a header. Constructing the new Response from the old one's init keeps the status and headers. The exclusions matter more than they look; without them the function would also buffer and inspect every script and image under /shop/.
Declarations and ordering
A function can be declared inline, through its exported config, or in netlify.toml with an [[edge_functions]] block naming the function. Both forms accept path and excludedPath as URLPattern strings, pattern and excludedPattern as regular expressions, a header table that matches on a header being present (true), absent (false) or matching a regular expression, and cache. Two properties are inline-only: method, to restrict to GET, POST and so on, and onError.
# netlify.toml
[[edge_functions]]
path = "/account/*"
function = "auth"
[[edge_functions]]
path = "/api/mobile/*"
function = "mobile-headers"
[edge_functions.header]
user-agent = "(iPhone|Android)"When several functions match, the order is fixed: framework-generated functions declared in netlify.toml, then your netlify.toml declarations from top to bottom, then framework-generated inline declarations, then your inline declarations in alphabetical order of file name. The exception is caching: any function with cache set to manual runs after all functions that are not cached, regardless of where it is declared. If order matters, as it does when an auth check must precede personalisation, declare both in netlify.toml where the order is explicit rather than relying on file names.
The context API
The context object carries what a handler usually needs:
| Member | What it gives you |
|---|---|
context.geo | city, country code and name, subdivision, latitude, longitude, timezone and postal code of the visitor |
context.ip | client IP address as a string |
context.cookies | get(name), set(options), delete(name) in CookieStore style |
context.next() | continue the chain and get the Response; accepts a Request when you have read the body, and a sendConditionalRequest option |
context.params | values of named path parameters, such as name for /pets/:name |
context.waitUntil(p) | finish work such as logging after the response, still inside the CPU limit |
context.requestId, context.server.region | for log correlation and placement |
context.site, context.deploy, context.account | site id, name and URL; deploy id, context and published flag; team id |
Environment variables come from Netlify.env.get(name), with has, set, delete and toObject alongside; set and delete affect only the current invocation. One rule about bodies: a request body can be read once. If your function reads it, for example to validate a token in JSON, pass a new Request carrying the body to context.next(request), or the downstream handler receives an empty stream.
Worked example: a signed-cookie gate
A common job is a cheap gate in front of private pages: reject requests without a valid signed session cookie before the origin does any work. The sketch below verifies an HMAC over the cookie value with the Web Crypto API, which Deno implements; confirm it in Netlify's list of supported Web APIs before relying on it. The secret comes from an environment variable and comparison is done by the crypto library rather than by string equality.
// netlify/edge-functions/auth.ts
import type { Config, Context } from "@netlify/edge-functions";
const enc = new TextEncoder();
let keyPromise: Promise<CryptoKey> | null = null;
function key() {
keyPromise ??= crypto.subtle.importKey(
"raw", enc.encode(Netlify.env.get("SESSION_SECRET") ?? ""),
{ name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
return keyPromise;
}
function unb64(s: string) {
const b = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from(b, (ch) => ch.charCodeAt(0));
}
export default async (request: Request, context: Context) => {
const raw = context.cookies.get("session"); // "<payload>.<sig>"
const [payload, sig] = raw?.split(".") ?? [];
const ok = payload && sig && await crypto.subtle.verify(
"HMAC", await key(), unb64(sig), enc.encode(payload));
if (!ok) {
return Response.redirect(new URL("/login", request.url), 302);
}
const { exp } = JSON.parse(new TextDecoder().decode(unb64(payload)));
if (Date.now() / 1000 > exp) {
return Response.redirect(new URL("/login", request.url), 302);
}
return context.next();
};
export const config: Config = { path: "/account/*", onError: "fail" };Two decisions in that code are security decisions. onError is left at fail, which is also the default, so a crash serves an error page instead of letting the request through; bypass would skip the failing function and continue the chain, which for an auth gate means failing open. And the gate does its work at the edge but is not the only control: the origin APIs still check the session, because an edge gate protects pages, not every path into your data.
Caching edge responses
By default an edge function's output is not cached. Caching needs two parts, and doing only one does nothing: declare cache: "manual", inline or in netlify.toml, and return caching headers from the handler. Netlify honours Cache-Control, CDN-Cache-Control and Netlify-CDN-Cache-Control, with the Netlify-specific header taking precedence for its CDN, plus Expires, Vary and Netlify-Vary. Cached responses do not count as invocations, and a new deploy in the same context invalidates them automatically.
export default async (request: Request, context: Context) => {
const code = context.geo?.country?.code ?? "";
const bucket = ["DE", "FR", "US"].includes(code) ? code : "other";
const prices = await loadPriceTable(bucket); // output depends only on bucket
return new Response(JSON.stringify(prices), {
headers: {
"content-type": "application/json",
"netlify-cdn-cache-control": "public, s-maxage=300",
"netlify-vary": "country=de|fr|us",
"cache-control": "public, max-age=0, must-revalidate",
},
});
};
export const config: Config = { path: "/api/prices", cache: "manual" };The vary header is the dangerous part. A cached response is reused for every request with the same cache key, so the handler's output must depend only on what is in the key; that is why the example collapses all other countries into one bucket before the lookup. Anything you personalise must be in the key: a country through Netlify-Vary: country=..., an experiment bucket through cookie=..., a language through language=.... Responses personalised per user should not be cached at all. Remember too that a caching edge function shadows static files at the same path, and that local development ignores cache headers, so caching behaviour must be tested on a deploy preview.
Limits and platform behaviours
| Limit or behaviour | Value | What it means in practice |
|---|---|---|
| CPU time per request | 50 ms, excluding time waiting on I/O | Fine for headers, routing and small rewrites; risky for large HTML parsing or heavy crypto loops |
| Response header timeout | 40 s | Long upstream calls must at least start responding in time; stream if you can |
| Memory | 512 MB per set of deployed edge functions | Shared by all your edge functions, not per function |
| Code size | 20 MB after compression | Keep dependencies lean; bundle size also affects startup |
| Rewrites | same-site URLs only | Proxy to another origin with fetch, not a returned URL |
| Custom headers and basic auth | not applied to edge functions | Set headers in the function itself |
| Split Testing | disables edge functions | Do assignment in an edge function instead of combining the two |
| Compliance | not HIPAA-compliant | Keep protected health information off this path |
Failure modes
- Recursive invocation. A function on
/*that fetches its own site re-enters itself; usecontext.next()or exclude the internal path. - CPU limit on body rewriting. Buffering and regex-replacing a large page can exceed 50 ms of CPU; restrict the path, skip non-HTML responses, or move the transform to build time.
- Cache leaks. A personalised response cached without the matching Netlify-Vary dimension is served to other visitors. Review every cache: manual function for what it varies on.
- Fail-open auth. onError set to bypass on a gate, or a gate that only covers pages while APIs remain open.
- Lost request body after reading it in the function and calling context.next() without passing the request.
- Order surprises from alphabetical inline ordering, and from cache: manual functions silently moving to the end of the chain.
- Assuming headers or basic auth from netlify.toml protect a path served by an edge function; they do not apply there.
- A function declared only on a path that visitors reach through a static-routing rewrite never fires, because edge functions do not run for rewritten requests; declare it on the path the visitor actually requests.
Trade-offs and related reading
Choose an edge function for small, latency-sensitive decisions on many requests: gating, routing, localisation, header and cookie work, light HTML edits, and cached API responses keyed by a few dimensions. Choose a serverless Netlify Function for heavier work, longer execution, Node-specific libraries, or anything that talks to a regional database many times per request; the edge saves little when every call crosses the ocean to that database anyway. For pages that are expensive to render but identical for many visitors, durable caching as described in Netlify DPR usually beats running code on every hit. If you need data at the edge, a key-value store such as Cloudflare KV illustrates the eventual-consistency trade-offs any edge data layer brings.
Compared with Vercel's equivalent, the programming model is similar, web-standard Request and Response handlers, while the declaration model and chain semantics differ; porting between them is mostly a matter of configuration and context APIs, not logic.
What to do next
- List the request-time behaviours your site needs and mark which must run on every request and which can be cached.
- Create one middleware function with a narrow path and explicit excludedPath for assets, and deploy it to a preview.
- Declare order-sensitive functions in netlify.toml so the order is explicit; check where cache: manual functions land.
- Replace any same-site fetch() inside edge functions with context.next().
- Set onError deliberately per function: fail for gates, bypass only for optional enhancements.
- For each cached function, write down every input that changes the response and confirm it is in Netlify-Vary or the URL.
- Load-test the heaviest path against the 50 ms CPU limit and log context.requestId for tracing.