This guide is maintained by Ops Error Atlas from a backend engineering perspective. It favors evidence, command output, and failure-layer separation over broad definitions or blind configuration changes.
How Ops Error Atlas reviews guides499 Client Closed Request is an Nginx-specific access-log status for the case where the downstream client connection closes while Nginx is still processing the request. It is not a standard HTTP status code from the HTTP RFCs, and the client usually does not “receive a 499”. Nginx records 499 because the client side disappeared before Nginx could finish the response path.
The Nginx source defines NGX_HTTP_CLIENT_CLOSED_REQUEST as 499. That fact is simple. The production question is harder:
Who was the "client" from Nginx's point of view, and why did that side close first?
From Nginx’s point of view, the “client” might be:
- a browser tab that navigated away;
- a mobile app with a short deadline or unstable network;
- another backend service with a request context deadline;
- a CDN(内容分发网络)or edge proxy;
- a load balancer(负载均衡器)in front of Nginx;
- an API gateway(API 网关)or service mesh(服务网格)sidecar;
- a health checker, crawler, or synthetic monitor.
Do not write off 499 as “user canceled, not our problem.” A 499 can be the first visible symptom of upstream latency, timeout-budget mismatch, deploy draining, or large-response design problems.
What 499 proves, and what it does not prove
499 proves:
- the downstream connection to Nginx closed before Nginx completed request processing;
- Nginx logged the request from its own perspective;
- the client-side close happened before a normal final response was logged.
499 does not prove:
- a human user intentionally canceled the request;
- the upstream application was healthy;
- Nginx caused the problem;
- the request was harmless;
- increasing Nginx timeouts will help.
If the upstream was slow for 30 seconds and the load balancer in front of Nginx gave up at 30 seconds, Nginx may log 499. The access-log status says who closed first; it does not identify the original bottleneck.
Place 499 in the request timeline
A proxied request can fail at different moments:
client sends request
-> Nginx receives request
-> Nginx sends request to upstream
-> upstream processes dependencies
-> upstream sends response headers
-> upstream sends response body
-> Nginx sends response to client
499 can appear in multiple phases:
| Phase | What it suggests | First branch |
|---|---|---|
| Before upstream is contacted | caller abort, bad upload, front proxy timeout | client/proxy behavior, request body timing |
| While request body is being read | slow upload or client disconnect | upload size, client network, client_body_timeout |
| While Nginx waits for upstream headers | upstream handler latency | app queueing, dependency latency, worker saturation |
| While response body is being sent | slow client, large response, streaming idle gap | bytes sent, buffering, client bandwidth, stream design |
| Near a repeated duration | timeout boundary | client, CDN, load balancer, gateway, service client deadlines |
| During deployments | lifecycle/draining issue | readiness removal, connection draining, worker shutdown |
The phase decides the owner. A low-volume browser navigation cancel is normal noise. A high-volume 499 spike on one API route with high upstream header time is a backend latency incident with a client-side close symptom.
Add log fields that make 499 useful
Default access logs are rarely enough. Add fields that tell you request duration, upstream timing, bytes sent, request size, source identity, and completion state.
log_format client_abort_debug
'$remote_addr request_id=$request_id '
'"$request" status=$status completion="$request_completion" '
'rt=$request_time bytes=$body_bytes_sent req_len=$request_length '
'uaddr="$upstream_addr" ustatus="$upstream_status" '
'uct="$upstream_connect_time" uht="$upstream_header_time" urt="$upstream_response_time" '
'xff="$http_x_forwarded_for" ua="$http_user_agent" '
'host="$host" uri="$request_uri"';
access_log /var/log/nginx/access.log client_abort_debug;
Read the fields together:
$request_timeshows how long Nginx processed the request before logging it.$request_completionisOKwhen a request completes, and empty when it does not complete normally.$body_bytes_senttells you whether Nginx sent little, partial, or substantial response body data.$request_lengthhelps identify large uploads or large request headers.$upstream_statusshows whether Nginx received an upstream status.$upstream_header_timepoints to app/dependency delay before headers.$upstream_response_timepoints to total upstream response exchange.$upstream_addrlets you detect one sick upstream peer.$http_x_forwarded_forand$http_user_agenthelp separate user traffic, health checks, crawlers, gateways, and service clients.
NGINX documents upstream timing variables as seconds with millisecond resolution, and multiple upstream attempts may appear as comma-separated values. Treat the log line as evidence, not a single magic number.
First triage: group before guessing
Start with simple grouping:
grep ' 499 ' /var/log/nginx/access.log | tail -50
Group by path for a combined-style log:
awk '$9 == 499 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
Group by source:
awk '$9 == 499 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
If your log contains request_time=..., look for repeated boundaries:
grep ' 499 ' /var/log/nginx/access.log \
| grep -o 'rt=[0-9.]*' \
| sort | uniq -c | sort -nr | head
Repeated durations near 10s, 30s, 60s, or another configured value are a signal that some caller, load balancer, gateway, or proxy timeout is closing first. It is not proof until you match the configured timeout for that layer.
Compare downstream timing with upstream timing
Use the log fields to pick a branch:
| Evidence | Strong suspect | What to verify |
|---|---|---|
status=499, high $upstream_header_time | upstream slow before headers | app handler, queueing, DB/cache/API latency |
status=499, high $upstream_response_time, high body_bytes_sent | large or streaming response | body transfer, buffering, client bandwidth, idle gaps |
status=499, low upstream time, high $request_time | downstream transfer/client path | slow client, front proxy, bandwidth, response size |
status=499, no upstream status | client closed before upstream response | upload abort, early client timeout, routing before proxy |
one $upstream_addr dominates | bad upstream peer | node health, deploy, restart, CPU/memory/network |
| one source IP or proxy dominates | front-door behavior | load balancer logs, gateway timeout, crawler/health checker |
| durations cluster at one value | timeout boundary | caller/client/LB/gateway timeout settings |
If upstream timing is high, do not call it “just a client abort.” The client closed first because the server path took longer than the caller was willing to wait.
Timeout budget alignment
499 frequently appears when the outer caller’s timeout is shorter than the inner work.
Bad example:
mobile client timeout: 10s
load balancer timeout: 30s
Nginx proxy_read_timeout: 60s
app handler deadline: 90s
database client timeout: 120s
This budget makes 499 likely. The client may give up at 10s, Nginx logs 499, and the application may keep working against the database long after the user is gone.
Better shape for normal interactive APIs:
database client timeout: 2s
app handler deadline: 3s
Nginx proxy_read_timeout: 5s
load balancer timeout: 10s
client timeout: 15s
These numbers are examples, not universal recommendations. The principle is that inner layers should usually fail with useful evidence before outer layers silently abandon the request. Long-running endpoints should have explicit contracts and separate routes, not hidden dependence on global timeouts.
Know what Nginx proxy timeouts mean
For proxied requests:
| Directive | What it controls |
|---|---|
proxy_connect_timeout | time to establish the upstream connection |
proxy_send_timeout | timeout between successive write operations to upstream |
proxy_read_timeout | timeout between successive read operations from upstream |
send_timeout | timeout between successive write operations to the downstream client |
Important detail: proxy_read_timeout is not a whole-response deadline. NGINX documents it as a timeout between successive read operations from the proxied server. A stream can run longer than the numeric value if it keeps sending data. It can also fail if it goes silent longer than the value.
For 499, inspect both sides:
- upstream timers explain whether the backend was slow;
- downstream/client timers explain why the client side closed first.
proxy_ignore_client_abort is not a blanket fix
NGINX documents proxy_ignore_client_abort as controlling whether the upstream connection is closed when the client closes the connection without waiting for a response.
This directive changes what Nginx does after the downstream client has gone away. It can be useful in selected workflows, but it is not a generic “fix 499” switch.
Before enabling it, answer:
- Should the upstream continue work after nobody is waiting for the response?
- Is the operation idempotent or safe to complete without a live client?
- Could client-abort spikes amplify backend load?
- Does the application already support cancellation?
- Would a background job queue be a cleaner design?
For most interactive APIs, continuing work after the caller is gone wastes capacity. For some fire-and-forget ingestion, export preparation, or async workflows, continuing may be intentional. Make that design explicit and route-specific.
Case 1: Normal browser or user cancellation
Some 499s are normal.
Strong signals:
- low volume and stable baseline;
- many unrelated URLs;
- short request times;
- little or no upstream involvement;
- user agents look like browsers;
- no correlation with latency, deploys, or errors.
Response:
- measure the baseline;
- avoid paging on normal low-rate 499s;
- keep enough logging to detect spikes;
- focus alerts on volume, endpoint concentration, latency correlation, and user impact.
Do not spend engineering time eliminating every 499. Spend it explaining spikes and high-impact clusters.
Case 2: Upstream latency causes callers to give up
This is the important production case.
Strong signals:
- one endpoint dominates 499 volume;
$upstream_header_timeis high;- app logs show slow handler or dependency waits;
- request time clusters near a client/LB/gateway timeout;
- 499s appear near 504s for the same route.
Fix path:
- Add request tracing inside the app.
- Break handler time into queue wait, auth, DB/cache, downstream API, render/serialization.
- Set dependency deadlines below the app handler deadline.
- Return controlled app errors before outer callers give up.
- Reduce fan-out, cache safely, paginate, or move long work async.
- Align client, load balancer, Nginx, app, and dependency timeouts.
The goal is not to hide 499. The goal is to stop doing expensive work after callers have already left.
Case 3: Front-door proxy or load balancer closes first
Strong signals:
- one source IP range dominates;
X-Forwarded-Foror headers identify a CDN, load balancer, gateway, or service client;- request times cluster exactly near a configured front-door timeout;
- Nginx is still waiting on upstream or sending response when the close happens.
Fix path:
- inspect front proxy or load balancer logs;
- confirm its idle and request timeout settings;
- compare with Nginx and app deadlines;
- make inner deadlines shorter than the front-door timeout;
- avoid raising only Nginx timeouts if the front door still closes first.
If the load balancer gives up at 30s and Nginx waits 60s, Nginx’s longer timeout does not help the user. It only lets the upstream keep working longer after the caller is gone.
Case 4: Large response, download, export, or stream
Large bodies and streams need separate handling because the request may start normally and fail during response transfer.
Strong signals:
- high
body_bytes_sent; - response size is large or unbounded;
- failures happen on exports, downloads, report generation, or streaming endpoints;
$upstream_header_timeis low but$upstream_response_timeor$request_timeis high;- clients disconnect after partial transfer.
Fix path:
- use pagination for large lists;
- move long exports to background jobs and downloadable artifacts;
- support resumable downloads where appropriate;
- send deliberate heartbeat chunks for streaming protocols that need to stay idle-safe;
- align downstream idle timeouts with stream behavior;
- propagate cancellation so expensive upstream work stops when the client leaves.
Do not solve a product-flow problem only by raising proxy timeouts.
Case 5: Upload or request-body abort
499 can happen before Nginx ever receives a full request body.
Strong signals:
- high
request_lengthvariance; - upload endpoints dominate;
- client networks are mobile or unreliable;
- error occurs before upstream status appears;
- Nginx error logs mention client request body read issues.
Fix path:
- enforce explicit upload size limits;
- use direct-to-object-storage upload patterns for large files;
- surface upload progress and retry behavior to clients;
- tune
client_body_timeoutonly with evidence; - avoid sending large uploads through latency-sensitive API workers.
Upload aborts are not the same as upstream slowness. Separate them in dashboards and logs.
Case 6: Deploy-time 499
499 spikes around deploys usually mean the lifecycle budget is wrong.
Check:
load balancer drain timeout
readiness removal timing
pod termination grace period
application graceful shutdown
Nginx worker shutdown behavior
long-running request duration
Typical failure shape:
old worker still has active requests
deployment starts termination
front proxy or client connection closes
Nginx logs 499 or related upstream errors
upstream work may be interrupted or continue uselessly
Fix path:
- fail readiness before stopping request acceptance;
- drain load balancer targets before killing workers;
- let in-flight requests finish within a controlled grace period;
- log shutdown reason and interrupted request IDs;
- avoid deploying in a way that truncates long-running responses.
Decision tree
Nginx logs 499
|
+-- low-volume, random browser paths, short duration?
| |
| +-- likely normal user cancellation; track baseline, avoid noisy alerts
|
+-- one endpoint dominates and upstream_header_time is high?
| |
| +-- upstream latency; inspect app handler and dependencies
|
+-- request_time clusters at a timeout boundary?
| |
| +-- identify caller/LB/CDN/gateway timeout that closes first
|
+-- body_bytes_sent is high or route is stream/export/download?
| |
| +-- inspect body transfer, buffering, stream idle gaps, cancellation
|
+-- request_length is high or upload route dominates?
| |
| +-- inspect upload flow, client_body_timeout, request buffering
|
+-- deploy window correlation?
|
+-- inspect draining, readiness, graceful shutdown, termination grace
What not to do
- Do not treat every 499 as harmless user cancellation.
- Do not treat every 499 as an Nginx bug.
- Do not assume the human browser is the direct client; it may be a proxy.
- Do not raise Nginx timeouts while the load balancer still closes earlier.
- Do not use
proxy_ignore_client_abortglobally without capacity and correctness analysis. - Do not keep expensive upstream work running after caller cancellation unless the workflow requires it.
- Do not alert on raw 499 count without grouping by endpoint, source, duration, and user impact.
Minimal incident note template
Symptom:
- time window:
- affected route:
- 499 count / baseline:
- user impact:
Nginx evidence:
- sample request_id:
- request_time:
- request_completion:
- body_bytes_sent:
- request_length:
- upstream_addr:
- upstream_status:
- upstream_connect_time:
- upstream_header_time:
- upstream_response_time:
- remote_addr / x_forwarded_for:
- user_agent:
Timeout budget:
- client/service caller:
- CDN / load balancer:
- Nginx:
- app handler:
- dependency clients:
Correlation:
- deploy event:
- upstream latency:
- response size:
- upload size:
- source IP/client type:
Hypothesis:
- who closed first:
- why:
- evidence:
Fix:
- code/config/product-flow change:
- validation:
The incident is understood only when you can explain both sides: why the downstream client closed, and what Nginx/upstream was doing at that moment.
References
- Nginx source:
NGX_HTTP_CLIENT_CLOSED_REQUEST 499 - NGINX logging guide and upstream timing variables
- NGINX core module variables, including
$request_timeand$request_completion - NGINX
proxy_ignore_client_abortdirective - NGINX
proxy_read_timeoutdirective - NGINX
send_timeoutdirective
Related errors
Move laterally when the first symptom points to adjacent network failures.
Why "nginx upstream timed out" happens
A practical guide to Nginx upstream timed out errors that separates connect, send, and read timeouts from slow apps, dependency latency, and overloaded upstream workers.
Read guideHow to debug 504 Gateway Timeout between Nginx and upstream services
A practical Nginx 504 troubleshooting guide that separates slow upstream code, dependency latency, connection problems, and unsafe timeout changes.
Read guideHow to fix Nginx "client intended to send too large body"
A practical Nginx client intended to send too large body guide that separates upload limits, route-specific sizing, buffering, app limits, ingress limits, and unsafe global increases.
Read guideHow to fix "broken pipe" in Linux applications
A practical guide to broken pipe errors in Linux services, focused on finding who closed first, which write path failed, and how proxies, clients, and streaming responses trigger it.
Read guideWhy "upstream prematurely closed connection" happens in Nginx
A practical guide to Nginx upstream prematurely closed connection errors, focused on proving whether the app, proxy, keepalive reuse, or transport layer closed first.
Read guideKeep one representative log line, the failing source and destination, the command output you used, and the verification command after the change. This makes the result reproducible and helps separate temporary recovery from a proven fix.
Browse related error guides