Skip to content

Edge Computing Architecture Patterns for Web Applications

Explore edge computing patterns that bring computation closer to users, reduce latency, and enable new capabilities for modern web applications and APIs.

5 min read
World map showing edge nodes distributed globally with data flow patterns between them

Moving computation from a centralized data center to the network edge isn't just about performance—it's a fundamental shift in how we architect web applications. When your code runs in 300 locations worldwide instead of one, the constraints and possibilities change dramatically.

Edge computing reduces latency by eliminating round trips to distant origins. But it also introduces challenges that traditional server architectures don't face: limited compute time, eventual consistency across regions, and cold start penalties that affect real users.

The Edge Computing Spectrum

Not everything belongs at the edge. Understanding the spectrum from full-edge to edge-assisted helps you make smart placement decisions.

tstypescript
// ❌ Putting everything at the edge without thinking
// Edge functions have time limits, memory limits, and no persistent storage
 
// ✅ Strategic placement based on data and compute needs
interface WorkloadPlacement {
  location: "edge" | "regional" | "origin";
  rationale: string;
  examples: string[];
}
 
const placementStrategy: WorkloadPlacement[] = [
  {
    location: "edge",
    rationale: "Stateless, fast, personalization based on request",
    examples: [
      "A/B test routing",
      "Geolocation-based redirects",
      "Auth token validation",
      "Request header manipulation",
      "Static asset transformation",
    ],
  },
  {
    location: "regional",
    rationale: "Needs data access, moderate compute, regional cache",
    examples: [
      "API responses with regional data",
      "Session management",
      "Rate limiting with shared counters",
      "Search with regional indexes",
    ],
  },
  {
    location: "origin",
    rationale: "Heavy compute, global consistency, complex transactions",
    examples: [
      "Payment processing",
      "Database writes with ACID guarantees",
      "ML model inference (large models)",
      "Report generation",
    ],
  },
];

The goal isn't to eliminate the origin server—it's to handle as much as possible before the request ever reaches it. Think of edge as your first line of processing, not your only one.

Edge-Side Authentication and Authorization

Validating authentication at the edge eliminates one of the most common round trips. Instead of forwarding every request to the origin for auth checks, validate tokens in milliseconds at the nearest edge node.

tstypescript
// Edge function: validate JWT before reaching origin
import { jwtVerify, importSPKI } from "jose";
 
interface EdgeAuthResult {
  authenticated: boolean;
  userId?: string;
  roles?: string[];
  error?: string;
}
 
// Public key cached at edge (rotated via cron)
const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhk...
-----END PUBLIC KEY-----`;
 
async function authenticateAtEdge(
  request: Request
): Promise<EdgeAuthResult> {
  const authHeader = request.headers.get("authorization");
 
  if (!authHeader?.startsWith("Bearer ")) {
    return { authenticated: false, error: "Missing token" };
  }
 
  const token = authHeader.slice(7);
 
  try {
    const publicKey = await importSPKI(PUBLIC_KEY_PEM, "RS256");
    const { payload } = await jwtVerify(token, publicKey, {
      issuer: "https://auth.example.com",
      audience: "https://api.example.com",
    });
 
    return {
      authenticated: true,
      userId: payload.sub,
      roles: payload.roles as string[],
    };
  } catch {
    return { authenticated: false, error: "Invalid token" };
  }
}
 
// Edge handler
export default async function handler(request: Request): Promise<Response> {
  const auth = await authenticateAtEdge(request);
 
  if (!auth.authenticated) {
    return new Response(
      JSON.stringify({ error: auth.error }),
      { status: 401, headers: { "Content-Type": "application/json" } }
    );
  }
 
  // Forward authenticated request to origin with user context
  const originRequest = new Request(request);
  originRequest.headers.set("X-User-Id", auth.userId ?? "");
  originRequest.headers.set("X-User-Roles", (auth.roles ?? []).join(","));
 
  return fetch(originRequest);
}

This pattern saves 50-200ms per request by avoiding the origin round trip for auth. The edge function validates the JWT using a cached public key—no database lookup needed. Unauthorized requests never reach your origin server.

Smart Cache Invalidation at the Edge

The hardest problem in edge caching isn't caching—it's invalidation. Stale data at 300 edge nodes is worse than no cache at all. Tag-based invalidation provides surgical precision.

tstypescript
// ❌ Cache everything with time-based expiry
// Products page shows stale prices for up to 60 seconds
const headers = new Headers();
headers.set("Cache-Control", "public, max-age=60");
 
// ✅ Tag-based cache with instant invalidation
interface CacheEntry {
  response: Response;
  tags: string[];
  createdAt: number;
}
 
class EdgeCache {
  private cache: Map<string, CacheEntry> = new Map();
  private tagIndex: Map<string, Set<string>> = new Map();
 
  async get(key: string): Promise<Response | null> {
    const entry = this.cache.get(key);
    if (!entry) return null;
    return entry.response.clone();
  }
 
  async set(
    key: string,
    response: Response,
    tags: string[]
  ): Promise<void> {
    this.cache.set(key, {
      response: response.clone(),
      tags,
      createdAt: Date.now(),
    });
 
    // Build reverse index: tag → cache keys
    for (const tag of tags) {
      if (!this.tagIndex.has(tag)) {
        this.tagIndex.set(tag, new Set());
      }
      this.tagIndex.get(tag)!.add(key);
    }
  }
 
  async invalidateByTag(tag: string): Promise<number> {
    const keys = this.tagIndex.get(tag);
    if (!keys) return 0;
 
    let invalidated = 0;
    for (const key of keys) {
      this.cache.delete(key);
      invalidated++;
    }
 
    this.tagIndex.delete(tag);
    return invalidated;
  }
}
 
// Usage in edge handler
const cache = new EdgeCache();
 
async function handleProductRequest(
  request: Request
): Promise<Response> {
  const url = new URL(request.url);
  const cacheKey = url.pathname;
 
  const cached = await cache.get(cacheKey);
  if (cached) return cached;
 
  const response = await fetch(request);
 
  // Tag with product ID and category for targeted invalidation
  const productId = url.pathname.split("/").pop();
  await cache.set(cacheKey, response, [
    `product:${productId}`,
    "products-list",
    "storefront",
  ]);
 
  return response;
}
 
// When a product price changes, invalidate just that product
// await cache.invalidateByTag("product:abc123");
// This purges the product page without touching unrelated cache entries

Tag-based invalidation means you can update a product price and instantly purge only that product's cached responses across all edge locations—without touching the cache for thousands of other products.

Edge-Side Personalization

Personalization typically requires a round trip to the origin to fetch user preferences. Edge key-value stores enable personalization without the latency penalty.

tstypescript
// Edge personalization without origin round trip
interface UserPreferences {
  language: string;
  currency: string;
  theme: "light" | "dark";
  region: string;
}
 
async function personalizeAtEdge(
  request: Request,
  kvStore: KVNamespace
): Promise<Response> {
  const userId = request.headers.get("X-User-Id");
  const geo = request.headers.get("CF-IPCountry") ?? "US";
 
  let preferences: UserPreferences;
 
  if (userId) {
    // Fetch user preferences from edge KV
    const stored = await kvStore.get(`prefs:${userId}`, "json");
    preferences = (stored as UserPreferences) ?? getDefaultPreferences(geo);
  } else {
    preferences = getDefaultPreferences(geo);
  }
 
  // Fetch the page from origin
  const originResponse = await fetch(request);
  const html = await originResponse.text();
 
  // Inject personalization at the edge
  const personalized = html
    .replace("{{LANG}}", preferences.language)
    .replace("{{CURRENCY}}", preferences.currency)
    .replace("{{THEME}}", preferences.theme);
 
  return new Response(personalized, {
    headers: {
      ...Object.fromEntries(originResponse.headers),
      "Content-Type": "text/html",
      "Vary": "X-User-Id",
    },
  });
}
 
function getDefaultPreferences(countryCode: string): UserPreferences {
  const regionMap: Record<string, Partial<UserPreferences>> = {
    US: { language: "en", currency: "USD" },
    GB: { language: "en", currency: "GBP" },
    DE: { language: "de", currency: "EUR" },
    JP: { language: "ja", currency: "JPY" },
  };
 
  return {
    language: "en",
    currency: "USD",
    theme: "light",
    region: countryCode,
    ...regionMap[countryCode],
  };
}
 
interface KVNamespace {
  get(key: string, type: "json"): Promise<unknown>;
}

Edge KV stores typically have sub-millisecond read latency at the edge. The trade-off is eventual consistency—a preference change might take seconds to propagate to all edge locations. For personalization, this is usually acceptable.

Handling Edge Function Limitations

Edge functions run in constrained environments. Understanding the limits helps you design within them rather than fighting them.

tstypescript
// Common edge function constraints and workarounds
 
// ❌ Long-running computation at the edge
async function generateReport(data: unknown[]): Promise<Response> {
  // This will hit the 10-50ms CPU time limit on most edge platforms
  const report = heavyComputation(data); // Times out!
  return new Response(report);
}
 
// ✅ Delegate heavy work, respond immediately
async function smartEdgeHandler(request: Request): Promise<Response> {
  const url = new URL(request.url);
 
  // Fast decisions at the edge
  if (url.pathname === "/api/status") {
    return new Response(JSON.stringify({ status: "ok" }), {
      headers: { "Content-Type": "application/json" },
    });
  }
 
  // Geolocation routing
  const country = request.headers.get("CF-IPCountry") ?? "US";
  const regionOrigin = getRegionalOrigin(country);
 
  // Transform and forward to nearest regional server
  const modifiedRequest = new Request(regionOrigin + url.pathname, {
    method: request.method,
    headers: request.headers,
    body: request.body,
  });
 
  return fetch(modifiedRequest);
}
 
function getRegionalOrigin(country: string): string {
  const regions: Record<string, string> = {
    US: "https://us-east.api.example.com",
    CA: "https://us-east.api.example.com",
    GB: "https://eu-west.api.example.com",
    DE: "https://eu-west.api.example.com",
    JP: "https://ap-northeast.api.example.com",
    AU: "https://ap-southeast.api.example.com",
  };
  return regions[country] ?? regions.US;
}

Key Takeaways

Edge computing isn't about replacing your servers—it's about augmenting them with a globally distributed layer that handles the work that benefits most from proximity to users. Authentication, personalization, caching decisions, and geographic routing all improve dramatically when executed at the edge.

The most successful edge architectures follow a simple principle: do as much as possible with the data you already have at the edge, and only reach back to the origin when you genuinely need it. Every origin round trip you eliminate is 50-300ms saved for a real user somewhere in the world.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX