ERR_EMPTY_RESPONSE is a clean close with nothing in it. The connection opened, the request was accepted, and the far end hung up having sent zero bytes of HTTP. No status code, no headers, no body - which is why there is nothing on the page to work with and why it feels harder to debug than a 500.
What a clean, empty close narrows down
Getting here proves quite a lot for free. DNS resolved, the network delivered the packets, the TCP handshake completed and a server accepted the request. What failed is whatever was supposed to produce a response.
That points at three things, in this order: a backend that died, a proxy that stopped waiting, and a worker pool that had nothing left to give.
The log line that names the cause
On an nginx and PHP-FPM stack, one line does most of the diagnosis:
tail -100 /var/log/nginx/error.log | grep -i 'upstream'Look for upstream prematurely closed connection while reading response header from upstream. That is nginx saying it connected to PHP-FPM fine, sent the request, and PHP-FPM closed the connection before answering. It is a report about the backend, not about nginx, so the next place to look is the FPM log rather than this one.
Cause 1: the PHP worker died
tail -50 /var/log/php*-fpm.log
journalctl -u php8.2-fpm --since '15 minutes ago'
dmesg -T | grep -i 'killed process' | tail -20Three findings are worth acting on. A line mentioning SIGSEGV means an extension crashed on this particular input - usually image processing, PDF generation, or a compiled library handling something malformed. A PHP fatal error about memory means the script exceeded memory_limit. And a kernel OOM kill naming php-fpm means the machine ran out of RAM entirely, which is a different problem from the PHP limit and is not fixed by raising it.
For the memory cases, resist raising the limit as the first move. A script that loads an entire table into an array will fail again at a larger table; batching the query fixes it permanently and uses less memory for everyone.
Cause 2: a timeout mismatch between the layers
This is the cause behind almost every "only the slow pages fail" report. A request passes through several layers, each with its own patience, and the shortest one always wins. If PHP is allowed to run for 120 seconds but the proxy in front waits only 60, the proxy closes the connection at 60 every single time - and the backend was going to answer at 90.
grep -E 'max_execution_time' /etc/php/*/fpm/php.ini
grep -E 'proxy_read_timeout|fastcgi_read_timeout' /etc/nginx/nginx.conf /etc/nginx/sites-enabled/*Align them so that each outer layer waits at least as long as the one inside it:
fastcgi_read_timeout 180s;
proxy_read_timeout 180s;
proxy_send_timeout 180s;Behind Cloudflare there is a further limit you do not control on most plans, so a request that genuinely needs several minutes should not be a synchronous page load at all. Move long imports and exports to a background job that returns immediately and reports progress - that removes the whole class of failure rather than raising numbers until it stops.
Cause 3: the worker pool ran out
When every PHP-FPM child is busy and the backlog fills, new requests are closed rather than queued indefinitely. This is the version of the error that correlates with traffic rather than with a URL.
grep -c 'server reached pm.max_children' /var/log/php*-fpm.log
grep -E 'pm.max_children|pm.max_requests' /etc/php/*/fpm/pool.d/www.conf
free -hThe instinct is to raise pm.max_children, and that is right only if the memory is there to back it. Each child consumes RAM, and a pool sized larger than the machine can hold trades this error for an OOM kill. Work out roughly how much a single worker uses under load, divide the memory you can spare by that, and set the ceiling from the answer rather than from a round number.
Cause 4: the response was never valid
Occasionally the backend does respond and the response is unusable - output emitted before the headers, a fatal error printed into what should have been a body, or a redirect loop that terminates without content. Take the proxy out of the picture and ask the application directly:
curl -sSv http://127.0.0.1/the-failing-path 2>&1 | tail -20
php -l /path/to/the/script.phpRunning the failing script from the command line will often print the fatal error that the web request swallowed, which is the fastest route to the actual bug.
Working the problem in the right order
- Reproduce it with
curl -vand note whether anything at all came back. - Grep the nginx error log for
upstreamat that timestamp. - Read the PHP-FPM log and
dmesgfor the same second. - Compare the timeouts across every layer the request crosses.
- Only then start changing limits — and change the one the logs pointed at, not all of them.
The reason this order matters is that all four causes present identically in the browser. The blank page tells you nothing; the logs tell you everything, and they are usually more specific than the fix you would have guessed at.