Pre-Processing in HAProxy
In Rinha de Backend 2025, the challenge is to build an intermediary for a payment processor: receive POST /payments, forward each payment to an external processor, and answer GET /payments-summary with consistent totals. All of it runs under tight limits - 1.5 CPU and 350 MB of RAM shared between the load balancer, the API instances and whatever storage you use.
The usual setup is a load balancer in front of two API instances. The load balancer is "just" plumbing, so most people pick nginx or HAProxy and move on. But every request still pays for the full round trip: client → LB → API → LB → client. The API has to parse the request, decide what to do and reply before the client gets its answer.
Looking at the problem, POST /payments doesn't need an answer from the API at all. The client only cares about getting a 2xx back quickly — the real work (calling the payment processor, retrying, persisting) happens asynchronously anyway. So why wait for the API?
That's the idea of this post: let HAProxy answer the request itself and push the payload to the APIs in the background, using a tiny Lua script.
The Lua service
HAProxy can embed Lua and expose a Lua function as an HTTP service — something that generates the response directly, instead of proxying to a backend server.
local incr = 1
core.register_service("pre_processing", "http", function(applet)
-- 1. Answer the client right away
applet:set_status(201)
applet:start_response()
applet:send("")
-- 2. Pick an API instance (round-robin between two)
local urls = { 'unix@/tmp/api1.sock', 'unix@/tmp/api2.sock' }
local addr = urls[incr]
incr = (incr & 1) + 1 -- ;)
-- 3. Forward the original body to the chosen instance
local socket = core.tcp()
socket:connect(addr)
local data = applet:receive()
local req = 'POST /payments HTTP/1.1\r\n' ..
'Connection: keep-alive\r\n' ..
'Content-Type: application/json\r\n' ..
'Host: localhost\r\n' ..
'Content-Length: ' .. applet.length .. '\r\n\r\n' ..
data
socket:send(req)
socket:close()
end)
Step by step:
Answer first.
set_status(201),start_response()and an emptysend("")flush the response to the client immediately. From the client's point of view, the request is done.Round-robin, the lazy way.
incralternates between1and2:incr = 1→1 & 1 = 1→1 + 1 = 2incr = 2→2 & 1 = 0→0 + 1 = 1
A bitwise AND instead of a modulo or an
if. Not necessary, but fun. Note that this variable lives inside each Lua state, so it isn't perfectly shared across HAProxy threads — for two backends that's good enough, since the distribution evens out.Fire and forget.
core.tcp()opens a raw socket (here, a Unix socket — no TCP stack overhead between containers sharing a volume). We read the client body withapplet:receive(), write a hand-crafted HTTP request and close the socket without reading the response. The API receives the payload and enqueues it; nobody waits for its reply.
HAProxy configuration
global
maxconn 2000
tune.lua.bool-sample-conversion normal
lua-load /tmp/pre-processing.lua
defaults
mode http
timeout client 5000s
timeout connect 5000s
timeout server 5000s
option http-keep-alive
frontend frontend
bind *:80
use_backend summary if { path /payments-summary }
use_backend payment if { path /payments }
backend payment
http-request use-service lua.pre_processing
backend summary
balance static-rr
option http-keep-alive
server api1 unix@/tmp/api1.sock check maxconn 1000
server api2 unix@/tmp/api2.sock check maxconn 1000
The interesting bits:
lua-loadloads the script at startup and registers thepre_processingservice.- The frontend routes by path.
/payments-summarygoes to a normal backend, because the client actually needs the data from the API. backend paymenthas no servers.http-request use-service lua.pre_processinghands the whole request to the Lua service, which produces the response itself.- The
summarybackend is a regularstatic-rrload balancer over the same two Unix sockets.
Why it helps
- Lower latency on the hot path.
POST /paymentsis by far the most frequent request. Its response time now depends only on HAProxy, not on the API's event loop, GC pauses or queue. - Less work in the APIs. They don't have to build and send responses for payments; they just read the body and enqueue.
- Unix sockets. Both hops (HAProxy → API) avoid the loopback TCP stack, which matters when you're counting fractions of a CPU.
Trade-offs
This is a benchmark trick, and it comes with real costs you wouldn't accept in production:
- You lie to the client. A
201is returned before anything has been persisted. If the API instance is down or crashes before enqueuing, the payment is silently lost and/payments-summarywill be inconsistent. - No error handling.
socket:connectandsocket:sendcan fail, and the script doesn't check. A health check or a fallback to the other instance would be the first thing to add. - No backpressure. If the APIs fall behind, HAProxy keeps accepting and acknowledging requests anyway.
- Hand-written HTTP. It works because we control both sides. Any proxy or framework expecting a well-behaved client might not appreciate the connection being closed before it responds.
Wrapping up
HAProxy is usually treated as a dumb pipe, but with a few lines of Lua it can take over part of your application logic: answer requests on its own, pick a backend and forward the payload asynchronously. For a challenge like Rinha de Backend, where every millisecond and every slice of CPU counts, moving the "accept the payment" step into the load balancer is a cheap and effective win. `
About the author
I'm from Brazil and I've been working professionally with technology since 2009. I love to learn, and my current professional interests are:
Infrastructure Security Platforms Cloud Coding and automation (always my number #1 <3)
Comments
Join the discussionNo comments yet. Be the first to comment.