log in
consulting hosting industries the daily tools about contact

The nginx directive that cuts PHP-FPM overhead in half

One keepalive line in your nginx upstream block and you stop paying TCP handshake tax on every PHP request. Here's why it's off by default and how to tune it.

I've tuned a lot of nginx configs over the years and I kept seeing the same thing: servers handling two or three hundred PHP requests per second with perfectly healthy CPU and memory numbers, but p99 latency sitting higher than it should be. The culprit, more often than not, was sitting right in the upstream block — or rather, wasn't sitting there. The keepalive directive. One line. Huge difference.

What's actually happening on every request

nginx talks to PHP-FPM over a socket — either a Unix domain socket or a TCP socket. By default, nginx opens a new connection to PHP-FPM for every single request and closes it when the response is done. If you're running PHP-FPM over TCP (which many setups do, especially when FPM is on a separate container or host), that means a full TCP handshake — SYN, SYN-ACK, ACK — before PHP even sees your request. Then a FIN/ACK teardown on the way out.

Over Unix sockets the handshake cost is lower, but it's still not zero. You're still paying kernel overhead to establish and destroy the connection on every cycle.

At 10 req/s you'll never notice. At 300 req/s on a busy Laravel app, you're doing 300 unnecessary handshakes per second, adding latency, burning file descriptors, and putting pointless pressure on the FPM process manager.

The fix is nginx upstream keepalives — telling nginx to hold a pool of idle connections open to the upstream instead of closing them after each request.

The config

Here's what a typical PHP-FPM upstream block looks like before:

upstream php_fpm {
    server 127.0.0.1:9000;
}

And after:

upstream php_fpm {
    server 127.0.0.1:9000;
    keepalive 32;
}

That keepalive 32 tells nginx to keep up to 32 idle connections per worker process open to the upstream. But that alone isn't enough — you also need to set the right HTTP version and connection header on the proxy pass, because keepalives require HTTP/1.1 and nginx defaults to HTTP/1.0 for upstream requests:

server {
    listen 80;
    server_name example.com;
    root /var/www/html/public;

    index index.php;

    location ~ \.php$ {
        fastcgi_pass php_fpm;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_filename;
        include fastcgi_params;

        # These two are required for keepalives to actually work
        fastcgi_keep_conn on;
    }
}

Wait — if you're using fastcgi_pass, the relevant directive is fastcgi_keep_conn on, not the HTTP/1.1 header trick. That HTTP version thing applies when you're proxying to an HTTP upstream. FastCGI has its own keepalive mechanism, and fastcgi_keep_conn on is the switch that activates it. You need both the keepalive N in the upstream block and fastcgi_keep_conn on in the location block. Either one without the other does nothing useful.

Full working example:

upstream php_fpm {
    server 127.0.0.1:9000;
    keepalive 32;
}

server {
    listen 80;
    server_name myapp.example.com;
    root /var/www/myapp/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass php_fpm;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_keep_conn on;

        # Sane timeouts while we're here
        fastcgi_connect_timeout 5s;
        fastcgi_read_timeout 30s;
        fastcgi_send_timeout 30s;
    }
}

If you're running PHP-FPM over a Unix socket instead of TCP, the keepalive benefit is smaller but still real. The config is identical — just swap the server line:

upstream php_fpm {
    server unix:/run/php/php8.2-fpm.sock;
    keepalive 16;
}

Why it's off by default

This is the part that gets people. nginx is conservative here for a reason.

Keepalive connections are held open per nginx worker process. If you have 8 workers and keepalive 32, you're reserving up to 256 idle connections to PHP-FPM. PHP-FPM has its own pm.max_children limit — if your FPM pool is configured for 20 children and nginx is trying to hold 256 connections open, you'll exhaust the FPM pool with idle connections and real requests will queue or fail.

The default-off behavior protects you from that misconfiguration. It's the right call for a general-purpose default. It's the wrong call for a production server you've actually thought about.

The rule of thumb I use: set keepalive to somewhere between 25–50% of your FPM pm.max_children. If FPM is configured for 50 children and you have 4 nginx workers, keepalive 8 means up to 32 total idle connections — leaves plenty of FPM children available for real work.

The gotchas that will bite you

FPM process manager mode matters. If you're running pm = ondemand, FPM spins processes up and down based on demand. Keepalive connections can hold FPM children open longer than ondemand wants, interfering with its lifecycle management. On high-traffic apps I always run pm = static or pm = dynamic, never ondemand. This is part of why.

pm.max_requests interacts with keepalives. FPM has a setting to recycle worker processes after N requests — useful for catching memory leaks in PHP code. When an FPM child hits its pm.max_requests limit and exits, nginx may have a keepalive connection open to it that's now dead. nginx will get a connection reset, log an upstream error, and retry — usually transparently, but you'll see noise in your logs if you're not expecting it. This is normal. Don't turn off pm.max_requests to silence it; just accept the occasional retry.

keepalive_timeout on the upstream is separate from your client timeout. The upstream keepalive timeout (configurable via keepalive_timeout in the upstream block, defaults to 60s) controls how long an idle connection stays in the pool. Your fastcgi_read_timeout is something else entirely. Don't conflate them.

Don't just copy a number from a blog post. Including this one. Load test your specific app. I ran k6 against a Laravel app for a Seattle e-commerce client last year — 150 concurrent users, mixed page weight. With keepalives off: p95 around 180ms. With keepalive 16 and fastcgi_keep_conn on: p95 dropped to around 95ms. Same hardware, same PHP code, same database. That's the handshake overhead, measured.

Your numbers will be different. But the direction will be the same.

When I'd reach for this

Every production PHP-FPM server running more than trivial traffic. It's a one-line change with a five-minute audit of your FPM pool size. The risk is near-zero if you're not accidentally over-allocating idle connections relative to your FPM children.

I add this to every nginx config I write now. It's in my internal NWOS server provisioning scripts. There's no good reason not to on a properly sized server.

I'd skip worrying about it on:

  • Dev/staging environments where you just want parity with prod config, not optimized for throughput
  • Very low traffic sites (under ~20 req/s) where the gains are invisible
  • Environments where you genuinely don't control the FPM config and can't verify pm.max_children

If you're on shared hosting or a managed PHP environment where nginx and FPM are both black boxes — none of this applies to you anyway.

The broader lesson

nginx's defaults are conservative because nginx ships to millions of setups and can't know yours. That's sensible. But running production with uncritical defaults is leaving performance on the table that you already paid for in hardware.

Five minutes with your upstream block. Check your FPM pool size. Add the two lines. Reload nginx. Watch your p99 latency drop.

It's one of the cheapest performance wins I know of, and it's sitting right there in the config file you're probably already editing.

Need help shipping something like this? Get in touch.