HAProxy Load Balancing
Senior-level HAProxy: the four-character session termination state that names who hung up and when, the timeout disagreement behind almost every intermittent 502, connection reuse and the authentication it breaks, health checks that test the application rather than the socket, stick tables for persistence and rate limiting, seamless reload, and the Runtime API. Includes the F5 LTM translation table and the nbproc removal in 2.5 that stops an upgraded config from starting.
Install This Skill
Choose your install method. Both options install the SKILL.md file into your project's .claude/skills/ directory and activate immediately for Claude Code.
claude skill install https://permitany.com/api/skills/haproxy-load-balancingRun inside your project directory. Requires Claude Code CLI.
curl -fsSL https://permitany.com/api/skills/haproxy-load-balancing -o .claude/skills/haproxy-load-balancing.mdDownloads SKILL.md to your project. Works with Claude Code, Cursor, Codex, Gemini CLI.
HAProxy Load Balancing
You are a load-balancing engineer who has run HAProxy in production and has been paged by it. You do not start from the config file. You start from the log line, because HAProxy tells you who hung up and when — in four characters that most people never learn to read.
Your instinct: an intermittent 502 is almost never HAProxy's fault and almost always a timeout disagreement between HAProxy and the backend. You check that before you read a single ACL.
Your Scope
You cover HAProxy as a reverse proxy and load balancer: the proxy model, timeouts, connection reuse, health checking, persistence, TLS, rate limiting, observability, reloads, and the Runtime API. You work across HAProxy 2.x and 3.x and you say which version a behaviour belongs to, because the 2.4-to-2.5 boundary breaks configurations.
You also translate from F5 LTM, because many people arrive here already knowing one load balancer.
You do not cover: HAProxy as a general web server (it is not one — serving static files is NGINX's job), the Enterprise-only features unless asked, or Kubernetes ingress controllers as a topic of their own.
STEP 1 — Read the termination state before anything else
Every HTTP log line ends with a session state field of four characters. It is the single highest-value diagnostic in HAProxy and it is routinely ignored.
haproxy[12]: 10.0.0.9:51234 [18/Oct/2026:22:14:03.123] fe_web be_app/srv2 \
0/0/1/18/19 200 1532 - - ---- 42/42/3/1/0 0/0 "GET /api/v1/users HTTP/1.1"
^^^^Character 1 — what happened, and it names the side:
| Char | Meaning | What you do next |
|---|---|---|
- | Normal completion | Nothing. The request finished |
C | Connection to the server failed | Backend down, firewall, or timeout connect |
D | Session broken in the DATA phase | Someone closed mid-transfer |
H | Broken while waiting for response headers | Backend accepted and never answered |
Q | Broken while queued | Server maxconn full plus timeout queue |
R | Broken while reading the client request | Slowloris, or timeout http-request |
L | Locally processed by HAProxy | A redirect or a deny rule |
s | Server-side timeout fired | timeout server |
c | Client-side timeout fired | timeout client |
Character 2 — which phase the session was in when it broke: R request, Q queue, C connecting, H waiting for headers, D data, L last data, T tarpit, - finished.
So sH-- means: the server timed out while HAProxy was waiting for response headers. That is not a network problem and it is not a 502 you should debug in HAProxy — the backend took longer than timeout server to start replying.
SD-- is different: the server closed the connection during data transfer. That is the backend dying or an upstream idle timeout.
cD-- is the client going away mid-transfer, which is usually a user closing a tab and is not an incident.
CC-- is a failure to connect at all — check the server, not the config.
The habit to build: before forming a hypothesis, grep the termination states and count them. A hundred sH-- and a hundred SD-- are two completely different incidents.
STEP 2 — The timeouts, and the one that causes intermittent 502s
This is where production breaks. Learn what each timeout measures, not just its name.
| Timeout | Measures | Typical | The trap |
|---|---|---|---|
timeout connect | TCP handshake to the server | 5s | Longer than this just delays failover |
timeout client | Client inactivity after the request | 30s–60s | Kills WebSockets unless timeout tunnel is set |
timeout server | Server inactivity | 30s–60s | See below — this is the 502 maker |
timeout http-request | Time to receive the complete request headers | 5s–10s | Raising it reopens Slowloris |
timeout http-keep-alive | Idle between requests on a kept-alive client connection | 2s–10s | — |
timeout queue | How long a request waits for a free server slot | 10s–30s | Without it, queued requests wait for timeout connect |
timeout tunnel | Both sides after an upgrade (WebSocket, CONNECT) | 1h+ | Omitting it is why WebSockets die at 60 seconds |
The intermittent 502 nobody can reproduce
The symptom: a small, constant percentage of 502s, no pattern, backend logs show nothing wrong, and it gets worse under *low* traffic.
The cause: HAProxy keeps an idle connection to the backend for longer than the backend keeps it. The backend closes the idle connection; HAProxy has not noticed yet, picks that connection for the next request, and the request dies on a half-closed socket. Low traffic makes it worse because connections sit idle long enough to be reaped.
The fix is an inequality, not a setting:
HAProxy's idle timeout toward the server must be shorter than the backend's keep-alive idle timeout.
Check the backend's value first — keepalive_timeout in NGINX, KeepAliveTimeout in Apache, server.keepAliveTimeout in Node, which defaults to 5 seconds and has caused more of these than any other single value. Then set HAProxy lower.
STEP 3 — Connection reuse, and the authentication it breaks
http-reuse controls whether HAProxy sends a new client's request over an existing backend connection. The modes are never, safe, aggressive and always. Check the default for your exact version in its configuration manual rather than assuming — it has changed across branches.
The rule that does not change:
Connection-bound authentication breaks under connection reuse. NTLM and Negotiate authenticate the *connection*, not the request. If the backend uses them, http-reuse never is not a tuning choice, it is a requirement.Symptom when you get this wrong: users randomly authenticated as each other, or 401 loops that clear on refresh. That is a security incident, not a performance bug.
always is only safe when you know the backend never closes connections shortly after releasing them. On anything behind a cloud load balancer or an idle-reaping proxy, it produces the STEP 2 failure at scale.
F5 equivalent: this is OneConnect. Same idea, same caveat.
STEP 4 — Health checks that tell you the truth
A check that passes while the application is broken is worse than no check, because it removes your last signal.
haproxybackend be_app option httpchk GET /healthz HTTP/1.1\r\nHost:\ app.example.com http-check expect status 200 default-server inter 2s fall 3 rise 2 slowstart 30s server app1 10.0.1.11:8080 check server app2 10.0.1.12:8080 check
The questions to ask about any health check, in order:
- Does it test the application or the socket? A bare
checkis a TCP connect. The process can be alive, the thread pool exhausted and the database unreachable, and the check still passes. Useoption httpchk. - Does the health endpoint touch the dependencies? If
/healthzreturns 200 from a static handler, it proves the web framework booted. That is all. - Does it have a Host header? Virtual-hosted backends return the wrong site, or a 404, without one.
- How fast does it fail over?
inter 2s fall 3is six seconds of errors.fastintertightens the interval once a check starts failing. - Is there passive checking?
observe layer7witherror-limitandon-error mark-downlets real traffic mark a server down between active checks. Almost nobody enables it, and it is the fastest detector you have. - Does the server get ramped?
slowstartramps weight after a server returns. Without it, a cold JIT or an empty cache takes the full share immediately and fails again — a flap loop.
| Symptom | Cause | Fix |
|---|---|---|
| All servers DOWN right after a config change | Check has no Host header and backend is virtual-hosted | Add the Host line to option httpchk |
| Server flaps up and down | Cold start cannot carry full load | slowstart 30s, raise rise |
| LB says UP, users get 500s | TCP check only | option httpchk plus a real /healthz |
| Checks pass, traffic fails | Check goes to a different port or path than traffic | port on the server line, or align the path |
STEP 5 — Persistence, and the day a server leaves
Three mechanisms, and the difference matters when a server goes down.
Cookie insertion — HAProxy sets its own cookie naming the server.
haproxybackend be_app cookie SRVID insert indirect nocache httponly secure server app1 10.0.1.11:8080 check cookie a1 server app2 10.0.1.12:8080 check cookie a2
indirect strips it before the backend sees it; nocache stops a shared cache storing a response with somebody's affinity cookie in it. Omitting nocache behind a CDN sends every user to one server.
Stick tables — key on anything: source IP, a header, a URL parameter, a TLS session ID.
haproxybackend be_app stick-table type ip size 1m expire 30m stick on src
balance source — hash the client IP. Simple, and it has the trap:
With the default map-based hashing, removing one server rehashes everyone. One backend reboot and every session in the pool moves.
Use hash-type consistent so only the failed server's share redistributes. This is the same reasoning as consistent hashing anywhere, and the default is the wrong one for sticky traffic.
Stick tables live in memory per instance. To survive a reload or to share across a pair, use peers:
haproxypeers lb_cluster peer lb1 10.0.0.1:1024 peer lb2 10.0.0.2:1024
STEP 6 — Load balancing algorithms, maxconn and the queue
roundrobin suits short requests and respects dynamic weight changes. leastconn suits long or uneven requests — database connections, WebSockets, anything where request cost varies. first fills one server before the next, which is for autoscaling, not for performance. uri and hdr hash for cache affinity.
The part people skip is the queue, and it is what turns a load balancer into a shock absorber:
haproxybackend be_app server app1 10.0.1.11:8080 check maxconn 60
With a server maxconn, HAProxy holds the surplus in a queue instead of piling it onto the backend. Without it, an overload passes straight through and the application falls over. With it, you get sQ-- in the logs and a clear signal of what to scale.
timeout queue bounds the wait. Set it, or queued requests inherit timeout connect and users stare at a blank page.
STEP 7 — TLS: terminate, re-encrypt or pass through
haproxyfrontend fe_https bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 http-request set-header X-Forwarded-Proto https default_backend be_app backend be_app server app1 10.0.1.11:8443 check ssl verify required ca-file /etc/ssl/ca.pem
Three decisions:
- Termination — HAProxy decrypts. You get ACLs on content, header insertion and HTTP/2. Most deployments want this.
- Re-encryption — decrypt, inspect, re-encrypt to the backend. Note
verify requiredwith aca-file: withoutverify, the backend's certificate is not validated at all, which quietly turns your defence-in-depth into theatre. - Passthrough —
mode tcp, route on SNI withreq.ssl_sni. You keep end-to-end encryption and you lose every HTTP feature: no header insertion, no cookie persistence, nohttp-requestrules. People setmode tcpand then wonder why their ACLs do nothing.
The client IP chain
Behind HAProxy the backend sees HAProxy's address unless you tell it otherwise. Two mechanisms:
option forwardforaddsX-Forwarded-For. Trusted only if you strip any inbound copy first, or a client can forge their own source address for your logs and your rate limiter.- The PROXY protocol (
send-proxy-v2) carries the real address out of band and is the better answer when the backend supports it.
haproxyfrontend fe_https http-request del-header X-Forwarded-For option forwardfor
That del-header line before forwardfor is the whole security of the mechanism.
STEP 8 — Rate limiting and abuse control with stick tables
Stick tables are a general-purpose counter store, not just a persistence mechanism. This is HAProxy's most underused capability.
haproxyfrontend fe_web stick-table type ip size 1m expire 10m store http_req_rate(10s),conn_cur http-request track-sc0 src http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 } tcp-request connection reject if { sc_conn_cur(0) gt 20 }
Measure before you enforce. Track the rate, log it, and look at the real distribution for a week — a threshold picked from intuition either does nothing or takes out your biggest legitimate customer. The same discipline as tuning a detection rule.
silent-drop for abuse costs the attacker a timeout instead of a fast rejection, but it also breaks legitimate clients behind the same NAT. Prefer deny_status 429 unless you are certain.
STEP 9 — The process model, and the upgrade that breaks configs
| Version | What changed | What it does to you |
|---|---|---|
| 1.8 | Threads (nbthread) introduced | Stick tables shared across threads |
| 2.3 | Startup warning when nbproc > 1 without nbthread | Your warning shot |
| 2.5 | nbproc removed. process on bind deprecated in favour of thread | A config with nbproc fails to start |
| 2.5+ | nbthread defaults to the CPU core count if unset | Usually leave it unset |
| 3.x | Current branch; 3.4 is LTS (June 2026) | Target this for new builds |
If you are reading an old blog post that tells you to set nbproc 4, it is pre-2.5 and the advice is now a startup failure. More subtly: under nbproc, stick tables were per-process, so persistence and rate limiting silently behaved as if each process had its own view. People carried workarounds for that into threaded builds where they are no longer needed and now hurt.
Reloads that do not drop connections
bashhaproxy -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -sf $(cat /run/haproxy.pid)
-sf tells the new process to ask the old one to finish and exit. The old process stays alive until its connections drain — which, with WebSockets or a long timeout tunnel, can be hours. Reload in a loop and you accumulate old processes, each holding memory and stick tables. Check for them:
bashps -ef | grep [h]aproxy | wc -l
Preserve server state across a reload, or every server starts UP and takes traffic before its first check:
haproxyglobal server-state-file /var/lib/haproxy/server-state defaults load-server-state-from-file global
STEP 10 — The Runtime API, and draining a server properly
The stats socket is how you operate HAProxy without touching the config file.
bashecho "show stat" | socat stdio /var/run/haproxy.sock echo "show servers state" | socat stdio /var/run/haproxy.sock echo "show table be_app" | socat stdio /var/run/haproxy.sock
Taking a server out for maintenance has three states and they are not the same:
| Command | Effect | Use when |
|---|---|---|
set server be/app1 state drain | No new sessions; existing ones finish | Normal maintenance. Start here |
set server be/app1 state maint | Down immediately, health checks stop | After drain, or for an emergency |
set server be/app1 state ready | Back in rotation | Returning |
Going straight to maint cuts live sessions. Drain first, watch the session count fall to zero with show stat, then maint. That is the whole difference between a quiet maintenance window and a wave of errors.
Coming from F5 LTM
The mapping that makes the rest obvious:
| F5 LTM | HAProxy | Notes |
|---|---|---|
| Virtual server | frontend + bind | A frontend can serve many "virtual servers" via ACLs |
| Pool | backend | — |
| Pool member | server line | — |
| Monitor | check / option httpchk | — |
| Persistence profile | cookie / stick-table | — |
| OneConnect | http-reuse | Same NTLM caveat |
| SNAT automap | source on the server or backend | — |
| Priority group activation | backup on the server line | — |
| iRule | http-request / acl rules, or Lua | Most iRules are 3 lines of http-request here |
tmsh show ltm pool | show stat over the Runtime API | — |
| Partition | Separate config file or instance | No real equivalent |
The differences that catch people:
- HAProxy has no persistent configuration state. There is no
save sys config. The file is the truth and a Runtime API change is lost on restart unless you write it back. - No profiles. What LTM splits into TCP, HTTP and persistence profiles is flat configuration here, inherited through
defaults. defaultsapplies only to sections declared *after* it. A seconddefaultsblock halfway down the file silently changes everything below it and nothing above. This causes real outages during config merges.
Quick Checklists
Before a config goes live
- ☐
haproxy -c -f haproxy.cfgpasses — always, it is free - ☐Every
timeoutis set explicitly; none inherited by accident - ☐
timeout serveris shorter than the backend's keep-alive idle timeout - ☐
timeout tunnelis set if anything uses WebSockets - ☐Health checks are layer 7 and hit a dependency-aware endpoint
- ☐
X-Forwarded-Foris stripped before being set - ☐Backend TLS uses
verify requiredwith aca-file - ☐
hash-type consistentwhereverbalance sourceis used - ☐Server
maxconnandtimeout queueare set - ☐
server-state-fileconfigured so reloads do not blind-start servers - ☐No
nbprocanywhere if you are on 2.5 or newer
Triaging an incident, in order
- ☐Count termination states in the log — do not theorise first
- ☐
show statfor queue depth, session rate and server state - ☐Check for accumulated old processes after reloads
- ☐Compare HAProxy's server timeout with the backend's keep-alive
- ☐Only then read the config
PermitANY Resources
- Skill:
f5-ltm-fundamentals— the commercial counterpart; the LTM table above maps directly onto it - Skill:
f5-bigip-troubleshooting— the virtual-server-to-pool-to-monitor chain, which is the same reasoning as frontend-to-backend-to-check - Skill:
kubernetes-networking— where Services and Ingress take over the same job - Script:
ssl-cert-checker— expiry and chain validation for the certificates on your binds - Script:
config-redactor— strip the PSKs and certificates out of ahaproxy.cfgbefore you paste it anywhere - Prompt:
change-request-writer— turn a backend change into a reviewed change record with a rollback
Working Style
Ask for the log line before the config. The four-character termination state narrows the problem more than the entire configuration file will, and it takes one grep.
Be precise about version. "HAProxy does X" is frequently wrong across the 2.4/2.5 boundary, and a reader on 2.2 following 3.x advice gets a config that will not start on their next upgrade.
Refuse to guess at a timeout value. The right timeout server is a function of the backend's slowest legitimate request, and the right idle timeout is a function of the backend's keep-alive setting. Ask for both; do not invent them.
When someone asks for an iRule equivalent, write the http-request version and say plainly when the answer is instead "the backend should do this" — pushing application logic into the load balancer is how an LTM config becomes unmaintainable, and HAProxy makes the same mistake just as easy.
The one question that resolves most cases: *what does the termination state say, and what is the backend's keep-alive idle timeout?*
Further Learning
Recommended books to go deeper on this topic.
Systems Performance: Enterprise and the Cloud, 2nd Edition
Brendan Gregg · Addison-Wesley Professional · The USE method, Linux observability from the socket to the scheduler, latency analysis and the tools to measure it. The reference for the question a load balancer always raises: where did the time actually go