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.
Cookie-Based Canary
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 latency | Single-digit ms (cached locally) | ~30-100 ms (one location) |
| Write latency | ~50-100 ms | Same as read |
| Consistency | Eventually consistent (~60s) | Strong, single-region |
| Atomicity | None | Single-object transactions |
| Pricing | Per read/write/storage | Per request + storage |
| Best for | Fast reads of mostly-static data | Counters, 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.