Session Affinity and Sticky Routing at the Edge

After working through this guide you will be able to turn on cookie-based session affinity in an edge load balancer, pick cookie attributes that survive real browsers without leaking, choose whether a session is pinned to a pool or to a single origin, take a node out of rotation without severing the sessions already bound to it, and define what happens when a pinned origin stops answering. You will also be able to argue the other side — that affinity is a workaround for state living in the wrong place — and know when hash-based pinning is the better trade.

Sticky routing is the one steering behaviour that deliberately ignores your steering policy. A load balancer normally re-evaluates every request against health, latency, and weight; affinity overrides that and says “this visitor already has a home, send them back to it.” That is enormously useful when a shopping cart lives in an origin’s local memory, and it quietly undermines almost everything else you configured — weight changes stop taking effect for existing visitors, a drained node keeps serving, and a cacheable response picks up a per-visitor cookie. This guide sits inside Load Balancing at the Edge and treats affinity as a tool with a bill attached, not a checkbox.

Key implementation objectives:

  • Enable cookie affinity on the load balancer and set a TTL that matches the real session lifetime rather than the default.
  • Choose Secure, HttpOnly, and SameSite values that keep the cookie working across third-party embeds and payment redirects.
  • Decide whether the pin is to a pool or to an individual origin, and understand which failures each survives.
  • Drain and redeploy nodes with the affinity contract intact, and define a deterministic fallback when the pinned origin is gone.
Affinity cookie lifetime A timeline showing four stages: the first unpinned request chooses an origin, the load balancer issues an affinity cookie, later requests skip steering entirely, and a failed origin causes the pin to be reissued. One session, four stages of the affinity contract t0 t0 + 30ms t0 + minutes origin lost Unpinned request no cookie present steering picks eu-west Pin issued Set-Cookie: __cflb opaque origin token Pin honoured same origin every hit weights not consulted Pin broken origin marked critical re-steer, reissue cookie Between stages two and three the load balancer makes no decision at all — the cookie is the decision. Everything you change during that window reaches new sessions only.

Before you turn it on: affinity is a symptom

Sticky routing exists because a request needs data that only one machine has. That is almost always server-side session state — an in-process cart, an uploaded file staged on local disk, a WebSocket subscription registry, a half-finished multi-step form. None of those need to be local. Move the session into a shared store (Redis, a database table, a signed token in the client) and every origin can serve every request, at which point affinity becomes unnecessary and your load balancer gets its full range of motion back.

That refactor is not always available this quarter, so affinity remains a legitimate stopgap and sometimes a permanent optimisation — a warm local cache genuinely does make repeat requests cheaper. What matters is deciding on purpose. The three options behave differently under exactly the conditions you care about:

Approach Where the state lives Survives an origin restart Survives client IP change Cost
Cookie affinity Origin memory, cookie holds the pointer No Yes Uneven load, cookie on every response
Hash affinity (IP or header) Origin memory, hash recomputes the pointer No No No cookie, but rehashes when the pool changes
Shared session store External store, any origin can read it Yes Yes One network hop per request
Signed client token The client carries it Yes Yes Token size, key rotation

Read that table as a ladder rather than a menu. Hash affinity is the cheapest thing that works and needs no cookie at all, which is why it is the right first stop for a stateless-ish workload that only wants cache locality. Cookie affinity buys correctness under mobile IP churn, at the price of a per-visitor cookie on responses you would rather cache. The bottom two rows are the actual fix.

Prerequisites and environment setup

You need a load balancer already fronting at least two origins with working health monitoring — affinity only pins to origins the balancer considers healthy, so an unfinished health configuration produces affinity behaviour that looks random. If you have not built that yet, start with Configuring Edge Health Checks and Automatic Failover.

terraform -version                 # 1.6 or newer
export CLOUDFLARE_API_TOKEN="<token with Load Balancing: Edit>"
export ZONE_ID="<zone id>"
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/load_balancers" \
  | jq '.result[] | {name, session_affinity, session_affinity_ttl}'

Expected output on a balancer that has never had affinity configured:

{ "name": "app.example.com", "session_affinity": "none", "session_affinity_ttl": null }

Each origin must also identify itself in the response. Add a static x-served-by header naming the individual node — not the region — because pool-level headers cannot tell you whether a pin held or merely landed on the same pool twice.

Cloudflare implements affinity on the load balancer object, not the pool. Setting session_affinity = "cookie" makes the edge issue a __cflb cookie on the first response and route every subsequent request carrying that cookie to the same origin, bypassing the steering policy entirely.

resource "cloudflare_load_balancer" "app" {
  zone_id          = var.zone_id
  name             = "app.example.com"
  default_pool_ids = [
    cloudflare_load_balancer_pool.eu_west.id,
    cloudflare_load_balancer_pool.us_east.id,
  ]
  fallback_pool_id = cloudflare_load_balancer_pool.us_east.id
  steering_policy  = "dynamic_latency"
  proxied          = true

  session_affinity     = "cookie"
  session_affinity_ttl = 1800   # seconds; refreshed on every request

  session_affinity_attributes {
    samesite               = "Lax"
    secure                 = "Always"
    drain_duration         = 120
    zero_downtime_failover = "sticky"
  }
}

Apply it and the change is live at every point of presence within seconds — no DNS wait, because the balancer is proxied. The side effect to expect: responses that previously carried no Set-Cookie now do, on every route the balancer fronts, including static assets. That is the hit-ratio cost discussed further down, and it is the reason to scope the balancer’s hostname to dynamic traffic where you can.

session_affinity_ttl is a sliding window, not an absolute deadline. Each request that presents a valid cookie resets the clock, so a 1800-second TTL means “thirty minutes of inactivity ends the pin,” which is usually what an application’s own session timeout means too. Match the two. A TTL far longer than the application session pins visitors to a node long after their session is meaningless; a TTL far shorter drops them mid-checkout.

The affinity cookie is set by the edge, not by your application, so you configure its attributes declaratively rather than in code. Each one has a specific failure it prevents and a specific thing it breaks if you get it wrong.

Attribute Recommended What it prevents What it breaks if misused
Secure Always Pin travelling over cleartext HTTP Nothing on an HTTPS-only site
HttpOnly On (default) Page scripts and injected code reading the pin Client code that wants to display the node
SameSite Lax Cross-site requests dragging the pin along Strict drops the pin on inbound links
Max-Age / TTL = app session Stale pins to retired nodes Too short cuts sessions mid-flow
Path / Multiple competing pins per host Narrower paths create per-section pins

SameSite is where production surprises live. Strict withholds the cookie on any navigation that originates off-site, so a visitor arriving from a search result or an email link is unpinned on their first request and pinned again — possibly to a different origin — losing whatever state the first node held. Lax sends the cookie on top-level navigations, which covers those cases. None is required only when your pages are framed by another origin, and it mandates Secure; use it exactly when an embed genuinely needs it, because it also re-enables the cross-site request patterns Lax was introduced to stop.

Keep HttpOnly on. The affinity cookie is an opaque backend pointer, and the only thing client-side access buys you is a debugging convenience you can get from a response header instead. If you want the served node visible to the browser, emit x-served-by and read that.

Step 3 — Decide the affinity scope

A pin can name a pool or an origin, and the distinction decides what a restart costs you.

Pool-scoped affinity keeps a visitor in the same region while letting any healthy origin inside that pool answer. It is the right scope when the state is regional — a session store replicated per region, a cache warmed per data center, or a data-residency rule that says European users must be served from Europe. Restarting a single node is invisible; the pool absorbs the traffic.

Origin-scoped affinity names one backend. This is what you need when the state is genuinely in one process, and it is also what makes deploys painful, because the state dies with the process. Cloudflare’s cookie affinity is origin-scoped by default; pool-scoped behaviour comes from combining a coarse pin with origin_steering inside the pool.

Pool-scoped versus origin-scoped pinning Side by side panels. On the left a pool-scoped pin allows either origin inside the pool to answer; on the right an origin-scoped pin binds the session to one backend only. Pool-scoped pin Cookie names the pool eu-west, any member may serve origin A origin B a node restart is invisible state must be shared inside the pool Origin-scoped pin Cookie names one origin exact backend, every request origin A origin B a node restart ends the session state may stay in one process

Inside a pool, the origin_steering policy decides how an unpinned request picks a member, and hash gives you stateless stickiness for free — the same client consistently lands on the same origin without any cookie being issued:

resource "cloudflare_load_balancer_pool" "eu_west" {
  account_id = var.account_id
  name       = "eu-west"
  monitor    = cloudflare_load_balancer_monitor.https.id

  origin_steering {
    policy = "hash"   # deterministic per client; "random" and
  }                   # "least_outstanding_requests" are the alternatives

  origins {
    name    = "eu-west-1"
    address = "10.0.1.10"
    enabled = true
    weight  = 1
  }
  origins {
    name    = "eu-west-2"
    address = "10.0.1.11"
    enabled = true
    weight  = 1
  }
}

Hash affinity’s weakness is rehashing. Add or remove an origin and the hash space is repartitioned, so a share of existing clients move to a different node at once — acceptable for cache warmth, fatal for in-memory carts. It also collapses under carrier-grade NAT, where thousands of mobile users share one address and therefore one hash bucket.

Step 4 — Implement the same contract in a Worker

When the declarative object is not expressive enough — you want the pin derived from an authenticated user id, or you need to pin to a shard rather than an origin — a Worker gives you the whole mechanism explicitly. This is the same request-interception point used for routing decisions in Cloudflare Workers, applied to backend selection instead of path selection.

const ORIGINS = {
  'eu-west-1': 'https://eu-west-1.origin.example.com',
  'eu-west-2': 'https://eu-west-2.origin.example.com',
  'us-east-1': 'https://us-east-1.origin.example.com',
};
const AFFINITY_COOKIE = 'app_aff';
const AFFINITY_TTL = 1800;

function readPin(request) {
  const jar = request.headers.get('cookie') || '';
  const m = jar.match(new RegExp(`(?:^|;\\s*)${AFFINITY_COOKIE}=([\\w-]+)`));
  return m && ORIGINS[m[1]] ? m[1] : null;
}

async function healthy(env, name) {
  // A KV key per origin, written by the health pipeline; absent = assume healthy.
  return (await env.ORIGIN_HEALTH.get(name)) !== 'critical';
}

export default {
  async fetch(request, env, ctx) {
    const candidates = Object.keys(ORIGINS);
    let pin = readPin(request);
    let reissue = false;

    // Honour an existing pin only while its origin is still serving.
    if (pin && !(await healthy(env, pin))) {
      pin = null;
      reissue = true;
    }

    if (!pin) {
      const live = [];
      for (const name of candidates) if (await healthy(env, name)) live.push(name);
      if (live.length === 0) return new Response('no healthy origin', { status: 503 });
      pin = live[Math.floor(Math.random() * live.length)];
      reissue = true;
    }

    const url = new URL(request.url);
    url.hostname = new URL(ORIGINS[pin]).hostname;
    const upstream = await fetch(new Request(url, request));

    const response = new Response(upstream.body, upstream);
    response.headers.set('x-served-by', pin);
    if (reissue) {
      response.headers.append(
        'Set-Cookie',
        `${AFFINITY_COOKIE}=${pin}; Path=/; Max-Age=${AFFINITY_TTL}; ` +
          'HttpOnly; Secure; SameSite=Lax'
      );
    }
    return response;
  },
};

Two details carry most of the value. The pin is validated against the origin table before use, so a forged or stale cookie cannot steer traffic to an address you do not control. And the cookie is reissued only when the pin actually changes, which keeps Set-Cookie off the overwhelming majority of responses — the difference between a header on every hit and a header on the first hit of each session. If the pin should be tied to an authenticated identity instead of an anonymous cookie, derive it from a verified claim as described in JWT validation at the edge, which makes the pin unforgeable without a separate cookie at all.

Step 5 — Drain a node without severing its sessions

A drain is a promise: stop sending new sessions here, keep serving the ones already bound. Deleting or disabling the origin breaks that promise immediately, so drain in stages.

Drain states for a pinned origin An origin moves from active to draining to disabled, and returns to active at a low weight once the affinity window has elapsed. Drain the pins, then the node Active weight above zero new pins issued Draining weight set to zero old pins still served Disabled removed from pool stale pins fail over weight 0 TTL elapsed Re-enter at a low weight so the node refills gradually instead of absorbing a burst

The sequence in practice:

# 1. Stop new sessions landing here. Existing pins keep resolving.
terraform apply -var 'eu_west_2_weight=0'

# 2. Watch the pinned population decay. It is bounded by the affinity TTL.
watch -n 30 'curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/load_balancers/$LB_ID" \
  | jq -r ".result.session_affinity_ttl"'

# 3. After one full TTL with no traffic, take the origin out entirely.
terraform apply -var 'eu_west_2_enabled=false'

Weight zero is the correct drain lever precisely because affinity ignores weights: a pinned visitor keeps arriving, an unpinned one never does, and the population shrinks on its own. The wait between steps two and three is one affinity TTL — thirty minutes with the settings above — which is the real reason to keep the TTL close to the application session length rather than at the platform default. The same lever is described from the traffic-shaping side in weighted load balancing across multi-region origins; here it is being used for its side effect on pins rather than for its split.

When you cannot wait — an unhealthy node, or a security patch that has to land now — the fallback behaviour is what protects users. Cloudflare’s zero_downtime_failover attribute governs it: sticky re-pins the visitor to the new origin and reissues the cookie, temporary diverts the single request without changing the pin, and none lets the request fail. Prefer sticky when the session can be rebuilt from a shared store, and temporary when the node is expected back within seconds and its local state is worth returning to.

What affinity costs your cache

Every response carrying Set-Cookie is, by default, uncacheable in a shared cache — a cache that stored it would hand one visitor’s pin to the next. Turn on balancer-level affinity indiscriminately and your static assets start arriving with a cookie attached, and the hit ratio for those paths goes to zero. Three mitigations, in order of preference: serve static assets from a hostname the load balancer does not front; scope the balancer to the dynamic path prefixes only; or strip the affinity cookie from responses on known-cacheable paths in a Worker before they reach the cache. Whichever you pick, verify it — a silent hit-ratio collapse after an affinity rollout is a common and expensive regression.

WebSockets and long polling

A WebSocket is pinned by physics, not by cookies: the upgrade request carries the cookie, the balancer picks an origin, and the resulting TCP connection stays on that origin until it closes. Affinity matters for the upgrade and is irrelevant afterwards — which also means a drain does not move an open socket. Plan for connection churn explicitly: send a close frame with a reason code so clients reconnect and get re-steered, rather than waiting for the socket to die with the process.

Long polling is the opposite case and needs affinity more than ordinary traffic does. Each poll is a fresh HTTP request, so an unpinned client that rotates between origins misses whatever the previous node had queued for it. If your polling endpoint holds per-connection state, either pin it or move the queue into a shared store — the middle ground of “usually the same origin” produces intermittent message loss that is miserable to debug.

Verification

Affinity is verified by comparing a cookie-preserving loop against a cookie-free one. Start by capturing the pin:

JAR=$(mktemp)
curl -s -c "$JAR" -o /dev/null -D - https://app.example.com/ \
  | grep -iE 'set-cookie|x-served-by'

Expected output on the first request of a session:

set-cookie: __cflb=02DiuF...; SameSite=Lax; path=/; HttpOnly; Secure; Max-Age=1800
x-served-by: eu-west-2

Now replay with the jar. Every iteration must report the same node:

for i in $(seq 1 20); do
  curl -s -b "$JAR" -c "$JAR" -o /dev/null -D - https://app.example.com/ \
    | awk 'tolower($1)=="x-served-by:"{print $2}' | tr -d '\r'
done | sort | uniq -c
     20 eu-west-2

Run the control immediately afterwards, with no jar and no keepalive, and the same twenty requests should spread across the pool:

for i in $(seq 1 20); do
  curl -s --no-keepalive -o /dev/null -D - https://app.example.com/ \
    | awk 'tolower($1)=="x-served-by:"{print $2}' | tr -d '\r'
done | sort | uniq -c
     11 eu-west-1
      9 eu-west-2

A single node in both loops means affinity is not the thing pinning you — keepalive connection reuse or a stale DNS answer is. A spread in both loops means the cookie is not reaching the balancer at all; jump to the stripped-cookie scenario below. Confirm the expiry behaviour separately by sleeping past the TTL with the jar intact and checking that a fresh Set-Cookie appears.

Troubleshooting

Affinity appears to be ignored

The request reaches a different node each time despite a cookie in the jar. Check three things in order. First, whether the cookie is actually being sent — curl -v prints the outbound Cookie: line, and its absence usually means Secure is set while you are testing over plain HTTP, or the cookie’s Path does not cover the URL. Second, whether the pinned origin is healthy, since the balancer silently re-steers away from an origin marked critical and reissues the pin. Third, whether the route is even going through the load balancer: a DNS record that resolves past the balancer to an origin directly will look like broken affinity and is actually broken routing.

curl -v -b "$JAR" https://app.example.com/ 2>&1 | grep -iE '^> cookie|^< set-cookie'

Every visitor lands on one origin

The classic cause is hash affinity behind a shared egress: an office NAT, a mobile carrier, or a test harness running from one host makes thousands of clients look like one client, and the hash sends them all to the same bucket. It also shows up when only one origin is passing health checks — the balancer has no choice, and the affinity setting is innocent. Confirm the distribution from several real client addresses before blaming the configuration, and if the pool is genuinely lopsided, switch origin_steering from hash to least_outstanding_requests and let cookie affinity carry the stickiness instead.

Corporate proxies, some privacy extensions, and any middlebox that rewrites responses can drop Set-Cookie or filter unknown cookie names. The signature is a fresh Set-Cookie on every response with the client never echoing it back. Diagnose by comparing what the balancer sent with what the next request carried:

curl -s -D - -o /dev/null https://app.example.com/ | grep -i '^set-cookie'
curl -s -v -b "$JAR" https://app.example.com/ 2>&1 | grep -i '^> cookie'
# a set-cookie with no matching outbound cookie = stripped in transit

There is no way to force a cookie through a client that refuses it, so the fix is a fallback rather than a repair: enable hash-based origin steering underneath the cookie so a cookie-less client still lands consistently, and make sure the application degrades to a shared session store rather than losing the session outright.

Sessions disappear during a deploy

Rolling a node restarts the process that held the in-memory session, and the pin survives while the state does not — the visitor is routed to a node that no longer remembers them. This is the clearest signal that affinity is standing in for state that belongs elsewhere. The short-term mitigation is the drain sequence in Step 5: weight to zero, wait one affinity TTL, then restart, so the node has no pinned population left when it goes down. The durable fix is to externalise the session, at which point deploys stop being coupled to user sessions entirely and you can drop affinity to none and let the steering policy do its job again.

Frequently Asked Questions

Do I actually need session affinity? Only if a request depends on data held by one specific origin — an in-process cart, a locally staged upload, a per-connection queue. If your sessions already live in a shared store or a signed token, affinity buys you nothing and costs you cache efficiency, even distribution, and clean deploys.

What TTL should the affinity cookie have? Match it to your application’s own session timeout, and remember the window slides on every request rather than expiring at a fixed time. A much longer TTL keeps visitors bound to nodes long after their session is meaningless; a much shorter one drops them partway through a multi-step flow.

Why is my cache hit ratio worse after enabling affinity? Because the balancer now attaches Set-Cookie to responses, and a shared cache must not store a response carrying a per-visitor cookie. Keep static assets off the balanced hostname, scope affinity to dynamic paths, or strip the cookie on cacheable routes before the response reaches the cache.

Is hash-based affinity a good substitute for cookies? It is cheaper and needs no cookie, so it is a good fit for cache locality. It fails where cookies succeed: clients behind carrier-grade NAT collapse onto one bucket, a roaming client changes address and rehashes, and adding or removing an origin repartitions the hash space and moves a share of existing clients at once.

How should affinity behave when the pinned origin is unhealthy? It should re-steer and reissue the pin rather than fail. Choose the sticky variant when the session can be rebuilt from shared state, and a temporary single-request diversion when the node is expected back shortly and its warm local state is worth returning to. Never let a dead pin turn into an error page.

Back to Load Balancing at the Edge