Skip to content

HAProxy sticky sessions

When and how to pin a client to a backend in HAProxy — cookie insertion, appsession-style stick rules, source IP affinity, and the failure modes we plan around.

Sticky sessions pin a client to a specific backend server for the duration of a logical session. Sometimes this is necessary (stateful uploads, WebSocket reconnects to the same node, legacy apps without a shared session store); often it is a workaround for a missing shared state layer. This article covers the three sticky mechanisms HAProxy supports and the production caveats for each.

How to verify

For an existing config, the runtime API exposes the active stick tables and the cookies HAProxy is inserting.

echo "show table be_app" | sudo socat /run/haproxy/admin.sock -
echo "show servers state" | sudo socat /run/haproxy/admin.sock -
curl -v http://localhost/ 2>&1 | grep -i 'set-cookie'
curl -v -b "SRVID=app1" http://localhost/ 2>&1 | grep -i 'x-served-by\|cookie'

The show table output lists every key the table currently holds (source IP, cookie value) with the server it points at. The verbose curl confirms whether HAProxy is inserting the cookie and which server it routed the client to.

What’s happening

HAProxy supports three approaches:

  • cookie directive (HTTP-only) — HAProxy inserts or rewrites a cookie identifying the backend server. Subsequent requests from the same client carry the cookie and HAProxy routes accordingly.
  • stick-table + stick on (HTTP or TCP) — HAProxy maintains an in-memory table keyed by some request attribute (source IP, cookie value, header value) that maps to a server. Survives across multiple requests from the same key.
  • balance source (TCP or HTTP) — a hash of the client IP picks the server. No state; rebalances naturally when servers join or leave.

cookie is the production default for HTTP because it survives client IP changes (mobile networks, NAT) and is explicit — you can see the cookie in DevTools. stick-table is what you use when you cannot insert a cookie (TCP frontends, third-party clients that strip cookies). balance source is the fallback when you have neither.

The big trade-off: stickiness pins a client to a server, which means a sick server keeps receiving requests from its pinned clients until it goes DOWN. The redispatch option exists precisely for this; without it, a sticky session attached to a dead server returns errors forever.

The procedure

  1. Cookie-based stickiness. Insert a cookie naming the server, scope it to the path, and let HAProxy rewrite the server selection:

    backend be_app
        balance roundrobin
        cookie SRVID insert indirect nocache httponly secure
        option httpchk
        http-check send meth GET uri /healthz
        http-check expect status 200
        server app1 10.0.1.11:8080 check cookie app1
        server app2 10.0.1.12:8080 check cookie app2

    The cookie app1 on the server line is the value HAProxy writes into the SRVID cookie when this server is chosen. The insert indirect nocache httponly secure flags are the production combination: insert means HAProxy adds the cookie, indirect means HAProxy strips it from the request before forwarding (so the backend doesn’t see it), nocache makes it not survive in caches, httponly prevents JS access, secure requires HTTPS.

  2. Stick-table by source IP. For TCP frontends or when you cannot insert cookies:

    backend be_app
        balance leastconn
        stick-table type ip size 100k expire 30m
        stick on src
        server app1 10.0.1.11:8080 check
        server app2 10.0.1.12:8080 check

    size 100k is the maximum number of entries; expire 30m is the idle timeout per entry. The table lives in memory only — see HAProxy stick tables for persistence across reloads and HAProxy peer protocol for replicating across instances.

  3. Stick on a header value. When the client identity is in a custom header:

    backend be_app
        stick-table type string len 64 size 50k expire 1h
        stick on hdr(x-tenant-id)
        server app1 10.0.1.11:8080 check
        server app2 10.0.1.12:8080 check

    type string len 64 allocates 64 bytes per key; sizing matters because the table is preallocated.

  4. balance source — no state needed. A hash of the source IP picks the server:

    backend be_app
        balance source
        hash-type consistent
        server app1 10.0.1.11:8080 check
        server app2 10.0.1.12:8080 check

    hash-type consistent uses a Karger-style ring so adding or removing a server reshuffles only ~1/N of the clients. Without consistent, every client gets reshuffled when the pool changes.

  5. Combine cookie and redispatch. The production-safe pattern: stick clients to a server, but redispatch if that server is down:

    defaults
        option redispatch
        retries 3
    
    backend be_app
        cookie SRVID insert indirect nocache httponly secure
        option httpchk
        http-check send meth GET uri /healthz
        http-check expect status 200
        server app1 10.0.1.11:8080 check cookie app1
        server app2 10.0.1.12:8080 check cookie app2

    option redispatch is what saves you when a stuck client tries to reach a downed pin: HAProxy gives up after the retries and routes to a healthy server.

  6. Persist stick-table across reloads. Without persistence, a systemctl reload haproxy empties the table:

    backend be_app
        stick-table type ip size 100k expire 30m peers cluster_peers
        stick on src
        server app1 10.0.1.11:8080 check
        server app2 10.0.1.12:8080 check

    The peers cluster_peers clause replicates the table to peers. On reload, HAProxy pulls state from a peer.

Common pitfalls

  • A sticky session without option redispatch keeps trying the dead server forever; the client sees errors until the cookie expires.
  • Cookie-based stickiness with insert rewrites the Set-Cookie header — if the backend already sets SRVID for its own purposes, you collide. Use a unique cookie name.
  • balance source without hash-type consistent reshuffles every client on a server swap. With autoscaling, this is a thundering herd against cold caches.
  • Stick tables are per-process — without peers, a multi-instance HAProxy cluster has N independent tables. The client whose state lives on instance A but who hits instance B in the next request gets reshuffled.
  • The cookie HAProxy inserts is HTTP-only and Secure by best practice, but if the app explicitly reads its own session cookie and HAProxy is also inserting one, your debug logs will fill with the wrong cookie. Use distinct names.

Stack Harbor recommends moving to a shared session store (Redis, database, JWT) as the first option and treating sticky sessions as the fallback for legacy apps. When sticky is required, we deploy with peers replication so a reload or instance loss does not unstick everyone at once. That continuity is part of how we run Clustered Environments.