Skip to main content

Command Palette

Search for a command to run...

Edge Functions Explained: A Practical Guide with Real Examples

Updated
•8 min read•View as Markdown
Edge Functions Explained: A Practical Guide with Real Examples

Most explanations of edge functions stop at "your code runs closer to the user." That is true, and it is also the least useful thing to know when you are deciding whether to put one in production.

This guide covers what edge functions actually are, how the runtime differs from Node.js, four patterns you can copy today, and the one architectural mistake that makes edge functions slower than the regional server they replaced.

The examples use Cloudflare Workers syntax. The same ideas apply to Vercel Edge Functions, Deno Deploy, Netlify Edge Functions, and Supabase Edge Functions with small changes.

What is an edge function?

An edge function is a small piece of server-side code that is deployed to many locations around the world at once. When a request arrives, the network routes it to the nearest location and runs your code there.

Compare that with a traditional backend:

  • Regional server or serverless function: your code lives in one region, for example ap-south-1. A user in Sydney waits for the request to travel to Mumbai and back.

  • Edge function: your code lives in hundreds of locations. The Sydney user is served from Sydney.

The network round trip to a distant region can cost 200 to 300 ms before your code runs a single line. Edge functions remove most of that.

How the runtime works: isolates, not containers

Traditional serverless platforms start a container or micro-VM for your function. If no warm instance exists, the user waits for a cold start, which can take hundreds of milliseconds.

Most edge platforms use V8 isolates instead. An isolate is the same sandbox that separates tabs in Chrome. Thousands of isolates can share one process, and a new one starts in a few milliseconds.

This design has two consequences:

  1. Cold starts are close to zero. You can run code on every request without a latency penalty.

  2. You do not get Node.js. You get a runtime built on Web standard APIs: Request, Response, fetch, URL, crypto.subtle, and streams. Packages that depend on native modules or on fs will not work. Node compatibility layers exist on most platforms, but they are partial.

Your first edge function

export default {
  async fetch(request, env, ctx) {
    const country = request.cf?.country ?? "unknown";

    return Response.json({
      message: "Hello from the edge",
      country,
    });
  },
};

A function receives a Request and returns a Response. That is the whole contract. request.cf is Cloudflare-specific and carries geolocation data; other platforms expose the same information through headers.

Edge functions vs serverless functions

Serverless functions Edge functions
Location One region you choose Many locations worldwide
Isolation Container or micro-VM V8 isolate
Cold start Tens to hundreds of ms A few ms
Runtime Full Node.js, Python, Go, Java Web standard APIs, partial Node support
CPU and memory Generous Strict limits
Max duration Minutes Seconds of CPU time
Best for Heavy computation, long jobs, native dependencies Short, latency-sensitive logic on every request

These are not competing options. Most production systems use both: edge functions in front, regional functions or servers behind.

Four patterns worth copying

1. Verify auth before the request reaches your origin

Rejecting bad requests at the edge saves origin capacity and gives unauthenticated users a fast answer.

import { jwtVerify } from "jose";

export default {
  async fetch(request, env) {
    const header = request.headers.get("Authorization") ?? "";
    const token = header.replace("Bearer ", "");

    if (!token) {
      return new Response("Unauthorized", { status: 401 });
    }

    try {
      const secret = new TextEncoder().encode(env.JWT_SECRET);
      const { payload } = await jwtVerify(token, secret);

      // Forward to the origin with the verified user id attached
      const forwarded = new Request(request);
      forwarded.headers.set("x-user-id", payload.sub);
      return fetch(forwarded);
    } catch {
      return new Response("Invalid token", { status: 401 });
    }
  },
};

jose works here because it is built on the Web Crypto API. The popular jsonwebtoken package depends on Node's crypto module and is a common first stumbling block.

2. Proxy third-party APIs to keep keys out of your app

Any key you ship inside a mobile or web bundle can be extracted. An edge function is the cheapest place to hide it.

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const { prompt } = await request.json();

    const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model: "gpt-6-luna",
        messages: [{ role: "user", content: prompt }],
      }),
    });

    return new Response(upstream.body, {
      status: upstream.status,
      headers: { "Content-Type": "application/json" },
    });
  },
};

The client only ever sees your endpoint:

const res = await fetch("https://api.example.com/ai", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ prompt: "Summarise my notes" }),
});
const data = await res.json();

This pattern matters most for React Native and Expo apps, where there is no server by default. If you generate your app with RapidNative, put a proxy like this in front of every paid API before you ship. Add rate limiting per user as well, because a hidden key behind an open endpoint is still an open wallet.

3. A/B testing without flicker

Client-side experiments load the page and then swap content, which users see as a flash. Deciding the variant at the edge avoids it.

export default {
  async fetch(request) {
    const cookie = request.headers.get("Cookie") ?? "";
    let variant = cookie.match(/variant=(a|b)/)?.[1];
    const isNew = !variant;

    if (isNew) {
      variant = Math.random() < 0.5 ? "a" : "b";
    }

    const url = new URL(request.url);
    url.pathname = `/${variant}${url.pathname}`;

    const response = await fetch(new Request(url, request));
    const result = new Response(response.body, response);

    if (isNew) {
      result.headers.append(
        "Set-Cookie",
        `variant=${variant}; Path=/; Max-Age=2592000`
      );
    }

    return result;
  },
};

4. Cache API responses close to the user

export default {
  async fetch(request, env, ctx) {
    const cache = caches.default;

    let response = await cache.match(request);
    if (response) return response;

    response = await fetch(request);
    response = new Response(response.body, response);
    response.headers.set("Cache-Control", "public, max-age=60");

    ctx.waitUntil(cache.put(request, response.clone()));
    return response;
  },
};

ctx.waitUntil lets the cache write finish after the response has been sent, so the user does not wait for it.

The mistake that makes edge functions slower

Here is the part most tutorials skip. Your function runs in Sydney. Your Postgres database runs in Virginia. Every query crosses the Pacific.

If a handler makes three sequential queries, you have paid for three long round trips instead of one. A regional function sitting next to the database would have been faster.

There are four ways to deal with this:

  • Cache reads at the edge. Use the Cache API or a key-value store for data that tolerates being slightly stale.

  • Use a globally distributed data store. Edge KV stores, replicated SQLite, or Postgres read replicas put the data near the function.

  • Use an HTTP-based driver or connection pooler. Isolates cannot hold long-lived TCP connection pools the way a server can. Without a pooler you can exhaust database connections quickly.

  • Move the function to the data. Several platforms let you pin a function to a region. For write-heavy endpoints this is often the right answer.

A simple rule: if a handler makes more than one uncached database round trip, measure it before you assume the edge is faster.

Other limitations to plan for

  • CPU time limits. Image processing, PDF generation, and large JSON transforms can hit the ceiling.

  • Bundle size limits. Large dependencies may not fit.

  • Package compatibility. Check that a library supports edge runtimes before you design around it.

  • Observability. Code running in hundreds of locations needs structured logs and a request id on every line.

When to use edge functions

Use them when the logic is:

  • short,

  • run on most requests,

  • sensitive to latency, and

  • not dependent on a far-away database.

Auth checks, redirects, geolocation, personalisation, API proxying, webhooks, and caching all fit.

Use regional serverless functions or a server for heavy computation, long-running jobs, native dependencies, and transactional database work.

Shipping it

For a mobile app, the practical stack is usually a client, a thin edge layer for auth and proxying, and a regional backend for data. If you would rather not wire that together by hand, RapidNative Deploy handles shipping the app and its backend from one place, and a starter kit such as AppLighter gives you a pre-configured Expo project to start from.

Summary

  • Edge functions run your code in many locations, on V8 isolates, with almost no cold start.

  • The runtime is Web standard APIs, not Node.js. Check your dependencies.

  • They are ideal for auth, proxying, A/B tests, and caching.

  • They do not fix a slow database path. If your data is in one region, cache it, replicate it, or run the function next to it.

  • Use edge and regional functions together, each for the work it suits.