>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Nginx 499 Client Closed Request: Why It Happens, How to Fix

An access-log line that ends in 499 0 means the client closed the connection before nginx finished answering — the 0 is the number of bytes nginx managed to send, which is zero. The 499 status is nginx's private "client closed request" code: it is not an HTTP response, it never reaches the browser, and it is far too easy to read as an nginx fault when the real problem sits upstream.

I hit a wall of 499s on a reverse proxy in front of a Python API, spent an evening assuming nginx was broken, and finally reproduced the exact behavior on a lab nginx 1.30.4 with a deliberately slow upstream. Everything in this post — the log lines, the status codes, the timeouts — is what that lab actually printed. Here is what 499 means, who really closed the connection, and how to find the component at fault.

TL;DR

  • 499 = the client closed the connection while nginx was still waiting on the upstream (or mid-response). It exists only in nginx's logs; it is never sent over the wire.
  • It is a symptom, not the fault. Someone gave up before nginx could answer — usually an upstream that answered too slowly, or a proxy, load balancer, or health check that timed out first.
  • Fix the timeout chain (CDN/LB timeout ≥ nginx timeout ≥ app timeout), move long work out of the request path, and log $upstream_response_time so the next 499 tells you exactly who was slow.

What the 499 status actually is

499 is an nginx-internal status constant (NGX_HTTP_CLIENT_CLOSED_REQUEST), not a code from the HTTP specification. Cloudflare's support docs describe it the same way: "The 499 Client Closed Request status code is specific to nginx and indicates that the client closed the connection while the server was still processing the request." nginx logs 499 when request processing ends because the client's TCP connection disappeared — either while nginx was still waiting for the upstream to produce a response, or while it was streaming a response back.

The signature in the error log is unmistakable. This is the exact line my lab wrote when the client bailed:

2026/08/27 08:37:00 [info] 97770#97770: *1 epoll_wait() reported that client
prematurely closed connection, so upstream connection is closed too while
sending request to upstream, client: 127.0.0.1, server: , request:
"GET /slow/ HTTP/1.1", upstream: "http://127.0.0.1:8000/", host: "127.0.0.1:8080"

Because the client is already gone, nginx closes the upstream connection too. The upstream server is often still working — it simply never gets to deliver. That is the trap: you restart, retune, and redeploy nginx while the actual bottleneck, a slow upstream, keeps eating requests.

Keep 499 straight from its cousins — they all mean "something gave up," but the something differs:

CodeWho gave upTypical nginx error-log line
499The client (browser, app, LB, CDN, health checker)client prematurely closed connection
504nginx (after proxy_read_timeout / proxy_send_timeout)upstream timed out (110: Connection timed out) while reading response header from upstream
502The upstream (invalid response, refused connection)upstream sent an invalid response / connect() failed
408nginx (client never finished sending the request)client timed out

How I reproduced it

The setup is two processes on one box: an upstream that sleeps 30 seconds before answering, and nginx proxying to it with a 10-second proxy_read_timeout. The sleep is the lab harness — it stands in for the slow endpoint you are actually chasing.

worker_processes  1;
pid               logs/nginx.pid;
error_log         logs/error.log info;

events {
    worker_connections  256;
}

http {
    access_log logs/access.log combined;

    server {
        listen 8080;

        location /slow/ {
            proxy_pass            http://127.0.0.1:8000/;
            proxy_connect_timeout 5s;
            proxy_read_timeout    10s;
        }
    }
}

The deliberately slow upstream, so you can reproduce this on your own box:

#!/usr/bin/env python3
"""Lab upstream that sleeps 30s before answering. Do NOT ship this — it is
the stand-in for the slow endpoint you are debugging."""
import time
from http.server import BaseHTTPRequestHandler, HTTPServer


class SlowHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        time.sleep(30)
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"slow upstream finally answered\n")

    def log_message(self, fmt, *args):
        print(f"[upstream] {self.client_address[0]} {fmt % args}", flush=True)


if __name__ == "__main__":
    HTTPServer(("0.0.0.0", 8000), SlowHandler).serve_forever()

Run the upstream, start nginx with that config, and abort the request after two seconds — faster than nginx's ten-second read timeout. curl exits 28 (operation timeout) with nothing received; the story is in nginx's logs:

$ curl -s -o /dev/null --max-time 2 http://127.0.0.1:8080/slow/
$ echo "curl rc=$?"        # 28 = aborted client-side

$ tail -1 logs/access.log
127.0.0.1 - - [27/Aug/2026:08:37:00 +0200] "GET /slow/ HTTP/1.1" 499 0 "-" "curl/8.14.1"

Now hold the connection open instead of aborting. nginx's read timeout fires first, and you get the 504 that the 499 was hiding:

$ curl -s -o /dev/null -w 'http=%{http_code}\n' http://127.0.0.1:8080/slow/
http=504

$ tail -1 logs/access.log
127.0.0.1 - - [27/Aug/2026:08:37:15 +0200] "GET /slow/ HTTP/1.1" 504 167 "-" "curl/8.14.1"

$ tail -1 logs/error.log
[error] 97770#97770: *3 upstream timed out (110: Connection timed out) while
reading response header from upstream, ... request: "GET /slow/ HTTP/1.1",
upstream: "http://127.0.0.1:8000/"

Same request, same slow upstream, two different verdicts — the only difference is which timer ran out first. That is the whole mental model for 499: it is whichever timeout in the chain fired earliest, as seen from nginx's side of the connection.

Who actually closed the connection

Work through these in order; the first one that matches your logs is usually the whole story.

  • The upstream was too slow, and the client's timeout fired first. The classic. A browser, mobile app, or API client has its own read timeout (Go's http.Client default, axios, a mobile stack — often 30–60 seconds). nginx is still patiently waiting inside its 60-second default when the client gives up. Your log shows 499s during exactly the endpoints that take longest.
  • A proxy, load balancer, or CDN in front of nginx timed out first. Then the "client" that closed the connection is that middlebox. This is the setup where you see 499s in the origin nginx log while no human was even looking — the LB's own timeout (haproxy's default 60s, an ELB's idle timeout) is shorter than nginx's, so the LB closes, and nginx logs the close as 499. If Cloudflare fronts your nginx and hits its 100-second origin deadline, the origin logs exactly this: a 499 whose client is Cloudflare — the Cloudflare-side story is Cloudflare 524 Error: But the Page Is Working — Why It Happens.
  • Health checks and uptime probes that don't read the response. ELB healthcheckers, monitoring agents, and status pages often open the connection, wait briefly, and hang up. If their probe hits a slow endpoint, nginx logs 499 with a tell-tale user agent like ELB-HealthChecker/2.0. These are the most common "false alarm" 499s.
  • Client-side noise. A user hitting Stop or refresh, a mobile network blip, an antivirus or local proxy aborting a request it decided was suspicious. These show up as scattered 499s from real browser UAs and are usually harmless — unless they cluster on one endpoint, which means that endpoint is too slow for real users too.

How to fix it

Do not start by raising nginx timeouts. Start by making the logs tell you which hop was slow, then remove the slowness or re-time the chain.

Step 1 — log the upstream timing. The default combined format does not include the time nginx waited on the upstream, so a 499 is unreadable. Add a format that records it:

log_format upstream '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '$request_time $upstream_response_time '
                    '"$http_user_agent"';

access_log /var/log/nginx/access.log upstream;

After a burst of 499s, the two numbers on each line tell you who was slow: a large $upstream_response_time (the upstream took long) plus a $status of 499 (the client left before it finished) points squarely at the upstream. A tiny $upstream_response_time with 499 means the client vanished before the upstream even got the request — check your fronting proxy's timeouts and the client stack instead.

Step 2 — fix the timeout chain, in the right order. nginx's defaults, straight from the proxy module docs, are 60 seconds each. The rule: the outermost hop must wait longest, and the innermost shortest.

HopDirective / settingSane value
Client → CDN/LBCDN/LB timeout (Cloudflare, ALB, haproxy)longest, e.g. 120s+
CDN/LB → nginxproxy_read_timeout, proxy_send_timeout60s default, raise only for known-long endpoints
nginx → appproxy_connect_timeout5–10s; if it fires, the app is down, not slow
app → DB/APIapp-level timeoutsshortest, e.g. 5–30s per dependency

If a middlebox times out before nginx, the client sees that middlebox's error page while nginx logs a 499. Shorten nothing arbitrarily — give each hop a distinct value and you will always know which timer fired by which status code appeared.

Step 3 — stop blocking on long work. If a request legitimately takes minutes (exports, reports, image processing), no timeout tuning saves you. Move it to a background queue and have the client poll a status endpoint; the slow work stops sitting in the request path where it generates 499s for everyone who does not wait. For genuinely long synchronous needs, raise proxy_read_timeout on that location only:

location /export/ {
    proxy_pass        http://app:8000;
    proxy_read_timeout 300s;   # long job; everything else keeps 60s
}

Step 4 — keep health checks cheap. Point load-balancer probes at an endpoint that answers in milliseconds, never at a page that touches the database:

location = /healthz {
    access_log off;
    default_type text/plain;
    return 200 "ok\n";
}

When 499 is not a problem

Crawlers that give up on slow pages, uptime probes with short timeouts, and users who bounce all produce 499s without anything being broken. Two habits keep the noise out of your triage: watch for the repeat offenders (a health-check UA appearing constantly means a probe is hitting a slow endpoint — still worth fixing), and filter known probes out of your alerting. The endpoint above already does that at the source with access_log off.

One more trap from my own log: a burst of 499s at the same second, from the same client IP, is usually one client aborting many parallel requests — a mobile app cancelling a batch, or a browser closing a tab full of XHRs. Treat those as one incident, not many.

Key takeaway

499 is never an nginx failure mode. It is the log line nginx writes when it outlived its client, and the client is often not the human — it is a CDN, a load balancer, a health check, or the app's own timeout. Add $upstream_response_time to your log format, order your timeout chain from outside in, and the next 499 will name its own culprit.

Related reading on C2CZ: the Cloudflare 524 post covers the same "who gave up first" question one hop further out, and Turso SERVER_ERROR 404 right after creating a database shows the same discipline applied to a serverless edge failure. More troubleshooting on the C2CZ home page.

Sources

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.