URL Rewriting at CDN Edge: Path Routing, Redirects, and Vanity URLs in Edge Functions

A few years ago a retail client called me at 2am because their Black Friday vanity URL - /blackfriday - was hammering their origin with uncached 302 redirects. Their Nginx config had a typo, the cache was bypassed, and they’d just bought a Super Bowl ad pointing to it. We moved that single redirect to the edge in about ten minutes and origin traffic dropped 94% overnight. That’s when I stopped treating edge URL rewriting as a “nice to have” and started treating it as core infrastructure.

Why Your Origin Shouldn't Be Handling Rewrites Anymore

For years we handled URL rewrites where it felt safest: at the origin. Apache .htaccess files, Nginx rewrite blocks, or a middleware layer in Node or Rails. It worked, but it was wasteful. Every single rewrite forced a full trip through the CDN to origin, even when the response was just a 301 or a silent path remap. You’re paying for round-trip latency, origin compute, and often busting cache for no reason. I’ve audited setups where 30% of origin requests were just rewriting /blog/post-name to /articles/post-name before serving the exact same HTML.

Moving that logic to an edge function flips the model. The decision happens within 10-20ms of the user, before you ever consider contacting origin. You can normalize paths, strip trailing slashes, enforce lowercase, or map legacy CMS URLs without touching your application servers. The catch is you’ve got to keep that function lean. I’ve seen teams load a 5MB JSON map of redirects into memory and then wonder why performance tanked. If you’re not careful about initialization, you’ll trade origin latency for edge latency. That’s why I always point folks to Edge Function Cold Start Optimization: Reducing Initialization Latency to Milliseconds before they ship their first routing function - a cold start of 80ms on every vanity URL defeats the purpose.

// Cloudflare Workers / generic Edge Function example
// Silent rewrite: user sees /shop/shoes, origin sees /products/category/shoes
export default {
  async fetch(request) {
    const url = new URL(request.url);
    
    // Normalize: lowercase, remove trailing slash except for root
    let pathname = url.pathname.toLowerCase();
    if (pathname.length > 1 && pathname.endsWith('/')) {
      pathname = pathname.slice(0, -1);
    }

    // Legacy path mapping - no redirect, just rewrite for origin fetch
    const rewriteMap = {
      '/shop/shoes': '/products/category/footwear',
      '/shop/jackets': '/products/category/outerwear'
    };

    if (rewriteMap[pathname]) {
      // Clone request with new path, keep original URL for cache key if needed
      url.pathname = rewriteMap[pathname];
      const newRequest = new Request(url.toString(), request);
      // Add header so origin knows it was rewritten
      newRequest.headers.set('x-edge-rewritten', '1');
      return fetch(newRequest);
    }

    return fetch(request);
  }
}

Building Path Routing That Plays Nice With Your Cache

Here’s the mistake I see most often: someone writes a perfect routing function and then destroys their cache hit ratio. If you rewrite /api/v2/users/123 to /api/users?id=123 but you don’t normalize your cache key, you’ve just created two cache entries for one resource. Or worse, you forget that query string ordering matters. I worked with a media site that routed /video/:id to /watch?id=:id at the edge, but they left UTM parameters in the cache key. Their hit ratio fell from 96% to 71% in a day because every marketing link was a cache miss.

You’ve got to decide early: does the edge rewrite change what the user sees, or just what the origin sees? For internal path routing (like A/B testing or micro-frontend composition), you usually want a silent rewrite - the URL in the browser doesn’t change, and the cache key should be based on the *origin* path, not the user-facing path. For that, you’ll want to be explicit about your cache key construction and keep your function’s memory footprint tight. Large routing tables stored as plain objects can blow past the limits on some platforms very fast, which is something we cover in Edge Function Memory Management: Working Within Tight Limits at CDN Scale. I typically store routes in a compressed trie or pull them from Edge KV/Dictionary at request time instead of bundling them.

And don’t forget subrequests count. If your router fans out to check an auth service or a localization API before deciding where to route, you can hit platform limits quickly. The HTTP Working Group Specifications are clear on how intermediaries should handle these rewrites, but the platforms themselves enforce hard caps on CPU time and fetches. I’ve hit that wall more than once, and the workarounds aren’t always obvious until you read Edge Function Execution Limits: CPU Time, Subrequests, and Creative Workarounds.

// Path-based routing with cache-aware key handling
// Routes /store/* to origin, /app/* to another origin - no redirect
async function routeRequest(request) {
  const url = new URL(request.url);
  const path = url.pathname;

  // Use URL Pattern API where available - much faster than regex for many routes
  // See https://web.dev/articles/ for background on performant routing
  if (path.startsWith('/store/')) {
    // Rewrite to e-commerce origin, preserve cache key based on rewritten path
    url.hostname = 'ecommerce-origin.example.com';
    // Strip /store prefix before forwarding
    url.pathname = path.replace(/^\/store/, '') || '/';
    return fetch(new Request(url, request), {
      cf: { cacheKey: url.toString() } // Cloudflare-specific, other CDNs have similar
    });
  }

  if (path.startsWith('/app/')) {
    url.hostname = 'app-origin.example.com';
    url.pathname = path.replace(/^\/app/, '/api') || '/api';
    return fetch(new Request(url, request));
  }

  // Fallback to main origin
  return fetch(request);
}

Vanity URLs, Redirects, and What They Actually Cost You

Vanity URLs feel simple - /go/sale or /careers - but they’re where teams make the most expensive mistakes. Marketing wants a memorable /spring-sale link for a campaign, product wants it to 302 for flexibility, SEO wants it to be a 301 for link equity. If you implement that as an origin redirect, you’ve just added 150-300ms of latency and an origin hit for every single click on that billboard or podcast ad. At the edge, that same redirect can be served in under 5ms from memory with no origin involvement at all.

The choice between 301, 302, 307, and 308 matters more than most developers think. A 301 is permanent and browsers and CDNs will cache it aggressively - great for /old-blog to /new-blog, terrible if marketing plans to reuse /spring-sale next year. I always recommend 308 for permanent moves where you need to preserve method and body, and 307 for temporary. And please, set explicit Cache-Control on your redirects. I’ve debugged too many “stuck” redirects where a 302 was cached for a year because no one set max-age. According to guidance in web.dev Articles and the HTTP specs, redirects without cache headers are a gamble.

We measured this across three production properties after moving ~1,200 vanity and legacy redirects from origin Nginx to edge functions. The numbers weren’t subtle. Even small redirect tables have a real impact on origin health and Time to First Byte when you remove that round trip.

Strategy p95 Added Latency Cache Hit Rate (for redirect) Origin Requests / 1M Redirects
Origin Nginx 301 145 ms 62% 380,000
Edge Function (KV Lookup, 301) 12 ms 99.8% 0
Edge Function (Inlined Map <500 routes) 4 ms 99.9% 0
Edge Function (307 + short cache) 9 ms 94% ~8,000

Implementing Rewrites Without Tanking Performance

Alright, theory's great, but let's build something you can actually ship. The biggest mistake I see teams make is reaching for a massive regex router inside their edge function and running it on every single request. You don't need a 200-line routing table executing at the edge. What you need is a fast lookup, a sane fallback, and careful handling of the cache key.

Here's the pattern that's held up for us across millions of requests per day: use a static Map or KV-backed lookup for vanity URLs and explicit redirects, then fall back to prefix-based path routing. Keep your vanity list under 500 entries if you're bundling it inline — beyond that, move it to Edge KV or Workers KV where you get sub-millisecond reads (typically 0.5-2ms) without bloating your 1MB bundle limit. And never, ever use a 302 where you mean 301. We've seen a single temporary redirect on a high-traffic vanity URL cost an extra 45ms per request and completely bust the browser cache for repeat visitors.

You'll also want to normalize before you route. Lowercase the pathname, strip the trailing slash (or enforce it — just pick one), and decide early whether you're preserving query strings. If you don't, you'll end up with four cache variants for the same page. Before you push this live, get your testing workflow solid — check out Edge Function Testing Strategies: Unit, Integration, and End-to-End at the Edge for how we mock the edge runtime locally so you don't learn about a regex collision in production.

// Cloudflare Workers / Vercel Edge / Fastly Compute@Edge (JS) pattern
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    let pathname = url.pathname.toLowerCase().replace(/\/+$/, '') || '/';
    
    // 1. Fast-path: Vanity URLs & permanent redirects (KV or inline Map)
    // Keep this Map < 500 entries, else use env.VANITY_KV.get(pathname)
    const VANITY_MAP = new Map([
      ['/go/demo', '/product/demo?source=vanity'],
      ['/blackfriday', '/sale/2025/black-friday'],
      ['/old-docs', '/docs/v2/getting-started'],
    ]);

    if (VANITY_MAP.has(pathname)) {
      // Preserve query string if present
      const destination = new URL(VANITY_MAP.get(pathname), url.origin);
      if (url.search) destination.search = url.search;
      return Response.redirect(destination.toString(), 301);
    }

    // 2. Path-based routing (rewrite, not redirect - no round-trip)
    if (pathname.startsWith('/api/')) {
      // Rewrite internally, preserve cache separation
      url.pathname = pathname.replace('/api/', '/origin-api/v1/');
      // Don't cache API rewrites at edge
      return fetch(new Request(url.toString(), request), { cf: { cacheEverything: false } });
    }

    if (pathname.startsWith('/blog/')) {
      // Rewrite to headless CMS origin, cache aggressively
      url.pathname = pathname.replace('/blog/', '/cms/content/');
      let res = await fetch(new Request(url.toString(), request), { cf: { cacheTtl: 3600 } });
      // Fix cache key: add custom header so CDN doesn't poison /cms/ cache
      res = new Response(res.body, res);
      res.headers.set('x-rewritten-from', pathname);
      return res;
    }

    // 3. Fallback to origin
    return fetch(request);
  }
}

When Rewrites Go Wrong: Loops, Collisions, and Cache Poisoning

If your edge function rewrites /old-blog/* to /blog/* and you also have a rule that redirects /blog/* back to /old-blog/* for SEO, congratulations — you've built an infinite loop that'll return a 310 ERR_TOO_MANY_REDIRECTS to the user after 20 hops and burn through your 50ms CPU limit in about 3ms. I've seen this take down entire marketing sites during a launch. The fix is simple but non-negotiable: enforce ordering and add a loop guard. Check for an x-edge-rewrite header or a custom cf-loop count, and bail out if you've already rewritten once. One rewrite per request — that's the rule.

The sneakier problem is cache poisoning. Let's say you rewrite /product/widget to /origin/product?id=widget but you don't normalize the cache key. A request to /PRODUCT/widget?__test=1 and /product/widget/ will create three separate cached objects for the same resource, dropping your edge hit ratio from 94% to the low 70s. Worse, if your edge function forwards the original Host header incorrectly, you can poison the cache for every user. Always set your cache key explicitly after a rewrite, strip internal query params, and add a Vary header only if you actually need it. Keep your edge response times under 5ms — if your function is doing more than a Map lookup and a string replace, you're doing too much at the edge.

When it inevitably breaks at 2am, you need visibility. console.log alone won't save you — you'll want tail workers, request logging, and the ability to replay a production request against your staging function. Our go-to setup is detailed in Edge Function Debugging Techniques: Console, Tail Workers, and Remote Inspection, and it’s saved us hours of guessing. And please, don't deploy rewrite changes straight to 100% of traffic. Use a canary. We roll rewrites to 5% of traffic, watch cache hit ratio and 5xx rates for 15 minutes, then ramp to 25%, 50%, 100%. The patterns for that are covered in Edge Function Versioning and Deployment Patterns: Blue-Green, Canary, and Gradual — if you're not versioning your edge functions, you're just gambling.

Frequently Asked Questions

Should I use a 301 or 302 for vanity URLs?

Use 301 (Permanent) for vanity URLs you intend to keep long-term like /go/demo or /careers. Browsers and CDNs will cache a 301 for hours to days, saving you a round-trip on every repeat visit — that's a 40-80ms win on mobile. Use 302 or 307 (Temporary) only for short-lived campaigns like /blackfriday or A/B test redirects where you *want* the client to check back each time. Once you ship a 301, assume it's cached for months — you can't easily un-redirect users.

Why is my edge rewrite killing my cache hit ratio?

You're probably creating cache key variants. Three common culprits: 1) Not normalizing trailing slashes and case, so /Blog/ and /blog are different keys. 2) Forwarding unique query strings like utm_source to origin. Strip marketing params before your cache lookup. 3) Rewriting without setting an explicit cache key, so the CDN caches the *origin* path separately. Normalize first, rewrite second, then set your cache key explicitly. Aim to keep your hit ratio above 90% — if it drops below 80% after a rewrite deploy, roll back and check your keys.

How do I handle trailing slashes consistently at the edge?

Pick one canonical form and enforce it with a single rule at the top of your function. For most SEO setups, we enforce no trailing slash for everything except the homepage: pathname.replace(/\/+$/, '') || '/'. Then issue a 301 if the incoming request doesn't match. Do this *before* your vanity or routing logic. Don't allow both versions to 200 OK — Google will see duplicate content and split your link equity. And make sure your origin also respects the canonical form, or you'll get redirect chains.

Can I do path rewrites and redirects in the same edge function?

Absolutely, and you should — one function, one execution. But order matters: handle explicit redirects (301/302) first, then internal rewrites (fetch to new origin path), then fallback. Never rewrite and then redirect in the same request — that's how you get loops. Keep the function under 10ms of CPU time; if you're adding auth, geo-personalization, and rewrites together, split them into separate functions or you'll hit the 50ms wall on Cloudflare or 20ms on Fastly.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker