Steven's Knowledge

Patterns

Auth at the edge, A/B testing, geo-routing, edge-side rendering, KV state, request/response transforms

Patterns

The handful of patterns that show up in nearly every edge-functions deployment. Examples use Cloudflare Workers syntax; concepts transfer.

Auth Middleware

Verify a JWT or session at the edge so unauthorized requests never reach origin:

import { jwtVerify } from 'jose';

export default {
  async fetch(request, env, ctx) {
    const auth = request.headers.get('authorization');
    if (!auth?.startsWith('Bearer ')) {
      return new Response('Unauthorized', { status: 401 });
    }

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

      // Add the user as a header so origin doesn't have to verify again
      const headers = new Headers(request.headers);
      headers.set('X-User-Id', payload.sub);
      headers.delete('authorization');

      return fetch(request.url, { ...request, headers });
    } catch {
      return new Response('Invalid token', { status: 401 });
    }
  },
};

Origin gets a clean request with a verified X-User-Id header. Bad traffic bounced at the edge — origin sees zero load.

A/B Testing

Bucket users at the edge, set a cookie so the variant is stable per user, route accordingly:

export default {
  async fetch(request, env, ctx) {
    let variant = getCookie(request, 'ab-variant');

    if (!variant) {
      variant = Math.random() < 0.5 ? 'control' : 'treatment';
    }

    const targetUrl = new URL(request.url);
    if (variant === 'treatment') {
      targetUrl.hostname = 'treatment-origin.example.com';
    } else {
      targetUrl.hostname = 'control-origin.example.com';
    }

    const response = await fetch(targetUrl, request);

    // Set the cookie if it didn't exist
    const newResponse = new Response(response.body, response);
    if (!getCookie(request, 'ab-variant')) {
      newResponse.headers.append('set-cookie',
        `ab-variant=${variant}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax`);
    }

    // Also send to your analytics
    ctx.waitUntil(logExposure(env, variant, request));

    return newResponse;
  },
};

ctx.waitUntil lets you fire-and-forget side effects without blocking the response.

Geo-Based Routing

Cloudflare puts country / region / city / colo on every request:

export default {
  async fetch(request, env, ctx) {
    const country = request.cf?.country;
    const url = new URL(request.url);

    if (country === 'CN' || country === 'RU') {
      // Different origin for compliance reasons
      url.hostname = 'origin-restricted.example.com';
    } else if (['DE', 'FR', 'IT', 'ES'].includes(country)) {
      // EU data-residency origin
      url.hostname = 'origin-eu.example.com';
    } else {
      url.hostname = 'origin-default.example.com';
    }

    return fetch(url, request);
  },
};

Vercel and Netlify expose similar request properties (request.geo).

Edge-Side Personalization

Serve cached HTML to everyone, inject personalized fragments at the edge from a fast KV store:

export default {
  async fetch(request, env, ctx) {
    const userId = getCookie(request, 'uid');

    const [shell, userData] = await Promise.all([
      fetch(request.url),                                  // cached
      userId ? env.USERS_KV.get(`user:${userId}`, 'json') : null,
    ]);

    if (!userData) return shell;                           // not logged in

    // Stream-replace a placeholder in the HTML
    const rewriter = new HTMLRewriter().on('#user-nav', {
      element(el) {
        el.setInnerContent(`<a>${userData.name}</a>`, { html: true });
      },
    });

    return rewriter.transform(shell);
  },
};

The shell stays fully cacheable (one entry for all users); per-user data comes from KV at the edge in single-digit ms. You get the speed of a static site with the personalization of a dynamic one.

HTMLRewriter is a Cloudflare-specific streaming rewriter. Other platforms have similar tools (Vercel's transform, Fastly's Response.body transformers).

Rate Limiting

export default {
  async fetch(request, env, ctx) {
    const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
    const key = `rl:${ip}:${Math.floor(Date.now() / 60000)}`;     // per-minute bucket

    const count = parseInt(await env.CACHE.get(key) ?? '0') + 1;
    await env.CACHE.put(key, count.toString(), { expirationTtl: 120 });

    if (count > 60) {                                              // 60 req/min/IP
      return new Response('Rate limited', { status: 429 });
    }

    return fetch(request);
  },
};

For production rate limiting, use a real rate-limiting primitive — Cloudflare Rate Limiting Rules, Durable Objects for atomic counters, or upstream a proper rate-limiter service. The KV approach above has race conditions; OK for rough protection.

Route a percentage of users (or specific user IDs) to a canary origin:

const CANARY_PCT = 0.05;

export default {
  async fetch(request, env, ctx) {
    let isCanary = getCookie(request, 'canary') === 'true';

    if (!getCookie(request, 'canary') && Math.random() < CANARY_PCT) {
      isCanary = true;
    }

    const url = new URL(request.url);
    if (isCanary) {
      url.hostname = 'canary.example.com';
    }

    const response = await fetch(url, request);

    const newResponse = new Response(response.body, response);
    if (!getCookie(request, 'canary')) {
      newResponse.headers.append('set-cookie',
        `canary=${isCanary}; Max-Age=86400; Path=/; HttpOnly`);
    }
    return newResponse;
  },
};

Static Site + Auth

Static sites are cheap to host but typically all-public. Edge functions make them gated:

export default {
  async fetch(request, env, ctx) {
    const session = getCookie(request, 'session');

    if (!session || !(await verifySession(session, env))) {
      const loginUrl = new URL('/login', request.url);
      loginUrl.searchParams.set('redirect', request.url);
      return Response.redirect(loginUrl, 302);
    }

    return fetch(request);    // serve the static asset
  },
};

Pair with Cloudflare Pages, Vercel, or Netlify — your static deployment becomes an authenticated app.

Image Transformation

Resize/convert on the fly, cache the result:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (!url.pathname.startsWith('/img/')) return fetch(request);

    const params = url.searchParams;
    const imageOptions = {
      width: parseInt(params.get('w')) || undefined,
      height: parseInt(params.get('h')) || undefined,
      format: params.get('f') || 'auto',
      quality: parseInt(params.get('q')) || 85,
    };

    // Source URL: strip /img/ prefix
    const sourceUrl = new URL(url.pathname.replace('/img/', '/'), request.url);

    return fetch(sourceUrl, {
      cf: { image: imageOptions },                          // Cloudflare image transformation
    });
  },
};

Request /img/photo.jpg?w=400&f=webp → resized/converted/cached at the edge. Don't bake variants ahead of time; generate on demand.

API Aggregator (BFF at the Edge)

For frontends that talk to multiple internal services, aggregate at the edge:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname !== '/api/dashboard') return fetch(request);

    const userId = await authenticateAndGetUserId(request, env);
    if (!userId) return new Response('Unauthorized', { status: 401 });

    // Parallel fan-out
    const [user, orders, recommendations] = await Promise.all([
      fetch(`https://users-service.internal/users/${userId}`),
      fetch(`https://orders-service.internal/orders?user=${userId}`),
      fetch(`https://reco-service.internal/recommend?user=${userId}`),
    ]);

    return Response.json({
      user: await user.json(),
      orders: await orders.json(),
      recommendations: await recommendations.json(),
    });
  },
};

The frontend makes one fast request; the edge fans out internally in parallel. Backend latency is hidden behind one network hop from the user.

Streaming Responses

For LLM responses, large lists, or server-sent events, stream:

export default {
  async fetch(request, env, ctx) {
    const { readable, writable } = new TransformStream();

    ctx.waitUntil((async () => {
      const writer = writable.getWriter();
      const encoder = new TextEncoder();
      for (const chunk of ['Hello, ', 'world', '!']) {
        await writer.write(encoder.encode(chunk));
        await new Promise(r => setTimeout(r, 100));
      }
      await writer.close();
    })());

    return new Response(readable, {
      headers: { 'content-type': 'text/plain' },
    });
  },
};

Most LLM proxies and chat backends use this pattern at the edge.

KV vs Durable Objects

Edge platforms expose two storage shapes:

KV (eventually consistent)Durable Objects (strongly consistent)
Read latencySingle-digit ms (cached locally)~30-100 ms (one location)
Write latency~50-100 msSame as read
ConsistencyEventually consistent (~60s)Strong, single-region
AtomicityNoneSingle-object transactions
PricingPer read/write/storagePer request + storage
Best forFast reads of mostly-static dataCounters, locks, coordinated state

KV for read-heavy data (config, sessions, cached responses), Durable Objects for "must be exactly right" state (counters, rate limits, multiplayer rooms).

Things Edge Patterns Don't Solve

Some problems still go to origin:

  • Heavy aggregation across many database rows.
  • Long-running computation (image generation that takes 30s).
  • Tight database transactions.
  • Anything stateful that doesn't fit KV / DO patterns.

The right mental model: edge handles the request shape; origin handles the data.

What's Next

You've seen the patterns that show up in real edge deployments. Best Practices covers limits, cold starts, observability, secrets, and how this all fits into a real system.

On this page