What does nginx 499 mean, and what is the same event called in HAProxy, Envoy and AWS ALB?
499 means the client hung up before nginx sent a response header. The ALB 460, HAProxy CH and Envoy DC equivalents, and how to tell which of four causes you have.
Key points
- 499 is nginx's own log code for "client closed request": the client closed the connection before nginx sent it a response header. It is never sent on the wire, because there is nobody left to send it to. It is
NGX_HTTP_CLIENT_CLOSED_REQUESTin the nginx source and appears nowhere in the nginx.org directive documentation. - The same event is 460 on an AWS ALB, a
CH--orCD--termination state on HAProxy (logged with status 400 on 3.2.25 in testing), and status 0 with flagDCand detailsdownstream_remote_disconnecton Envoy. - Find the cause by asking what the next hop out logged at the same moment. A 504 there is a timeout ladder inverted at that hop. A 460 or nothing at all is the real client leaving. A health checker user agent is a probe timeout. 499s piling up at exactly 60s behind an ALB are its idle timeout.
- A client that leaves after the response header has gone out is logged as 200 with a short
$body_bytes_sent, not 499, so 499 undercounts abandonment. proxy_ignore_client_abort(defaultoff) decides whether nginx closes the upstream connection. Closing it does not stop most synchronous application code, but it does cancel a Go handler's request context. Turn it on where half-finished non-idempotent work is worse than wasted work, and expect 499s to vanish from the access log when you do.
A 499 in the nginx access log means the client closed its connection before nginx had sent it a response header. It is not an HTTP status anyone receives: nginx invented the number to record the event, defines it in its source as NGX_HTTP_CLIENT_CLOSED_REQUEST, and by default also closes the connection to the upstream that was working on the request. The same event is called 460 on an AWS Application Load Balancer, appears as a CH or CD termination state on HAProxy, and is logged by Envoy as response code 0 with the flag DC. The "client" is whatever sits directly in front of nginx, often another proxy whose own timer fired, so a 499 is a question about the hop outside nginx.
There are four common causes, and the next hop out tells them apart:
| What the hop in front of nginx logged at the same moment | $request_time of the 499s | Cause | Fix belongs in |
|---|---|---|---|
| A 504 of its own | Clustered at one round value (15s, 30s, 60s) | Timeout ladder inverted at that hop | The outer hop's timeout, or the slow endpoint |
An ALB 504 with target_processing_time of -1 | Clustered at the ALB idle timeout (60s by default) | Outer load balancer idle timeout | The idle timeout, or make the endpoint send bytes sooner |
| An ALB 460, or no proxy in front at all | Spread out, or clustered at a client SDK's timeout | The real client gave up: a user, a mobile app, a script with --max-time | The endpoint's latency, or the client's timeout |
| Nothing, and the user agent is a health checker | Clustered at the probe timeout (Kubernetes defaults to 1s) | Health check timeout shorter than the endpoint | The probe timeout, or a cheaper health endpoint |
Where 499 is defined#
The nginx.org documentation for the proxy, upstream, core and log modules never mentions 499. The definition is in src/http/ngx_http_request.h, among nginx's private codes (444, 494 to 497, 499), with the reason in a comment:
/*
* HTTP does not define the code for the case when a client closed
* the connection while we are processing its request so we introduce
* own code to log such situation when a client has closed the connection
* before we even try to send the HTTP header to it
*/
#define NGX_HTTP_CLIENT_CLOSED_REQUEST 499499 is only logged if nothing had been sent yet. ngx_http_terminate_request() in ngx_http_request.c overwrites the logged status with 499 only when headers_out.status == 0 || connection->sent == 0. Once the response header has gone out, the status stays at whatever the upstream sent. In a test on nginx 1.30.5 with proxy_buffering off, a client that disconnected 1.5 seconds into a streamed 200 response was logged as:
200 GET /stream rt=1.500 urt=1.500 us=200 bbs=22A 200 with 22 bytes of body, not a 499. Abandoned downloads and streams never show up as 499, so the 499 count undercounts abandonment.
The error log is silent by default. nginx does write a line for the event, client prematurely closed connection, so upstream connection is closed too, but at the info level. With the usual error_log ... error or warn, a 499 has an access log line and nothing else.
The same event in HAProxy, Envoy and AWS ALB#
502 vs 503 vs 504 maps the server-side failures across proxies; this is the client-side counterpart. The nginx, HAProxy and Envoy columns were observed with an HTTP/1.1 client that disconnected one second into a request whose upstream took three seconds. The ALB column is from AWS's documentation.
| nginx 1.30.5 | HAProxy 3.2.25 | Envoy 1.36.12 | AWS ALB | |
|---|---|---|---|---|
| Status in the access log | 499 | 400 | 0 | 460 |
| Where the cause is named | The status itself; info line in error_log | Termination state CH-- (client closed while waiting for the response) or CD-- (client reset) | %RESPONSE_FLAGS% DC, %RESPONSE_CODE_DETAILS% downstream_remote_disconnect | elb_status_code 460 |
| What the upstream timing field shows | $upstream_status -, $upstream_response_time equal to how long the client waited | Tr of -1 | %UPSTREAM_HOST% still logged | Not documented for 460 |
| What happens to the upstream connection | Closed, unless proxy_ignore_client_abort on | Closed with abortonclose or a client reset; kept open after a plain FIN without it | Closed | Not documented |
| Setting that changes it | proxy_ignore_client_abort | option abortonclose | none for this case | none |
AWS ALB. The troubleshooting guide defines 460 as: "The load balancer received a request from a client, but the client closed the connection with the load balancer before the idle timeout period elapsed." Its advice is to "check whether the client timeout period is greater than the idle timeout period for the load balancer", which is the ladder rule from the client's side. A client that leaves mid-upload is a 400 instead (see failure modes).
Envoy. The formatter documentation says "a response code of 0 means that the server never sent the beginning of a response. This generally means that the (downstream) client disconnected", and DC is "Downstream connection termination". downstream_remote_disconnect is listed in the response code details as "The client disconnected unexpectedly". A dashboard that buckets statuses by first digit drops these entirely. Where the flags fit in the wider access log is covered in Envoy listeners, routes and clusters.
HAProxy. The manual describes CH as "The client aborted while waiting for the server to start responding. It might be the server taking too long to respond or the client clicking the 'Stop' button too fast", and CD as "The client unexpectedly aborted during data transfer". The 502 vs 503 vs 504 page decodes cD, the lowercase timeout form; this event is the uppercase abort form. The test found something the manual does not spell out:
On 3.2.25 the option ended a request already waiting for the backend's response, although its description mentions only the queue and connection establishment. Configuration context is in HAProxy configuration explained.
Reading a 499 from nginx's own fields#
Add three variables to the access log and every 499 says when it happened relative to the upstream:
log_format timing '$remote_addr "$request" $status rt=$request_time '
'urt=$upstream_response_time us=$upstream_status '
'bbs=$body_bytes_sent ua="$http_user_agent"';
access_log /var/log/nginx/access.log timing;| Fields on the 499 line | Meaning |
|---|---|
us=- and urt close to rt | The client left while nginx was waiting for the upstream's response header. The common case. |
urt=- | The client left before nginx had an upstream connection. The source finalizes with 499 in that case too. |
rt the same value on most lines | Something with a fixed timer closed the connection. Look for that number in the hop in front. |
rt spread across a range | Real clients giving up at different moments. |
Then histogram the request times of the 499s:
grep '" 499 rt=' /var/log/nginx/access.log | grep -o 'rt=[0-9]*' | sort | uniq -c | sort -rn | headA single tall bar at 60, 30 or 15 seconds is a timer, and the number names the hop. A long flat tail is people.
Decision tree: which of the four causes#
1. A timeout ladder inverted at the hop in front#
The hop in front of nginx (an Envoy sidecar, an ingress, a CDN, another nginx) has a request timeout shorter than the time nginx is willing to wait for the upstream. When its timer fires it returns a 504 to its own client and closes the connection to nginx, and nginx records that close as 499. The pairing is exact: a 504 in the outer log and a 499 in the nginx log for the same request, with the 499's $request_time equal to the outer hop's timeout. Nothing inside reports a timeout, because nothing inside timed out.
The usual offender is an Envoy route timeout at its 15 second default in front of nginx's 60 second proxy_read_timeout (the worked example below is exactly this). The fix is the rule in timeout budgets across a proxy chain: every hop must time out before the hop outside it. The timeout ladder checker finds the inverted pair from a list of values.
2. An outer load balancer's idle timeout#
An ALB's timer is an idle timeout, idle_timeout.timeout_seconds, which "the default is 60 seconds" in AWS's attribute reference. It fires when no bytes move in either direction. An endpoint that computes for 61 seconds before sending its first byte gets a 504 from the ALB, and the ALB access log documents this case: target_processing_time "can also be set to -1 if the registered target does not respond before the idle timeout". nginx, as the target, sees the ALB close the connection and logs 499 at about 60.0 seconds.
This is cause 1 with an idle timer instead of a request timer, which changes the fix: an idle timer can be satisfied without making the endpoint faster, by sending the response header early and streaming the body. Raising the idle timeout also works. The same timer closes quiet WebSockets and long polls, covered in WebSockets through proxies.
3. A client that gave up#
The browser user who closed the tab, the mobile SDK with a 10 second timeout, the cron job running curl --max-time 5. Behind an ALB these appear as 460 at the same moment, which is what separates them from cause 2: the ALB recorded that its client left, not that its own timer fired. Without a load balancer, the user agent and the shape of the $request_time distribution are the evidence. A cluster at a value no proxy in your chain uses is a client library's timeout.
The fix is the endpoint's latency, not configuration. A rising 499 rate on one endpoint is a breach the application's own metrics may not show, because the application often completes the request afterwards and records a normal duration.
4. A health checker with a short timeout#
A probe that times out before the health endpoint answers closes the connection, and nginx logs a 499 for it. The user agent identifies it: an ALB health check sends User-Agent: ELB-HealthChecker/2.0, according to AWS's troubleshooting guide. Kubernetes probes have a timeoutSeconds that defaults to 1 second, so a readiness endpoint that checks a database and occasionally takes 1.2 seconds produces a steady trickle of 499s at rt=1.0, each one a failed probe. Raise the probe timeout or make the endpoint answer from cached state. Health checks and upstream failover covers what a health endpoint should check.
proxy_ignore_client_abort: what it actually stops#
The nginx documentation is one sentence: the directive "determines whether the connection with a proxied server should be closed when a client closes the connection without waiting for a response". The default is off, so nginx closes it. In the source, ngx_http_upstream_check_broken_connection() does the closing, and it skips the close while a cacheable response is being fetched, so a proxy_cache fill continues. What closing the connection does to the application depends on whether the application is watching the socket.
A synchronous handler does not notice. In the test, a Python http.server handler sleeping for three seconds ran to completion after nginx closed the connection at one second, then failed with [Errno 32] Broken pipe when it tried to write the response. Any handler that never checks the socket behaves this way: the work completes, only the reply is lost.
A handler tied to the connection is cancelled. Go's net/http documents that "for incoming server requests, the context is canceled when the client's connection closes". A handler that passes r.Context() to its database calls has those calls cancelled when nginx closes the upstream connection, so a multi-step write can stop between steps.
That gives the rule for turning it on:
| Upstream behaviour | proxy_ignore_client_abort off (default) | on |
|---|---|---|
| Idempotent reads | Frees the upstream connection early. Keep the default. | Wasted work on abandoned requests |
| Non-idempotent work, handler ignores disconnects | Work completes anyway; only the response is lost | Same work, and the access log shows the real result |
Non-idempotent work, handler cancels on disconnect (Go r.Context()) | Work can stop half done | Work completes. Turn it on here, or stop deriving the work's context from the request |
| Expensive work during a client retry storm | Cancellable handlers shed the load | Every abandoned request keeps running while its retry arrives |
The trade-off is visibility. With on, nginx logs the status the upstream eventually returned. The test POST that the client abandoned at one second was logged as:
200 POST /keep/3 rt=3.002 urt=3.002 us=200 bbs=3A 200, with 3 bytes "sent" to a client that had already gone. Turning the directive on makes that location's 499s disappear, so an alert on the 499 rate goes quiet without anything having improved. The more durable fix for the half-done write is in the application: an idempotency key, or one transaction that either commits or does not.
Worked example: Envoy in front of nginx#
client -> Envoy (route timeout: default 15s) -> nginx (proxy_read_timeout 60s) -> appAn export endpoint takes 20 seconds. This is the run on Envoy 1.36.12 with no timeout set on the route, nginx 1.30.5 with the timing format above, and a Python upstream that sleeps for 20 seconds:
client: upstream request timeout (504 after 14.999535s)
Envoy: GET /export/20 504 UT response_timeout dur=14997 up=10.201.6.3:80
nginx: 10.201.6.4 "GET /export/20 HTTP/1.1" 499 rt=14.997 urt=14.997 us=- bbs=0
app: START GET /export/20
DONE GET /export/20
WRITE FAIL [Errno 32] Broken pipeThe client sees a 504 from Envoy. nginx's 499 at 14.997 seconds names Envoy's 15 second default. The application finished the work five seconds later and failed only on the write. Either set the route timeout above the endpoint's latency and above nginx's timeout, or lower proxy_read_timeout below 15 seconds so that nginx gives up first and its error log names the slow upstream.
Failure modes#
A client that gave up during an upload is not a 499. With request buffering on (the default), nginx reads the whole body before it contacts the upstream. A test client that sent 100 of 1000 declared body bytes and closed was logged by nginx 1.30.5 as 400 POST ... urt=- us=-, with an info line client prematurely closed connection and no so upstream connection is closed too. AWS lists "the client closed the connection before sending the full request body" as a cause of ALB 400, so the ALB does the same. Abandoned uploads therefore hide among malformed requests in both logs.
499s disappeared and nobody fixed anything. Someone set proxy_ignore_client_abort on, or a HAProxy in front of nginx now serves aborted requests to completion. Check the outer hop's 504 or 460 counts instead.
For anything that does not fit these shapes, the proxy debugging playbook bisects the chain hop by hop.
Where this answer stops applying#
The nginx, HAProxy and Envoy observations come from nginx 1.30.5, HAProxy 3.2.25 and Envoy 1.36.12 in their official container images, with HTTP/1.1 clients and a Python upstream. Over HTTP/2 the client cancels one stream rather than closing the connection, and nginx handles that in its HTTP/2 module rather than in the broken connection check described here (the check returns early for HTTP/2 streams); the status logged was not tested for that case. The ALB rows are from AWS documentation, not a test. The HAProxy behaviour was observed on one release only; check CH and abortonclose on your own version before relying on it.
Frequently asked questions#
What does status 499 mean in nginx?#
It means the client closed the connection before nginx sent a response header. nginx logs 499 (client closed request) for this and sends nothing, because the connection is already gone. It is a non-standard code defined in the nginx source as NGX_HTTP_CLIENT_CLOSED_REQUEST.
Is 499 a client error or a server error?#
It records a client action, but the cause is often on the server side. A client usually gives up because a response was slow, and the "client" is often a proxy or load balancer in front of nginx whose own timeout fired. Check what the hop in front logged before deciding.
What is the AWS ALB equivalent of nginx 499?#
HTTP 460. AWS documents it as the client closing the connection with the load balancer before the idle timeout period elapsed. A client that closes while still sending the request body is logged as 400 instead.
What is the difference between nginx 499 and 444?#
444 is nginx closing the connection without a response, because the configuration told it to (return 444). 499 is the client closing the connection first. Both are nginx-only log codes that no client ever receives.
Should I turn on proxy_ignore_client_abort?#
Only on locations that do non-idempotent work in an application that cancels on disconnect, such as a Go handler using the request context for its writes. Elsewhere the default off is right. With it on, abandoned requests are logged with the upstream's status instead of 499.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- nginx source: src/http/ngx_http_request.h (NGX_HTTP_CLIENT_CLOSED_REQUEST 499)
- nginx source: src/http/ngx_http_upstream.c and src/http/ngx_http_request.c (broken connection check, terminate request)
- nginx ngx_http_proxy_module: proxy_ignore_client_abort
- AWS: Troubleshoot your Application Load Balancers (HTTP 460, 408, 504)
- AWS: Application Load Balancer attributes (idle_timeout.timeout_seconds)
- AWS: Access logs for your Application Load Balancer
- HAProxy configuration manual: termination states, option abortonclose
- Envoy access logging
- Envoy substitution formatter: %RESPONSE_CODE%, response flags
- Envoy response code details
- Go net/http: Request.Context
- Kubernetes: Configure liveness, readiness and startup probes
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.