Edge Function Versioning and Deployment Patterns: Blue-Green, Canary, and Gradual

Last winter we pushed a seemingly harmless header-normalization change to an edge function handling 40k requests per second. No tests failed, staging looked clean, but within eight seconds of global deployment, 12% of our checkout traffic was returning 500s because of a subtle mismatch in how one PoP parsed an upstream cookie. We rolled back, but the damage was done — and it taught me that at the edge, you don't get a second chance to deploy slowly. If you're running edge deployment at any real scale, versioning isn't optional.

Why Immutable Function Versions Are Non-Negotiable at the Edge

On a traditional origin server, you can get away with mutable deploys. You overwrite the code, restart the process, and if it breaks you revert the container. At the edge that mental model falls apart completely. Your function isn't running in one place — it's replicated to 300+ points of presence, each with its own cache, its own isolates, and its own cold start behavior. When I first moved from origin-based workers to CDN edge serverless around 2016, I assumed a simple deploy --overwrite would be fine. It wasn't. We'd push a bad build and spend 20 minutes purging it from edge caches while users hit a mix of old and new logic.

That's why proper function versioning treats every deployment as immutable. You don't update v42, you publish v43 and then shift traffic to it. Each version gets a content hash, its own bundle, its own environment bindings, and its own edge manifest. The platform keeps the last N versions warm so a rollback is a routing change, not a re-upload. This is critical because Edge Function Cold Start Optimization: Reducing Initialization Latency to Milliseconds becomes much harder if your runtime has to re-compile a just-uploaded bundle during an emergency rollback.

Most mature platforms now do this under the hood, but you should enforce it in your pipeline. Don't let CI push to a floating production tag. Push to a versioned artifact and promote it. I've seen teams save themselves hours of debugging just by tagging versions with the git SHA and binding that SHA to logs and error reports. When an error spikes on v1.4.8-a3f9c1, you don't have to guess what code was running in Singapore vs. Ashburn — you know.

// wrangler.toml - immutable versioned deploys
[edge]
name = "auth-guard"
versioning = "immutable"  # never overwrite, always publish new

[deploy.versions]
retain = 10               # keep last 10 for instant rollback
alias.production = "v1.4.8-a3f9c1"
alias.staging = "v1.4.9-beta.2"

# Promote via routing, not re-upload
# npx wrangler versions promote v1.4.8-a3f9c1 --alias=production

Blue-Green Deployments When Your Fleet Is Every PoP

Blue-green is the simplest edge deployment pattern to reason about, and frankly my favorite when the change touches authentication or billing logic. You keep two complete environments — Blue (live) and Green (idle) — both fully replicated across the edge network. When Green is verified, you flip the router to send 100% of traffic to it in one atomic operation. At the edge, "flipping the router" usually means updating a global routing manifest or a tiered load balancer config that propagates in seconds, not minutes.

The beauty of blue-green for canary functions testing is that it gives you an all-or-nothing cutover with zero request mixing. That matters when your function handles stateful things like signed cookies or request signing. I worked with a media client who tried to canary a new JWT validation function and ended up with half their requests validated by old logic and half by new logic during the transition — users were logged out mid-session. We switched them to blue-green with synthetic traffic replay on Green before the flip, and the problem disappeared. You do need double the resource allocation for a brief window, so watch your limits — Edge Function Memory Management: Working Within Tight Limits at CDN Scale is a real constraint when you're warming two full versions at once, especially if your functions sit near the 128MB boundary.

Implementation at the edge typically relies on a version-aware reverse proxy. As described in the HTTP Working Group Specifications, you can use Cache-Control and CDN-Cache-Control headers to ensure the router doesn't cache the version decision itself. The router reads a header like x-edge-version: green or a cookie for internal testers, while external users follow the production alias. Your code doesn't need to know which color it is.

// edge router - atomic blue-green switch
export default {
  async fetch(request, env) {
    // Routing manifest is fetched from KV / config store
    // Updated atomically, propagated globally in <5s
    const manifest = await env.ROUTING.get("active-manifest", "json");
    const target = manifest.activeColor === "blue" ? env.BLUE_VERSION : env.GREEN_VERSION;

    // Internal QA can force Green via header before public flip
    if (request.headers.get("x-edge-preview") === "green") {
      return env.GREEN_VERSION.fetch(request);
    }

    // Production traffic uses atomic alias
    return target.fetch(request);
  }
}

Canary Functions and Gradual Rollouts: Trading Speed for Confidence

If blue-green is a light switch, canary and gradual rollout are dimmers. With a canary, you send a small, fixed slice — say 5% — of live traffic to the new function version and watch your signals. With a gradual rollout you automate the ramp: 5% → 25% → 50% → 100% based on error budgets and latency. At CDN scale, this is where you catch the weird stuff: a regex that’s fine in US-East but catastrophically slow on the smaller ARM isolates in smaller PoPs, or a dependency on a subrequest that trips Edge Function Execution Limits: CPU Time, Subrequests, and Creative Workarounds only under real traffic patterns.

We've run canaries for every edge function change since 2021, and our rule is simple: no promotion without 10 minutes of clean canary data. What does clean mean? We compare p95 latency, 5xx rate, and origin subrequest success against the baseline color. Guidance from web.dev Articles on Core Web Vitals is useful here — if your edge function injects headers or does HTML rewriting, a latency regression at the edge will crush your LCP even if the origin is healthy. Don't just watch for crashes; watch for 20ms latency creep. A canary that adds 30ms globally will cost you more in user experience than a 0.1% error rate.

The trick is sticky routing. You don't want the same user bouncing between v43 and v44 on subsequent requests. Use a deterministic hash — often hash(clientIP + user-agent) % 100 or a cookie — so canary assignment is consistent. Otherwise your analytics and your users will both get confused. Most platforms now support weighted version aliases natively, but if you're rolling your own, keep the decision at the outermost router and forward with a version header so inner functions can log which logic path executed.

Pattern Traffic Shift Typical Rollback Time Blast Radius if Bad Ideal For
Blue-Green 100% atomic flip 4 - 8 seconds (alias swap) 100% during flip window Auth, payments, breaking schema changes
Canary (Fixed 5%) 5% canary / 95% stable 15 - 30 seconds (weight to 0%) ~5% of users, sticky by IP Performance-sensitive rewrites, A/B logic
Gradual Rollout 5% → 25% → 50% → 100% (auto) Auto-rollback in <45s on SLO breach Grows with ramp, capped by error budget Large refactors, untested PoP coverage
Dark Launch (0%) 0% live, mirrored traffic only 0s (no live impact) None (shadow execution) Testing CPU/memory under real load

Practical Implementation: Building a Version-Pinned Edge Router

Theory's great, but at 2 a.m. when you're pushing a checkout flow change, you need a router that won't lie to you. We've settled on a dead-simple pattern: every deployment gets a content-addressable version ID — think `func_checkout@a7f3c9e2` — and it's immutable once published. No overwriting `latest`, ever. Your edge config then maps traffic to version IDs, not to "the current code." That means a PoP in Singapore and one in Frankfurt might be milliseconds apart in propagation, but they'll never execute different code for the same version tag.

Most platforms give you this for free if you use it correctly. With Cloudflare Workers, Fastly Compute, or AWS Lambda@Edge, you publish a new version and then move an alias or route. Don't mutate the handler in place. We typically keep the last 10 versions warm and routable, with a manifest stored in KV or a config store that propagates in under 8 seconds globally. For canary and gradual rollouts, the router itself is a tiny, ultra-stable edge function — under 15KB, p95 overhead of 1-3ms — that reads a traffic split config and delegates via `fetch` or `env.dispatcher`. If you haven't nailed your test harness for this layer, review your Edge Function Testing Strategies: Unit, Integration, and End-to-End at the Edge before you let real money flow through it.

The snippet below is a stripped-down version of what we run in production. It handles version pinning via header for internal testing, cookie stickiness for canary users, and weighted random splitting for everyone else. Notice we aren't doing percentage math in the data plane on every request — we fetch a 1KB JSON config edge-cached for 5 seconds, so config updates roll globally in ~7-12 seconds without redeploying the router itself.

// version-router.js - Deployed as @router-v1 (immutable)
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    
    // 1. Allow pinned version for QA: curl -H "x-edge-version: a7f3c9e2"
    const pinned = request.headers.get('x-edge-version');
    if (pinned && /^[a-f0-9]{8}$/.test(pinned)) {
      return env.VERSIONS.get(pinned).fetch(request);
    }

    // 2. Check for sticky canary cookie (ensures user doesn't flip-flop)
    const cookie = request.headers.get('cookie') || '';
    const sticky = cookie.match(/edge_canary=([a-f0-9]{8})/)?.[1];
    if (sticky) return env.VERSIONS.get(sticky).fetch(request);

    // 3. Weighted split from edge-cached config (5s TTL)
    const config = await getSplitConfig(env); // { stable: "b2e1d4f8", canary: "a7f3c9e2", canaryPercent: 5 }
    const isCanary = Math.random() * 100 < config.canaryPercent;
    const targetVersion = isCanary ? config.canary : config.stable;

    const response = await env.VERSIONS.get(targetVersion).fetch(request);
    
    // Stick canary users for 24h
    if (isCanary) {
      response.headers.append('Set-Cookie', `edge_canary=${targetVersion}; Path=/; Max-Age=86400; SameSite=Lax`);
    }
    response.headers.set('x-edge-version-served', targetVersion);
    return response;
  }
}

async function getSplitConfig(env) {
  return env.CONFIG_KV.get('router:checkout:split', { type: 'json', cacheTtl: 5 });
}

Advanced Patterns: Shadow Traffic, Instant Rollback, and What Breaks

Once you've got blue-green and canary working, the next levers are shadow deployments and bulletproof rollbacks. Shadow — or dark launch — is our favorite for high-risk rewrites. You fork 10-20% of live traffic, run it through the canary version asynchronously, but return only the stable version's response to the user. You're comparing performance and error rates without any user impact. We've caught 40ms p95 regressions and subtle JSON serialization bugs this way that never would've shown up in staging. Just watch your origin load; doubling 15% of requests can still spike your origin by 12-18% if you're not careful, so rate-limit the shadow fetch.

Gradual rollouts fail in predictable ways. The big three we see: config propagation lag causing version skew, cold-start amplification during traffic shifts, and cache poisoning. If your rollback takes more than 5 seconds to propagate globally, it's not a rollback — it's an apology tour. Keep your split config in the edge KV with strong consistency, not eventual. And version your cache keys. A canary that writes `cacheKey = /api/cart` with a new schema will poison the stable version's cache for hours. We namespace everything: `cart:v2:{userId}`. When things do go sideways, don't redeploy — just flip the alias. Paired with proper observability from Edge Function Debugging Techniques: Console, Tail Workers, and Remote Inspection, you can tail logs per version (`wrangler tail --version-id a7f3c9e2`) and see exactly which version threw that 500 in which PoP.

For A/B tests that outlive a deployment, don't conflate feature flagging with versioning. Use your version router for code safety, and a separate deterministic splitter for experiment assignment. That splitter should live *inside* the stable version, not in the router, so you don't have to touch routing to end an experiment. If you need traffic splitting that survives without redeploys and handles sticky bucketing by user ID or geo, our approach for A/B Routing with Edge Functions: Traffic Splitting Without Origin Changes works perfectly on top of this versioned foundation. Automate the progression — 1% for 10 minutes, 5% for 30 minutes, 25% for an hour, then 100% — but gate each step on error budget. If your error rate jumps from 0.2% to 0.8% at 5%, you'll be glad you didn't jump straight to 50%.

Frequently Asked Questions

How fast is an instant rollback really? Aren't edge deployments eventually consistent?

Depends on what you're rolling back. If you're moving an alias or updating a small KV config, most providers propagate globally in 5-15 seconds — Fastly and Cloudflare are typically 5-8 seconds, Lambda@Edge via CloudFront is slower at 45-120 seconds due to replication. That's why we never roll back by republishing code; we roll back by flipping the config pointer. The old version's code is still warm on every PoP, so there's no cold start penalty. Keep at least two prior versions deployed and you'll hit sub-10-second global rollback.

Should I do canary by percentage, geography, or user segment?

Start with random percentage — it's the cleanest signal. Once that's stable, graduate to geo-isolated canaries. Rolling to just `eu-west` or `ap-southeast` first limits blast radius and lets you test region-specific origins and compliance logic. Don't canary by premium users first; if that 1% is your highest-value customers, you've inverted your risk. We do 1% random → 5% random → 25% geo (low-risk region) → 100%. Use a sticky cookie or `cf-ipcity` style header to keep users pinned.

How do you avoid cache and state issues between blue and green versions?

Immutable cache keys and isolated state. Every version gets its own cache namespace and, if you're using Workers KV, Durable Objects, or EdgeKV, its own key prefix. Blue writes to `v1:session:*`, green writes to `v2:session:*`. When you promote green to blue, you don't migrate data — you let the new version lazily hydrate from origin or run a backfill job. For shared mutable state like carts, keep that API versioned at the origin and make both edge versions speak the same contract. Never let two edge versions write to the same unversioned key.

What's the overhead of a version-aware router function?

Negligible if you keep it lean. Our router adds 1-3ms at p95 and less than 128KB memory. The key is to never do heavy compute in the router — no JSON parsing of bodies, no auth checks. It's just header/cookie inspection and a single KV get that's cached at the edge for 5 seconds. That translates to ~ 0.5% added CPU time per request. We've run it at 1.2M RPS globally without hitting any platform limits. If your router is adding 10ms+, you've put too much logic in it.

Related Articles

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