# 504 Gateway Timeout in Nginx: Which Timeout Fired and Why

> 504 Gateway Timeout means a proxy stopped waiting for your app. Find which timeout fired, read the upstream timed out line, and fix the slow request.

1. [Home](/)
2. [Blog](/blog)
3. 504 Gateway Timeout in Nginx: Which Timeout Fired and Why

DeploymentOctober 27, 202611 min read

# 504 Gateway Timeout in Nginx: Which Timeout Fired and Why

A 504 Gateway Timeout means a proxy stopped waiting for your application. Which proxy, and which of its timeouts, decides the fix: find the layer that sent it, read the upstream timed out line in error.log, line the timeouts up, and deal with the slow request instead of waiting longer for it.

## The short version

A 504 Gateway Timeout means a proxy waited for your application longer than one of its timeouts allows and gave up. In nginx that is typically proxy\_read\_timeout, 60 seconds by default, logged as upstream timed out (110: Connection timed out) while reading response header from upstream. The application usually keeps working on the request after the 504, so raising the timeout rarely helps. Find what is slow and move long work out of the request.

[Teo Marquardt](/about#author)· Facts last checked October 27, 2026

The request spins for a full minute, and then the browser shows this:

html

```
<html>
<head><title>504 Gateway Time-out</title></head>
<body>
<center><h1>504 Gateway Time-out</h1></center>
<hr><center>nginx</center>
</body>
</html>
```

nginx spells it "Time-out", with the hyphen, and the page is the same whatever caused it. The usual advice is to raise `proxy_read_timeout` and move on. Sometimes that's the right fix, but most of the time it just makes users wait longer for the same error, while the work behind it piles up. A 504 is better read as a report: some layer between the browser and your code waited as long as it was configured to, and stopped. The useful questions are which layer it was, which of its timeouts fired, and why your application needed longer than that. All three have answers in the logs.

## What 504 Gateway Timeout means

[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-504-gateway-timeout) defines 504 as a gateway or proxy that "did not receive a timely response from an upstream server it needed to access in order to complete the request". The upstream is whatever the proxy forwards to: your application, or another proxy closer to it. The important word is timely. Something was reachable, or at least looked reachable, and the proxy waited for it until a configured limit ran out.

That's the line between 504 and its neighbour. A [502 Bad Gateway](/blog/502-bad-gateway-nginx) means the proxy got nothing usable: the connection was refused, or it broke before a response arrived. A 504 means the proxy waited and nothing came back in time. A 502 points at an app that is missing or crashing. A 504 points at one that is slow, or at a network path that silently drops packets.

### Is a 504 error my fault?

If you are visiting someone else's site, no. The timeout happened between that site's proxy and its servers, and nothing on your computer can shorten it. Reloading once is reasonable; if the page triggers a payment or a form submission, check whether it went through before you submit it again, because the server may have finished the work after the proxy gave up. If it's your site, read on.

## Find out which layer sent the 504

Production traffic rarely passes through a single proxy. A request might go through a CDN, then a load balancer, then nginx or an ingress controller, then your app server. Each hop has its own timeout, and whichever runs out first answers the user. Before changing any setting, work out which layer that was:

| What you see                                                                                 | Who gave up                                                                                                                                           | Where to look next                                                  |
| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| The page above, ending in <hr><center>nginx</center>, with a Server: nginx response header   | Your nginx, or an nginx-based ingress controller                                                                                                      | nginx error.log, sections below                                     |
| A Cloudflare-branded page with error code 524                                                | Cloudflare: your origin accepted the connection but sent no response within 100 seconds (the default as of writing, configurable on Enterprise plans) | Same as a 504 from your own proxy: the origin was too slow          |
| A 504 page without the nginx signature, and a 499 for the same request in nginx's access log | Something in front of nginx, such as a load balancer, gave up first and closed the connection                                                         | The timeout of that outer layer, and why your app is slower than it |
| A 504 and no entry for the request in nginx's access log at all                              | The layer in front of nginx could not reach nginx                                                                                                     | Network path and health of the nginx instances                      |

The 499 is worth knowing about. It's a non-standard code nginx writes to its access log when the client closed the connection before nginx had a response to send. The client might be a person closing a tab, but when the 499s cluster at a round number of seconds, the client is another proxy whose timeout ran out first. That proxy returned the 504 to the user, and nginx only saw the connection disappear.

The default access log format doesn't show timings, so add them. These variables are built into nginx and turn every 504 into a measurement:

nginx

```
log_format timing '$remote_addr "$request" $status '
                  'rt=$request_time uct=$upstream_connect_time '
                  'uht=$upstream_header_time urt=$upstream_response_time';

access_log /var/log/nginx/access.log timing;
```

A line with `504 ... uct=0.001 uht=- urt=60.002` says nginx connected immediately, waited 60 seconds for a response header, and gave up. A line with `uct=- uht=- urt=60.001` says the connection itself never completed. A `499 rt=30.004` says something in front of nginx gave up after 30 seconds. Those three cases have three different fixes.

## upstream timed out (110: Connection timed out) while reading response header from upstream

This is the line behind most nginx 504s. A typical entry in `error.log`:

text

```
2026/10/27 09:41:17 [error] 29#29: *8812 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 203.0.113.7, server: app.example.eu, request: "POST /reports HTTP/1.1", upstream: "http://10.0.1.12:8000/reports", host: "app.example.eu"
```

Errno 110 is `ETIMEDOUT`. Read the rest of the line literally. nginx connected to the upstream, sent the request, and then waited for the first bytes of the response header. Nothing arrived within `proxy_read_timeout`, so nginx closed the connection and returned 504\. The application was reachable and accepted the request. It was just busy for longer than nginx was willing to wait.

One detail of `proxy_read_timeout` is easy to miss. The [nginx documentation](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html#proxy%5Fread%5Ftimeout) defines it as the timeout "between two successive read operations, not for the transmission of the whole response". The default is 60 seconds. A response that streams a little data every few seconds can run for ten minutes without tripping it. A response that computes everything first and sends it at the end gets 60 seconds of total silence and no more. Here the timer ran out before the response header, which means the app had not even started answering. If the stall comes later, after the headers were sent, the line ends in `while reading upstream` instead, and the user gets a truncated response rather than a 504, because the status code has already gone out.

If the upstream is PHP-FPM, uWSGI or a gRPC service, the line is the same and the setting has another name: `fastcgi_read_timeout`, `uwsgi_read_timeout` or `grpc_read_timeout`, each 60 seconds by default.

### upstream timed out (110: Operation timed out)

Same error, different C library. The text after the number comes from the system's error message table, and glibc calls errno 110 "Connection timed out" while musl, the C library in Alpine Linux, calls it "Operation timed out". If your nginx runs from the `nginx:alpine` image, you'll see the second wording. The meaning and the fix don't change.

## upstream timed out (110: Connection timed out) while connecting to upstream

The errno is the same, but this line describes a different failure. This time nginx never got a connection to the upstream at all. It sent the opening packet of a TCP connection and got no answer of any kind within `proxy_connect_timeout`, which defaults to 60 seconds (the documentation adds that it "cannot usually exceed 75 seconds").

No answer is different from a refusal. If the host is up and nothing listens on the port, the kernel replies immediately and nginx logs `connect() failed (111: Connection refused)` with a 502, as in the [502 article](/blog/502-bad-gateway-nginx). A connect timeout means the packets went nowhere. The usual causes:

* A firewall rule, including a cloud provider's network firewall, drops traffic to the port instead of rejecting it, so nginx waits out the full timer.
* nginx is connecting to an address that no longer belongs to your app. nginx resolves a hostname in `proxy_pass` once, at startup. In Docker, recreate the app container and it can come back with a new IP, while nginx keeps sending to the old one until it is reloaded.
* The app is so overloaded that its listen queue is full. With default Linux settings, the kernel then quietly drops new connection attempts instead of refusing them, so from nginx's side the connect just hangs.

For the stale address, either reload nginx after the app's address changes, or make nginx re-resolve the name at runtime. In Docker the embedded DNS server is at `127.0.0.11`, and putting the hostname in a variable forces nginx to look it up through the resolver instead of once at startup:

nginx

```
resolver 127.0.0.11 valid=10s;

location / {
    set $app_upstream http://app:8000;
    proxy_pass $app_upstream;
    proxy_connect_timeout 5s;
}
```

Lowering `proxy_connect_timeout` is safe in a way that lowering the read timeout isn't. Inside one data centre a healthy upstream accepts a connection in milliseconds, so a few seconds is generous. A short connect timeout won't fix anything by itself, but it turns a minute of hanging into a fast error with a clear log line.

## The timeout chain: the shortest one wins

Every layer that handles a request has a limit, and the one that runs out first decides what the user sees. Most of these defaults were chosen independently of each other, so out of the box they rarely line up. The defaults as of writing:

| Layer                        | Setting                     | Default                                                                      | What happens when it fires                                                                                                               |
| ---------------------------- | --------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| CDN (Cloudflare)             | Origin response timeout     | 100 s                                                                        | Cloudflare's own 524 page                                                                                                                |
| Managed load balancer        | Idle or request timeout     | Often 60 s, varies by provider                                               | A 504 from the load balancer; nginx logs 499                                                                                             |
| nginx                        | proxy\_read\_timeout        | 60 s                                                                         | 504 Gateway Time-out                                                                                                                     |
| gunicorn, sync workers       | \--timeout                  | 30 s                                                                         | Worker killed; nginx returns 502                                                                                                         |
| PHP                          | max\_execution\_time        | 30 s                                                                         | Fatal error, usually a 500\. On Linux it counts only script execution time, so waiting on the database or the network doesn't trigger it |
| PHP-FPM                      | request\_terminate\_timeout | 0 (off)                                                                      | When set: worker killed, nginx returns 502                                                                                               |
| PostgreSQL                   | statement\_timeout          | 0 (off)                                                                      | Query cancelled; your code gets an error                                                                                                 |
| Outbound HTTP from your code | Client timeout              | None in Python requests; 300 s for response headers in Node's built-in fetch | Python: nothing, until a layer above gives up. Node: an error after 300 s, long after the proxy                                          |

The table explains a puzzle that trips up people reading the logs: the same slow request can produce a 502 one week and a 504 the next. With gunicorn's sync workers at their default 30 seconds and nginx at 60, gunicorn kills the worker first and nginx sees the connection close, which is a 502 and the line `upstream prematurely closed connection`. Raise `--timeout` to 120 to make the 502s stop, and nginx's 60 seconds now runs out first. The same request now ends in a 504\. The request is exactly as slow as before. Only the layer that gives up has changed.

It also explains the two `0 (off)` rows. A database query with no time limit, or an outbound API call with none, will wait for as long as the outer layers let it. When those give up, the query or the call keeps going.

Timeouts only help when each layer's limit is shorter than the one outside it. Then the innermost layer, the one that knows what went wrong, fails first with an error your code can catch, log and turn into a clear response. A database that cancels a query at 15 seconds produces an error message naming the query. A load balancer that gives up at 60 seconds produces a blank page and a 499 that names nothing. One consistent chain:

text

```
statement_timeout      15s   database cancels the query, the app gets an error
outbound HTTP calls    10s   each external API call has its own limit
app worker timeout     30s   the app server's last resort
proxy_read_timeout     60s   nginx only fires if everything inside failed to
load balancer / CDN   >60s   longer than nginx, so nginx's log has the answer
```

The numbers are an example, not a recommendation. What matters is that the limits shrink as you go inward. In PostgreSQL the per-role setting is the easiest to get right, because it covers every connection the app opens: `ALTER ROLE app_user SET statement_timeout = '15s';`.

## Why raising proxy\_read\_timeout is usually the wrong fix

When nginx gives up, it closes its connection to the upstream. Most application servers don't notice. A Python view keeps running its query, a Node handler keeps awaiting its promise, and PostgreSQL keeps executing the statement, because nothing told any of them the client has gone. PHP is a typical example: it usually only discovers the client is gone when it tries to send output. The work finishes, the result is thrown away, and the user has already seen the error.

Now add a person in front of the screen. They see the 504 and reload. The second request starts the same slow work while the first copy is still running. On a busy page, a few minutes of this means every worker is busy computing results nobody will receive. Requests that would be fast queue behind them and start timing out too. A 504 that began on one heavy endpoint spreads to the whole site.

Raising the timeout doesn't change the arithmetic. It makes each user wait longer for an answer that may still not come, and keeps the worker occupied for the extra time. It also has to be raised at every layer in the chain or the next one out simply fires instead. A five-minute `proxy_read_timeout` behind a load balancer that gives up at 60 seconds changes nothing for the user.

There is one legitimate case: a single endpoint that is slow by design, used rarely, by people who expect to wait, such as an admin CSV export. Raise the limit for that location only:

nginx

```
location /admin/export {
    proxy_pass http://app:8000;
    proxy_read_timeout 300s;
}
```

Behind ingress-nginx in Kubernetes, the same setting is an annotation on the Ingress, with values in seconds as strings. The default is "60". The Kubernetes project retired the community ingress-nginx controller in March 2026, and it no longer gets releases or security fixes, so check what your cluster actually runs before relying on these:

yaml

```
metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
```

Everywhere else, keep the limit and make the request faster, or stop doing the work inside it.

## What is actually slow

A request that takes more than 60 seconds is rarely 60 seconds of computation. Almost always it is waiting on something. These are the usual suspects, in roughly the order they turn up:

* A slow database query. A missing index or a query that scans a growing table gets slower every week until it crosses the line. Turn on `log_min_duration_statement` or use `pg_stat_statements` to find it, and set a `statement_timeout` so the next one fails in seconds with a useful error.
* Waiting for a database connection, not for the query. The query itself takes 20 milliseconds, but every connection in the pool is busy, so the request waits for one. Request time climbs while query time stays flat. [Connection pooling](/blog/pgbouncer-connection-pooling) covers how the pool queues, and [too many clients already](/blog/postgres-too-many-clients-already) covers what happens when the database's own limit is hit instead.
* An external API with no timeout. Python's `requests` waits indefinitely unless you pass `timeout=`, and a third-party service having a bad day then holds your worker for as long as the proxy allows. Give every outbound call a limit shorter than your own, for example `AbortSignal.timeout(10_000)` with `fetch` in Node.
* Every worker is busy. An app server with four sync workers handles four requests at a time. Four slow requests occupy all of them, and the fifth request waits in the queue even if it's a trivial one. The clearest symptom is a 504 on an endpoint that normally answers in milliseconds, such as a health check.
* A blocked event loop. In Node, synchronous CPU work such as parsing a large file or hashing a password with a synchronous call stops every other request in that process until it finishes.

Check whether everything is slow, or one thing is

Look at which URLs return 504\. If it's always the same one or two endpoints, the cause is in their code or their queries. If fast endpoints time out too, the app is saturated: workers are all busy, the pool is empty or the event loop is blocked, and the slow endpoint is only the trigger.

## Move long work out of the request

Some work is slow because it has to be: generating a large report, processing an upload, sending a few thousand emails, calling a slow partner API. No timeout setting makes that fit into an HTTP request, and it doesn't have to. The request can accept the job and return straight away, and the work can run somewhere that has no proxy in front of it:

javascript

```
// Accept the job and answer immediately with 202 Accepted.
app.post("/reports", async (req, res) => {
  const { rows } = await pool.query(
    "INSERT INTO jobs (kind, params, status) VALUES ('report', $1, 'queued') RETURNING id",
    [req.body]
  );
  const jobId = rows[0].id;
  res.status(202).location(`/reports/${jobId}`).json({ id: jobId, status: "queued" });
});

// The client polls this until status is 'done' and a result is attached.
app.get("/reports/:id", async (req, res) => {
  const { rows } = await pool.query(
    "SELECT status, result_url FROM jobs WHERE id = $1",
    [req.params.id]
  );
  if (rows.length === 0) return res.sendStatus(404);
  res.json(rows[0]);
});
```

A separate worker process picks queued jobs up, runs them with no proxy timeout in sight, and writes the result back. The HTTP request now takes milliseconds whatever the job does. A user who reloads sees the job's status instead of starting a second copy of it. This is the same pattern that fixes a [GitHub webhook that keeps timing out](/blog/github-webhook-timed-out): answer first, do the work afterwards. Whether that worker should be a scheduled job, a long-running worker or a proper queue depends on how quickly results are needed and how much load there is, which is covered in [cron jobs, workers and queues](/blog/cron-jobs-workers-queues).

## 504 with several upstream servers

When nginx balances across more than one server, a timeout can quietly turn into a retry. The `proxy_next_upstream` directive defaults to `error timeout`, so after a timeout on one server nginx passes the same request to the next. For a slow GET that means waiting 60 seconds per server before the user finally gets a 504, with the slow work started on each of them in turn. `proxy_next_upstream_timeout` and `proxy_next_upstream_tries` both default to 0, meaning no limit. Since nginx 1.9.13, non-idempotent requests such as POST, LOCK and PATCH are not passed to the next server unless you add `non_idempotent`, which is a sensible default to leave alone.

Retrying a failed connection is useful, because the request never reached the first server. Retrying a read timeout usually isn't, because the first server is probably still working on it. nginx files both under `timeout`, so you can't pick one by name. What you can do is cap the time window for retries: with a short connect timeout and `proxy_next_upstream_timeout` set below the read timeout, a dead server is skipped in seconds and a slow one is not retried at all:

nginx

```
upstream app {
    server 10.0.1.12:8000;
    server 10.0.1.13:8000;
}

server {
    location / {
        proxy_pass http://app;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_next_upstream error timeout;
        proxy_next_upstream_timeout 10s; # a 5 s connect timeout fits, a 60 s read timeout doesn't
        proxy_next_upstream_tries 2;
    }
}
```

## How Runsite handles it

Most of a 504 lives in application code, so most of the fix does too. What a platform can do is take the slow parts out of the request path and keep broken releases from serving traffic. [Runsite web services](/services/web-services) deploy with rolling releases gated by a configurable health check: new instances take traffic only after the check passes, and a release that fails it is rolled back automatically. Point the check at an endpoint that touches the database, and an app with an exhausted pool or a blocked event loop shows up as unhealthy instead of as a slow trickle of 504s.

For work that doesn't fit into a request, [Runsite cron jobs](/services/cron-jobs) run tasks as containers on a schedule, with a timeout of an hour by default, and alert you by email or webhook when a run fails or exceeds it. That's a better home for a nightly report or a bulk sync than an HTTP handler with a raised proxy timeout. Everything runs on servers in Germany, and the [Runsite docs](https://docs.runsite.app) describe the service settings.

## The short version

* A 504 means a proxy waited for an upstream longer than its timeout and gave up. A 502 means it got nothing usable at all.
* Find the layer first: the nginx signature on the page, a Cloudflare 524, or a 499 in nginx's access log for a 504 that something in front of nginx sent.
* `upstream timed out (110: Connection timed out) while reading response header from upstream`: the app accepted the request and stayed silent past `proxy_read_timeout`. On Alpine the same line says "Operation timed out".
* `... while connecting to upstream`: the connection itself never completed. Look for a firewall that drops packets, a stale upstream IP or a full listen queue.
* Make every timeout shorter than the one outside it, so the innermost layer fails first with a real error.
* Don't fix a 504 by waiting longer. The work keeps running after the 504 and reloads multiply it. Find what is slow, and move work that is slow by design into a background job.

Related service

## Web Services on Runsite

Deploy from a single git push and keep active apps warm — no cold starts, hosted entirely in the EU with a signed GDPR DPA on every plan.

[Explore Web Services](/services/web-services)

[Back to all articles](/blog)

FAQ

## Frequently Asked Questions

Common questions about this service.

### How can I fix the 504 gateway timeout error in Nginx?

Open the nginx error log and find the line for the failed request. upstream timed out (110: Connection timed out) while reading response header from upstream means your application accepted the request but did not start responding within proxy\_read\_timeout (60 seconds by default): find the slow query, the wait for a pooled connection or the external call without a timeout, and move work that is slow by design into a background job. The same message ending in while connecting to upstream means nginx could not open a connection at all, which points at a firewall, a stale upstream address or an overloaded app. Raise proxy\_read\_timeout only for a specific location that is slow by design.

### What is the difference between 502 and 504?

Both come from a proxy in front of your application. A 502 Bad Gateway means the proxy got no usable response: the connection was refused, reset or closed before a response arrived. A 504 Gateway Timeout means the proxy waited and no response came within its timeout. The same slow request can produce either one: if the app server's own timeout is shorter than the proxy's, it kills the request and the proxy reports 502; if the proxy's timeout is shorter, the proxy reports 504.

### Is a 504 error my fault?

Not if you are visiting someone else's site. The timeout happened between that site's proxy and its servers, so the only thing to do is wait and try again. If the page was submitting a payment or a form, check whether it went through before submitting again, since the server may have finished the work after the proxy gave up. If it is your own site, the cause is almost always a slow request in your application or a timeout chain where an outer layer gives up before the inner ones.

### How to avoid 504 gateway timeout?

Keep every request short. Give database queries a statement\_timeout and every outbound HTTP call its own timeout, both shorter than the proxy's, so slow work fails early with an error your code can handle. Order timeouts so each layer's limit is shorter than the one outside it. Run reports, imports, bulk emails and other long tasks as background jobs: return 202 Accepted with a job ID and let the client poll for the result. Monitor request duration and alert before it approaches the proxy timeout.

Keep reading

## Related articles

[Deployment9 min read502 Bad Gateway in Nginx After Deploy: Read the Error LogA 502 Bad Gateway page from nginx says nothing about the cause. The matching line in error.log does: connection refused, a worker that died mid-response, a keep-alive reset or an oversized header. What each one means and how to fix it after a deploy.Oct 9, 2026Read](/blog/502-bad-gateway-nginx)[Deployment9 min readGitHub Webhook "Timed out": Why Your Host Drops DeliveriesGitHub gives your endpoint 10 seconds and never retries on its own. Where those seconds go on a sleeping free tier, a serverless function or a deploy, and how to build a receiver that doesn't lose events.Sep 29, 2026Read](/blog/github-webhook-timed-out)[Scheduling14 min readCron Jobs, Background Workers and Queues: Choosing the Right OneThree primitives that answer three different questions. What each one does when it fails, why the syntax half of this topic is disappearing from search, and where the job actually runs.Sep 4, 2026Read](/blog/cron-jobs-workers-queues)

## Your app deserves to be online

€5 of credit on signup. Deploy in under a minute. No credit card needed.

[Start deploying](https://dashboard.runsite.app/login)[View documentation](https://docs.runsite.app)

---

Source: https://runsite.app/blog/504-gateway-timeout-nginx
