A 504 on shared hosting: the timeout is not yours
Search for how to fix a 504 and the answer is to raise proxy_read_timeout, or fastcgi_read_timeout, or request_terminate_timeout. All three live in files that on a shared plan you cannot read, let alone edit.
Which turns out to be a useful constraint, because the only remaining option is the one that actually fixes the problem: make the request finish sooner.
Which limit belongs to whom
| Limit | Set in | Yours on a shared plan |
|---|---|---|
| Proxy read timeout | The web server in front | No |
| FPM request termination | The PHP pool configuration | No |
| PHP execution time | php.ini, sometimes .user.ini | Sometimes, up to the host's ceiling |
| Database wait timeout | The database server | Rarely |
| The provider's own request cap | Somewhere you will never see | No |
The first row is the one that produces the 504, and it is the one furthest from your reach. Which is worth understanding before you spend an afternoon on the third row: raising PHP's execution time on a plan whose proxy gives up at sixty seconds changes nothing, because the visitor's connection was abandoned long before PHP finished.
The 504 is emitted by something in front of your application, reporting that it waited and stopped waiting. The number in the code is a duration, not a fault: what a 504 is actually telling you.
Finding out how long you are allowed
Ask the wall where it is, rather than reading documentation that may describe a different plan.
<?php // slow.php, delete immediately afterwards
$n = min( 300, (int) ( $_GET['s'] ?? 10 ) );
sleep( $n );
echo "survived {$n}s";
for s in 10 30 60 90 120; do
printf '%3ss -> %s\n' "$s" \
"$(curl -sS -o /dev/null -m 400 -w '%{http_code} in %{time_total}s' "https://example.com/slow.php?s=$s")"
done
The first value that returns 504 instead of 200 brackets the real limit. Two things to note from the output: the time actually elapsed before the error, which is the number to design against, and whether the failure is 504 or 502, because a 502 at that point means the process was killed rather than the wait being abandoned, and that is a different limit with a different owner.
Delete the file. A public endpoint that occupies a worker for two minutes on request is a denial-of-service tool aimed at your own client.
Five things that make a request slow, in the order they turn out to be it
An outbound call with no timeout. This is the big one and it deserves the rest of a section below.
A query with no index. Fine for three years, then the client's catalogue grew. The symptom is a specific page, or a specific filter, rather than the whole site.
An update check on every page load. Plugins that phone home, without caching the answer, on requests where nobody is going to install anything. When the far end starts rate limiting that traffic, the wait gets longer rather than shorter, which is a self-inflicted version of the 429 your own tooling produced.
Image processing in the request. A large upload being resized while the visitor waits.
Scheduled work riding on a page load. On WordPress this is the default arrangement: the site has no clock of its own and checks its task list during a visitor's request, so an unlucky person pays for the backup with their page load.
The outbound call, which takes the whole site down rather than one page
A page calls a third party. A payment provider, a shipping rate lookup, a font service, an analytics endpoint, a currency feed. That third party has a bad afternoon and starts taking ninety seconds to answer instead of two hundred milliseconds.
Your page now takes ninety seconds. Each of those requests occupies one PHP process for the whole time. A shared plan gives you a small number of concurrent processes, so it takes very few simultaneous visitors to consume all of them, and once they are gone every other page on the site queues behind. The homepage, which calls nothing, starts returning 504 as well.
One slow dependency has become a total outage, and nothing in your code changed.
The fix is a timeout on every outbound call, chosen deliberately. In WordPress the default for an HTTP request is five seconds, which is reasonable and is silently overridden by plenty of plugins:
add_filter( 'http_request_timeout', fn() => 5 );
In plain PHP, set it explicitly rather than relying on whatever the library defaults to:
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 3 );
curl_setopt( $ch, CURLOPT_TIMEOUT, 8 );
Then decide what the page does when the call times out. Degrading, showing a cached value or omitting a block, is nearly always better than failing, and it is a decision somebody has to make rather than a default.
Moving the work out of the request
When the work genuinely takes a long time, an import, a report, a bulk operation, no timeout value is the answer. The visitor should not be holding a connection open while it happens.
Two routes on a shared plan. Queue it, so the page returns immediately and something else does the work. Or schedule it from a real crontab entry, where the command line runs under a different configuration from the web server and is generally not subject to a wall-clock limit at all. Either way the visitor gets an answer in milliseconds and the long operation happens where nobody is waiting for it.
What to ask the host, in words that get an answer
Not "can you increase the timeout". That reads as a request to weaken a protection that exists to stop one account affecting others, and it will be declined.
Ask three specific questions instead. What is the proxy read timeout on this plan. How many PHP processes may this account run concurrently. And is there a separate limit above PHP that terminates long requests.
Those three numbers, written into the client's file, are the design constraints for everything on the site. They also tell you whether the plan is the wrong plan, which is occasionally the honest answer and is much easier to have as a conversation when you can name the limits rather than describe a feeling.
And if the question is simply what the code means before any of this, the short version is one paragraph long.