Skip to main content

Pre-Processing in HAProxy

Updated 5 min readMarkdown

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:

  1. Answer first. set_status(201), start_response() and an empty send("") flush the response to the client immediately. From the client's point of view, the request is done.

  2. Round-robin, the lazy way. incr alternates between 1 and 2:

    • incr = 1 → 1 & 1 = 1 → 1 + 1 = 2
    • incr = 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.

  3. 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 with applet: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-load loads the script at startup and registers the pre_processing service.
  • The frontend routes by path. /payments-summary goes to a normal backend, because the client actually needs the data from the API.
  • backend payment has no servers. http-request use-service lua.pre_processing hands the whole request to the Lua service, which produces the response itself.
  • The summary backend is a regular static-rr load balancer over the same two Unix sockets.

Why it helps

  • Lower latency on the hot path. POST /payments is 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 201 is returned before anything has been persisted. If the API instance is down or crashes before enqueuing, the payment is silently lost and /payments-summary will be inconsistent.
  • No error handling. socket:connect and socket:send can 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)

No comments yet. Be the first to comment.