ERR_EMPTY_RESPONSE: Causes and the Fix

· 5 min read · 4 views · Getwebup

The short answer

ERR_EMPTY_RESPONSE means the server accepted the connection and closed it without sending a single byte. The usual causes are a PHP worker that died mid-request, a proxy timing out before the backend finished, and a worker pool that ran out of capacity. The line 'upstream prematurely closed connection' in the nginx error log is the direct confirmation.

Connection killed with a reset instead? That is the other error — ERR_CONNECTION_RESET: Causes and the Fix

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 -20

Three 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 -h

The 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.php

Running 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

  1. Reproduce it with curl -v and note whether anything at all came back.
  2. Grep the nginx error log for upstream at that timestamp.
  3. Read the PHP-FPM log and dmesg for the same second.
  4. Compare the timeouts across every layer the request crosses.
  5. 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.

Questions people actually ask