Evomi

Blog / Error Resolution

HTTP 499 Client Closed Request: What It Means and How to Fix It

Satyam TripathiSatyam Tripathi25 min read
A small round teal-and-cream robot reaches up to pull a grey roller shutter down over a green service hatch under a 499 sign, already walking away, while a parcel tied with string still sits on a roller conveyor inside, on a sunny cobbled square with olive trees, crates and a street lamp.

The 499 error code, "client closed request", is the status nginx logs when the client side closes the connection before nginx sends the response headers. nginx writes this non-standard code only to its log, never to the client.

A 499 looks like the client's fault, even when a timeout in your own stack caused it. In our test behind Cloudflare, the visitor got a timeout page, nginx logged 499, and the app kept building a response nobody received.

This guide shows how to read the log line, find which timeout expired, and fix it, including in scrapers on residential proxies.

TL;DR

  • Group your 499s by request time first. A cluster shows which timeout expired, while scattered request times are normal.
  • The first timeout to expire decides the status code. If the client, CDN, or load balancer disconnects first, nginx logs 499. If nginx times out first, the client gets a 504.
  • The backend keeps working unless it checks for the client disconnect. Flask, FastAPI, Node, and Go handlers without that check finished every abandoned request.
  • When scraping through a proxy, set its idle timeout above your slowest page's time to first byte. On Evomi Core Residential, _maxtimeout- sets it per request.

How to Read a 499 in the nginx Access Log

When the client closes the connection, nginx also closes its connection to the upstream, your app server, by default (proxy_ignore_client_abort off). Add timing variables to log_format in the http block:

Plain Text
log_format timing '$remote_addr xff=$http_x_forwarded_for "$request" '
                  '$status $body_bytes_sent rt=$request_time '
                  'urt=$upstream_response_time us=$upstream_status '
                  'done=$request_completion';
access_log /var/log/nginx/access.log timing;

If you change the format only once, also add rid=$request_id and realip=$realip_remote_addr now, because later sections use them. With the format above, an abandoned request looks like this:

Plain Text
192.168.155.1 xff=- "GET /sleep?s=5 HTTP/1.1" 499 0 rt=2.010 urt=2.010 us=- done=

Each label in this line stands for an nginx variable:

  • $request_time (rt=) is how long the client waited before it closed the connection.
  • $upstream_response_time (urt=) is how long nginx waited for the upstream.
  • $upstream_status (us=) is -, because the upstream had not responded when the client closed the connection.
  • $request_completion (done=) is empty, because nginx sets it to OK only for completed requests.
  • $http_x_forwarded_for (xff=) holds the original client IP when a CDN or load balancer in front of nginx adds X-Forwarded-For. The variable shows - when nothing adds the header.

Read together, this line says the client closed the connection after about 2 seconds, while the upstream was still working. Many lines with the same value point to a 2-second client timeout.

nginx also logs the reason, "client prematurely closed connection", but at the info level, which the default error log level leaves out. To see it while you investigate, set error_log /var/log/nginx/error.log info;.

Why You See 499 in Your Logs

Every 499 means the client side closed the connection before nginx responded. The client side is the client itself, or a hop in front of nginx, such as a CDN or load balancer.

Cause

Who controls it

How to tell from the log

Client timeout shorter than your response time

The client developer

499s from clients that connect directly cluster at one $request_time, such as 10.0 or 30.0

A user closes the tab or the app aborts the request

The user or the front end

$request_time values vary widely

A load balancer, CDN, or reverse proxy in front of you times out

Whoever runs that hop

499s cluster at the hop's timeout value, across all user agents

A slow upstream

You

Your p99 $upstream_response_time is above common client timeouts

A client half-closes its socket (shutdown() on its write side) after it sends the request

The client developer

$request_time is 0.000, although the client was still waiting

An HTTP/2 client cancels one stream (one request on a shared connection) with RST_STREAM

The client or browser

499 on that request, while the connection stays open

Behind a CDN or load balancer, two kinds of timeout produce a cluster, because the hop adds little latency:

  • The hop's own timeout clusters at the hop's documented value, across all user agents.
  • A client's timeout clusters just below the client's value, with that client's user agent. This needs a hop that closes its connection to nginx as soon as the client leaves, as Cloudflare did in our test.

Behind a hop whose latency varies per request, a client's timeout produces no cluster. Its 499s spread below the client's timeout.

A 499 with a $request_time near 0 shows a request canceled before the app received it. On the client side, a canceled fetch() has no status code. It rejects with an AbortError, or with a TimeoutError if AbortSignal.timeout() canceled it. Chrome's Network panel shows it as (canceled):

Chrome DevTools Network panel listing 20 fetch requests to /api/report, each with the status (canceled)

In production, check a cluster against platform defaults first. Cloudflare's 524 documentation lists 125 seconds for its proxy read timeout. Older guides say 100 seconds, so trust the current documentation. Behind Cloudflare, if your app sends nothing for that long and nginx's proxy_read_timeout is longer, the visitor gets Cloudflare's 524 error page. At the same moment, nginx logs 499 for that request, with a $request_time of about 125 seconds. So one request ends with two status codes, one at each end:

One request behind Cloudflare, where the client gets a 524, Cloudflare times out at 125 seconds, nginx logs 499, and the app keeps working

Each documented default timeout below produces a 499 cluster at its value:

$request_time cluster

Likely timeout

What the client got

What to change

300 s

Node's built-in fetch, which waits 300 seconds for response headers

A client-side timeout (UND_ERR_HEADERS_TIMEOUT)

headersTimeout on an undici Agent

300 s

The default total timeout of aiohttp, common in async Python scrapers

A client-side timeout

ClientTimeout(total=...) on the session

180 s

Scrapy's DOWNLOAD_TIMEOUT default

A client-side timeout

DOWNLOAD_TIMEOUT in the settings, or download_timeout per request

125 s

Cloudflare proxy read timeout

524

Run long work as a background job, or increase Proxy Read Timeout on an Enterprise plan

60 s

AWS Application Load Balancer (ALB) idle timeout

504 from the load balancer

The ALB's idle_timeout.timeout_seconds

60 s

An iOS app on the URLSession default

A client-side timeout (NSURLErrorTimedOut)

timeoutIntervalForRequest on the session configuration

60 s

An AI agent on the Model Context Protocol (MCP) TypeScript SDK's default request timeout

A client-side timeout

The MCP request timeout option

30 s

Amazon CloudFront origin response timeout. For GET and HEAD requests, it makes up to 3 attempts by default, so one request can log three 499s

504

The origin's Response timeout, or run long work as a background job

30 s

Google Cloud Application Load Balancer backend timeout

502

The backend service's timeoutSec

30 s

Heroku's router

503 (error H12)

Run long work as a background job

29 to 30 s

Amazon API Gateway (29 s for REST APIs, 30 s for HTTP APIs)

504

Run long work as a background job, or request a higher integration timeout quota for a Regional or private REST API

20 s

Azure Application Gateway request timeout

504 (502 on the v1 SKU)

The request time-out in the backend settings

15 s

Envoy's default route timeout, such as in Envoy Gateway when the HTTPRoute rule sets no timeout

504 with upstream request timeout

timeouts.request on the HTTPRoute rule

10 s

OkHttp's read timeout in an Android app

A client-side timeout (SocketTimeoutException)

readTimeout() on the OkHttpClient builder

5 s

The default timeout of httpx, a common Python client

A client-side timeout (ReadTimeout)

timeout= on the httpx client

5 s, on the health-check path

AWS Application Load Balancer health check timeout for instance and IP targets

A failed health check. After repeated failures, the load balancer marks the target unhealthy

Keep the health endpoint fast, or increase HealthCheckTimeoutSeconds

1 s, on the health-check path

Kubernetes liveness or readiness probe timeout, when nginx runs in the pod

A failed probe. Failed liveness probes restart the container, and failed readiness probes remove the pod from Service endpoints

Keep the health endpoint fast, or increase the probe's timeoutSeconds

Python Requests and Go's http.Client have no default timeout, so a cluster from either one comes from a timeout that someone configured. Several timeouts share one value, so check the user agent to see which client it is before you change a platform setting.

With several hops in front of nginx, the shortest timeout usually expires first. Behind Cloudflare and a default AWS ALB, the ALB's 60-second timeout expires before Cloudflare's 125 seconds, so the client gets a 504, not a 524.

nginx's own proxy_read_timeout also defaults to 60 seconds, so in that setup either the ALB or nginx can send the 504. nginx logs 499 only when the ALB times out first. When you increase an outer timeout, increase proxy_read_timeout above your slowest response too.

How to Find the Timeout That Causes a 499 Spike

To triage a sudden 499 spike, work in this order:

  1. Group the 499s on the affected routes by $request_time, with the script below.
  2. Match each cluster to the defaults table, and check its client IPs and user agents. A user agent can show the SDK and its version.
  3. Compare the route's p99 $upstream_response_time before and after the spike.
  4. Check for a deployment, a client release, or a changed timeout at the start of the spike.
  5. Compare the rate of scattered 499s with that route's baseline rate, not with a fixed threshold.

If you send your logs to a log management platform, run step 1 there as a histogram of request_time for 499s on one route.

If your log uses nginx's default combined format, it has no $request_time field. You can still start with the 499 count per route and per user agent:

Shell
awk '$9 == 499 {split($7, p, "?"); print p[1]}' access.log | sort | uniq -c | sort -rn | head
awk -F'"' '$3 ~ /^ 499 / {print $6}' access.log | sort | uniq -c | sort -rn | head

To find the timeout itself, log $request_time with the log_format from the first section. Then count 499s per second of $request_time, rounded:

Shell
awk '/" 499 / && match($0, /rt=[0-9.]+/) {printf "%.0f\n", substr($0, RSTART+3, RLENGTH-3)}' access.log | sort -n | uniq -c

A tall count at one value is a cluster. The script below finds clusters for you and adds the top paths and clients.

Behind a CDN or load balancer, $remote_addr holds the hop's address, not the client's. To log the real client IP, set real_ip_header to X-Forwarded-For or CF-Connecting-IP, and list the hop's IP ranges as trusted with set_real_ip_from. The hop's address then moves to $realip_remote_addr, so log it too, to see which hop each request came through.

The script below, fingerprint_499.py, groups 499s by $request_time and lists the top paths and clients for each timeout. On a log that mixes client timeouts, a hop with a 3-second timeout, and user cancellations, the output looks like this:

Plain Text
311 lines with status 499
    175  request_time 1.871 to 2.021 s  a timeout near 2.0 s
       top paths: /sleep (95), /fastapi/sleep (20) | top clients: 192.168.148.1 (175)
     36  request_time 2.936 to 3.008 s  a timeout near 3.0 s
       top paths: /sleep (36) | top clients: 192.168.148.11 (36)
     34  request_time below 0.010 s  canceled before the app received it (queued or half-closed)
       top paths: /sleep (34) | top clients: 192.168.148.1 (34)
     66  spread: users, app code, or clients behind a proxy hop

The 3-second line is that hop's timeout, because all its requests come from the hop's address, 192.168.148.11. The 2-second line is a client timeout from the lab's direct clients, which share the Docker gateway address 192.168.148.1. In production, the client address alone does not separate a hop timeout from a client timeout. Match the value to the defaults table, and check user agents.

Run the script on one route's lines at a time, because many user cancellations on a busy route can hide a small cluster. Pass - to read from stdin, for rotated or Kubernetes logs:

Shell
grep '/api/search' access.log | python3 fingerprint_499.py -
gzip -dc access.log.*.gz | python3 fingerprint_499.py -
kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller --since=24h --tail=-1 | python3 fingerprint_499.py -

The script reads JSON logs, the ingress-nginx default format, and any format that logs $request_time as rt=. Save it as fingerprint_499.py:

Python
# fingerprint_499.py
"""Find the timeouts that cause the 499s in an nginx access log.

A timeout appears as a spike of 499s at one $request_time, in addition to a
spread of users and apps that cancel at any time. The script counts 499s per
time bin and reports the bins that are much larger than the bins around them.

Reads three formats: JSON lines with "status" and "request_time" keys, the
ingress-nginx default format, and any format that writes "status ... rt=".
Usage: python3 fingerprint_499.py access.log
   or: gzip -dc access.log.*.gz | python3 fingerprint_499.py -
Source: https://evomi.com/blog/http-499-status-code
"""
import json
import math
import re
import statistics
import sys
from collections import Counter, defaultdict

RT_FORMAT = re.compile(r'^(\S+) .*?"\S+ (\S+) [^"]*" (\d{3}) .*?\brt=([\d.]+)')
INGRESS = re.compile(r'^(\S+) .*?"\S+ (\S+) [^"]*" (\d{3}) \d+ "[^"]*" "[^"]*" \d+ ([\d.]+) \[')

def parse(line):
    """Return (client, path, status, request_time) or None."""
    if line.startswith("{"):
        d = json.loads(line)
        path = d.get("request_uri") or d.get("uri") or "-"
        return d.get("remote_addr", "-"), path, str(d.get("status")), float(d["request_time"])
    m = RT_FORMAT.search(line) or INGRESS.search(line)
    return (m[1], m[2], m[3], float(m[4])) if m else None

def bin_of(rt):
    # 50 ms bins up to 2.5 s, then bins that grow by 2% each, so the jitter of one timeout fits in a few bins
    if rt < 2.5:
        return int(rt / 0.05)
    return 50 + int(math.log(rt / 2.5) / math.log(1.02))

if len(sys.argv) != 2:
    sys.exit("usage: python3 fingerprint_499.py access.log (or - to read standard input)")

rows = []
source = sys.stdin if sys.argv[1] == "-" else open(sys.argv[1], errors="replace")
for line in source:
    try:
        row = parse(line.strip())
    except (ValueError, KeyError):
        continue
    if row and row[2] == "499":
        rows.append(row)
if not rows:
    sys.exit("no 499 lines with a request time found")

instant = [r for r in rows if r[3] < 0.01]
bins = defaultdict(list)
for r in rows:
    if r[3] >= 0.01:
        bins[bin_of(r[3])].append(r)
keys = sorted(bins)

# A bin is a candidate when it holds at least 2 x (the median of the 10 bins around it + 1)
def background(k):
    return statistics.median(len(bins.get(n, [])) for n in range(k - 5, k + 6) if n != k)

candidates = [k for k in keys if len(bins[k]) >= 2 * (background(k) + 1)]

# Candidates up to 2 bins apart form one group. A group is a timeout only when its
# count is well above the random variation of the bins around it, not just above zero.
groups, current = [], []
for k in candidates:
    if current and k - current[-1] > 2:
        groups.append(current)
        current = []
    current.append(k)
if current:
    groups.append(current)
timers = []
for g in groups:
    total = sum(len(bins[k]) for k in g)
    base = sum(background(k) for k in g)
    if total >= 5 and total >= base + 3 * math.sqrt(base + 1) + 2:
        timers.append(g)
in_timer = {k for t in timers for k in t}
spread = [r for k in keys if k not in in_timer for r in bins[k]]

def describe(group):
    paths = Counter(r[1].split("?")[0] for r in group).most_common(2)
    clients = Counter(r[0] for r in group).most_common(2)
    return ("       top paths: " + ", ".join(f"{p} ({n})" for p, n in paths)
            + " | top clients: " + ", ".join(f"{a} ({n})" for a, n in clients))

print(f"{len(rows)} lines with status 499")
for t in sorted(timers, key=lambda t: -sum(len(bins[k]) for k in t)):
    group = sorted((r for k in t for r in bins[k]), key=lambda r: r[3])
    mid = group[len(group) // 2][3]
    print(f"{len(group):7}  request_time {group[0][3]:.3f} to {group[-1][3]:.3f} s  a timeout near {mid:.1f} s")
    print(describe(group))
if instant:
    print(f"{len(instant):7}  request_time below 0.010 s  canceled before the app received it (queued or half-closed)")
    print(describe(instant))
if spread:
    print(f"{len(spread):7}  spread: users, app code, or clients behind a proxy hop")

What Happens to the Request After a 499

The results below come from nginx 1.31.8 in front of a Flask app on gunicorn. The upstream needs 5 seconds, or 6 seconds for the streaming response. Each row sent 20 requests, and every request in a row ended the same way:

Setup

Client got

nginx logged

Backend finished

Client timeout 2 s

Client-side timeout

499, $request_time 1.98 to 2.01

20 of 20

proxy_read_timeout 3s, client waits 10 s

A 504 response

504, $request_time 3.00 to 3.02

20 of 20

proxy_ignore_client_abort on, client timeout 2 s

Client-side timeout

200, $request_time 5.00 to 5.04

20 of 20

Streaming response, client timeout 2 s

200 and 2 chunks, then a client-side timeout

200, $request_completion empty

0 of 20

To run the four tests in this table yourself, use the setup in the section Reproduce 499 and 504 With nginx and a Slow Upstream.

With default settings, the first timeout to expire decides the status code. If the client's timeout expires first, the client gets no status code. nginx then logs 499, unless it has already sent the response headers. If nginx's proxy_read_timeout expires first, the client gets a 504 it can handle.

The status code does not show abandonment on streaming responses. nginx can log 499 only before it sends the response headers. When a user closes a chat tab halfway through a streamed LLM response, nginx therefore logs 200. On streaming routes, count requests with an empty $request_completion to measure the abandonment rate.

The backend finishes work that nobody will receive, unless its handler checks for the client disconnect. Flask, FastAPI on uvicorn, a plain Node http server, and a Go handler that ignored the request context all finished every request after its 499. A Go handler that checked r.Context().Done() stopped, and so did the streaming handler when its next write failed.

Under load, abandoned work exhausts the worker pool that new requests need. The test used 4 Flask workers, a 1.5-second endpoint, and 8 retrying clients, and measured goodput, the successful responses clients received. With a 10-second client timeout, goodput was about 160 a minute, the maximum for 4 workers. With a 2-second timeout, goodput fell to 4 a minute, because requests waited in the queue for more than 2 seconds. Almost every attempt ended as 499, and the workers kept serving abandoned requests for about 37 seconds after the clients had stopped.

How to Fix HTTP 499 on the Server

Match what the log shows to the fix:

What the log shows

Fix

A low rate of scattered $request_time values

Usually nothing. Users close tabs, and front ends abort requests

499s at a $request_time near 0

Usually nothing, because the app never received the request. A client that half-closes its socket needs a fix

A cluster at a platform default

Increase that timeout and proxy_read_timeout together, or run long work as a background job

A cluster below your p99 latency

A faster upstream (an index, a cache, or pagination), or run long work as a background job

Three 499s per call from clients that use the OpenAI or Anthropic SDK

Stream LLM responses

Workers still busy after the clients disconnected

Propagate the cancellation to your handler, or reject requests that waited too long

Autocomplete fields and single-page apps cancel earlier requests by design, so expect some 499s on those endpoints.

Many error-rate dashboards and service-level objectives (SLOs) count only 5xx responses, so they can miss a slow endpoint that users abandon. Count 499s whose $request_time is above your latency SLO as errors, and do not count the scattered ones.

Measure the wasted work before you change timeouts. nginx cannot show whether the backend finished an abandoned request, because it stops timing the upstream when it closes that connection. Add proxy_set_header X-Request-ID $request_id; to the location block and rid=$request_id to log_format, and have the app log done and that ID when it finishes. $request_id is unique per request, so you can then join the two logs:

Shell
grep '" 499 ' access.log | grep -o 'rid=[0-9a-f]*' | cut -d= -f2 | sort -u > abandoned.txt
grep -o 'done [0-9a-f]\{32\}' app.log | cut -d' ' -f2 | sort -u > finished.txt
echo "$(comm -12 abandoned.txt finished.txt | wc -l | tr -d ' ') of $(wc -l < abandoned.txt | tr -d ' ') abandoned requests still finished"

For a log with a mix of 499 causes, the commands print:

Plain Text
186 of 240 abandoned requests still finished

Each finished request is wasted work. The app never received the others, or they reached a handler that checked for the client disconnect. A 499 near 0 seconds costs the backend little or nothing.

Make inner timeouts shorter than outer ones. The hop nearest your app should time out first, and the client last. From shortest to longest, the order is the application deadline, proxy_read_timeout, the load balancer timeout, and the client timeout. Set even the shortest one above the endpoint's p99 latency.

The innermost hop then returns a status code, such as nginx's 504. The caller can report that status, or retry an idempotent request, one that is safe to repeat. For clients that allow 30 seconds, this location block returns a 504 before the client times out:

Plain Text
location /api/reports/ {
    proxy_connect_timeout 5s;
    proxy_read_timeout    25s;   # expires before the client's 30 s timeout
    proxy_pass http://app:8000;
}

With Gateway API, set timeouts.request on the HTTPRoute rule, because without it, the controller's default applies, such as 15 seconds on Envoy Gateway. On ingress-nginx, use the nginx.ingress.kubernetes.io/proxy-read-timeout annotation, in seconds. That annotation defaults to 60 seconds, so routes that worked on ingress-nginx can start returning 504s at 15 seconds after a move to Envoy Gateway.

proxy_read_timeout measures the time between two successive reads, not the whole response. An upstream that sends a byte every few seconds never makes it expire.

Include the application server in this timeout order. gunicorn kills a sync worker that spends more than 30 seconds on one request by default (--timeout), and nginx then returns 502. If the upstream block lists several servers, nginx also retries a timed-out request on the next server by default (proxy_next_upstream). nginx does not retry non-idempotent requests such as POST this way, but a slow GET can run twice.

Run long work as a background job. If a report, an export, or an LLM call takes longer than clients wait, accept the job and return 202 Accepted with a status URL. The client then polls, and no connection stays open for the slow part.

Stream LLM responses. The OpenAI and Anthropic Python SDKs retry twice on a timeout by default, even for a POST. With a 3-second client timeout against a 10-second endpoint, each call produced three 499 lines, and the backend finished all three. In the tested versions, openai 3.42.0 and anthropic 2.3.0, both SDKs sent their retry number in the X-Stainless-Retry-Count header. Log $http_x_stainless_retry_count to distinguish retries from new requests.

Clients of an OpenAI-compatible API expect the response in the same request, not a 202 Accepted status URL, so stream the response. Each streamed chunk resets the client's read timeout, and a streaming handler stops when its next write to a closed connection fails.

Propagate the cancellation to your handler. nginx closes the upstream connection on a 499, but a handler stops only if it checks for the client disconnect. The Go handler that stopped after the client disconnected used this pattern:

Go
select {
case <-time.After(work):  // simulates the real work
    fmt.Fprintln(w, "done")
case <-r.Context().Done(): // nginx closed the upstream connection
    return                 // stop and skip the side effects
}

Starlette and FastAPI offer await request.is_disconnected(). Django 5.0 and later raises asyncio.CancelledError in async views under ASGI.

In Node 26.1 and later, pass req.signal to fetch() and database calls. Behind nginx, the signal aborts the slow call as soon as the client disconnects. On earlier versions, listen for the response close event and abort downstream calls yourself.

The database needs the same signal, because PostgreSQL keeps running a query after the HTTP client disconnects. In Go, pass the request context into the query with QueryContext, so the pgx driver can cancel it. As a safety limit, set statement_timeout, which is disabled by default.

Reject requests that waited too long (load shedding). A synchronous Python app on WSGI, such as Flask or sync Django under gunicorn, cannot detect a client disconnect while the handler runs. Under overload, many requests are already abandoned when a handler starts, because their queue time has exceeded the client's timeout. Have nginx add an X-Request-Start header with the time it forwarded each request, in milliseconds:

Plain Text
# http context: $msec without the dot, in milliseconds
map $msec $msec_ms {
    "~^(?<s>\d+)\.(?<ms>\d+)$" "$s$ms";
}

# in the location that proxies to the app
proxy_set_header X-Request-Start $msec_ms;

Then, in the app, reject each request that can no longer finish within its client's timeout:

Python
import time
from flask import request

CLIENT_TIMEOUT = 2.0  # the shortest timeout your clients use, in seconds
NORMAL_TIME = 1.5  # how long the endpoint normally takes, in seconds
MAX_QUEUE = CLIENT_TIMEOUT - NORMAL_TIME

@app.before_request
def shed_late_requests():
    start = request.headers.get("X-Request-Start", "")  # milliseconds, set by nginx
    if start.isdigit() and time.time() - int(start) / 1000 > MAX_QUEUE:
        return "queued too long\n", 503, {"Retry-After": "1"}

In the same 4-worker overload test, load shedding increased goodput for the same 2-second clients from 4 to about 160 successful responses a minute. The result matches clients with a 10-second timeout, and no abandoned work ran after the clients stopped.

A CLIENT_TIMEOUT below the timeout your clients use rejects requests they would have waited for, so jobs fail that would have succeeded. The check also needs synchronized clocks on nginx and the app, which NTP usually keeps within a few milliseconds. For Django, django-request-queue-timeout does the same and reads this X-Request-Start header in milliseconds.

Use proxy_ignore_client_abort only where work must finish. The directive makes nginx log abandoned requests with the upstream's status, such as 200, instead of 499. The directive also keeps the upstream connection open, so even a handler that checks for the client disconnect still finishes. Enable it only in the location blocks where a half-finished operation costs more than the wasted work.

How to Fix HTTP 499 on the Client

Set the read timeout from the endpoint's measured p99 latency plus a safety margin, not from a library default. Separate connect and read timeouts so a slow connection and a slow response raise different errors.

Treat a timeout as an unknown outcome, because the server may still finish the request. An automatic retry of a POST can therefore create a duplicate order. Retry only idempotent requests, with exponential backoff and jitter. Limit retries with a retry budget, which caps retries at a percentage of all requests, so a slow server does not cause a retry storm.

For a POST, send an idempotency key if the API supports one, so the server can deduplicate the request and return the original response. For code examples, retrying failed Python requests with backoff and retry logic for failed Axios requests show exponential backoff for Python and Node clients.

What Other Reverse Proxies Log Instead of 499

Reverse proxies and load balancers log a client disconnect with different status codes, which matters if you're migrating from ingress-nginx. Kubernetes retired ingress-nginx in March 2026 and recommends Gateway API. NGINX Gateway Fabric runs nginx and still logs 499, but many Gateway API implementations run on Envoy, which logs a different code.

Rows marked Tested used the same 2-second client-side timeout:

Reverse proxy or load balancer

Logged on client disconnect

Source

nginx 1.31.8

499

Tested

Traefik 3.7.17

499

Tested

Caddy 2.11.7

499 (status 0 before v2.11.5)

Tested

HAProxy 3.4.7

400 with termination state CH-- (the client aborted while waiting for response headers)

Tested

Envoy 1.36.13

0 with response flag DC (downstream connection terminated)

Tested

AWS Application Load Balancer

460

AWS docs

Google Cloud Application Load Balancer

0 with client_disconnected_before_any_response

Google Cloud docs

Azure Application Gateway v2

499

Azure docs

Cloudflare

499 in logs and analytics, even after the response has started

Cloudflare docs

After a migration, an alert on 499 can stop firing. HAProxy logs client aborts as 400, which can fire an alert on 400 Bad Request. Envoy and Google Cloud log them as status 0, which a 4xx alert does not detect. Alert on what your load balancer or reverse proxy actually logs for a disconnect, including any termination state, response flag, or status detail.

How to Prevent 499s When Scraping Through a Proxy

On a slow page, your scraper gets a ReadTimeout, so you lose the page, and a site on nginx or Cloudflare logs a 499. First check your client's own timeout. httpx stops at 5 seconds by default, Scrapy at 180, and aiohttp at 300, while Requests has no default. A page with a long time to first byte needs a tunnel that stays open until the first byte arrives. Evomi Core Residential proxies let you set how long a tunnel to the origin may stay idle, with no data in either direction, per request. Add the _maxtimeout- parameter with the idle time in seconds to your proxy password, such as _maxtimeout-120. A scraper can then give slow sites a longer idle timeout than fast ones.

HTTPS goes through a CONNECT tunnel, so after the tunnel opens, the status codes you see come from the origin itself. A slow page therefore appears in your client as a timeout. Keep your read timeout slightly under the idle timeout, so your own client times out first and raises a clear ReadTimeout.

Find your credentials in the Proxy Generator in your Evomi dashboard:

Evomi dashboard Proxy Generator for Core Residential, with the username and password hidden, hostname core-residential.evomi.com, and port 1000

Replace USERNAME and PASSWORD in this snippet with your credentials, and keep the _maxtimeout-120 suffix:

Python
import requests

USERNAME = "USERNAME"
PASSWORD = "PASSWORD_maxtimeout-120"  # the idle timeout you choose: 120 s
proxy = f"http://{USERNAME}:{PASSWORD}@core-residential.evomi.com:1000"

response = requests.get(
    "https://ip.evomi.com/s",
    proxies={"http": proxy, "https": proxy},
    timeout=(10, 110),  # connect, read: read stays under the idle timeout
)
print(response.status_code, response.text)

The ip.evomi.com/s endpoint returns your exit IP:

Plain Text
200 203.0.113.x

To check one slow URL from the shell, use the same settings with curl. The timings show where the wait happens:

Shell
curl -s -o /dev/null --max-time 110 \
  -x "http://USERNAME:PASSWORD_maxtimeout-120@core-residential.evomi.com:1000" \
  -w 'connect %{time_connect}  tls %{time_appconnect}  first byte %{time_starttransfer}\n' \
  https://example.com/slow-page

If first byte comes long after tls, the site itself is slow to respond, so give that site a longer _maxtimeout-.

At scale, track response time per target domain and set each domain's read timeout and _maxtimeout- from its own p99 latency. A single global value set for typical sites is too short for slow sites, where it causes 499s at the origin. The same value is too long for fast sites, where hung requests wait longer than needed.

When many requests to one site time out, reduce your concurrency, because more concurrent requests can make a slow origin slower. Some sites also delay responses to clients they rate-limit, and a longer timeout does not fix that. Follow the site's robots.txt and terms of service as you adjust your crawl rate.

Evomi Scraper API can also fetch slow pages for you. With "async": true, a request returns 202 Accepted and a task URL, so no connection of yours stays open while the page loads. You poll that URL for the full page.

The API retries transient errors on its side, so your own code needs few retries.

Reproduce 499 and 504 With nginx and a Slow Upstream

This setup runs nginx 1.31.8 in front of a Flask app on gunicorn. Its /sleep endpoint takes as many seconds as the s query parameter. Save the three files in one empty directory, and run the stack only on your own machine.

The app prints a line for each request it finishes, so you can see whether abandoned work completes:

Python
# app.py
import time
from flask import Flask, Response, request

app = Flask(__name__)

@app.get("/sleep")
def sleep():
    seconds = float(request.args.get("s", 5))
    time.sleep(seconds)  # simulates a slow query or an LLM call
    print(f"finished {request.full_path}", flush=True)
    return f"slept {seconds}\n"

@app.get("/stream")
def stream():
    def chunks():
        for i in range(6):
            yield f"chunk {i}\n"
            time.sleep(1)
        print("stream finished", flush=True)
    return Response(chunks(), mimetype="text/plain")

The nginx config gives each timeout scenario its own location block and logs the timing variables:

Plain Text
# nginx.conf
log_format timing '$remote_addr xff=$http_x_forwarded_for "$request" '
                  '$status $body_bytes_sent rt=$request_time '
                  'urt=$upstream_response_time us=$upstream_status '
                  'done=$request_completion';

server {
    listen 8080;
    access_log /dev/stdout timing;

    location /        { proxy_pass http://app:8000; }
    location /short/  { proxy_read_timeout 3s; proxy_pass http://app:8000/; }
    location /ignore/ { proxy_ignore_client_abort on; proxy_pass http://app:8000/; }
    location /stream  { proxy_buffering off; proxy_pass http://app:8000; }
}

The Compose file starts both containers and publishes nginx on port 8080:

YAML
# compose.yaml
services:
  app:
    image: python:3.14-slim
    volumes: ["./app.py:/app/app.py:ro"]
    working_dir: /app
    command: >
      sh -c "pip install -q flask==3.1.3 gunicorn==26.2.0 &&
             gunicorn -w 8 --timeout 120 -b 0.0.0.0:8000 app:app"
  nginx:
    image: nginx:1.31.8-alpine
    ports: ["${PORT:-8080}:8080"]
    volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
    depends_on: [app]

Start the stack with docker compose up -d. Wait until docker compose logs app shows gunicorn's Booting worker lines, which takes about 20 seconds. Then send 20 requests with a 2-second client timeout to an endpoint that needs 5 seconds:

Shell
for i in $(seq 20); do
  curl -s -o /dev/null --max-time 2 "localhost:8080/sleep?s=5"; echo "curl exit $?"
done | sort | uniq -c

Each client reaches its own timeout (curl exit code 28) and gets no status code:

Plain Text
  20 curl exit 28

docker compose logs nginx shows 20 lines with status 499, while docker compose logs app shows 20 finished lines. Python Requests and Node fetch behave the same way.

Run the same loop against localhost:8080/ignore/sleep?s=5 and localhost:8080/stream, and against localhost:8080/short/sleep?s=5 with --max-time 10. Each loop sends 20 requests and produces one row of the results table above.

How 499 Differs From 504, 502, and Other Codes

These related codes come from the same chain of client, proxy, CDN, and server as 499:

Code

Who sends it

What it means

407 Proxy Authentication Required

A forward proxy

The proxy needs valid credentials in the Proxy-Authorization header. Error 407 is one of the common proxy error codes

408 Request Timeout

The server (nginx logs it but sends nothing)

The client sent its request too slowly, and client_header_timeout or client_body_timeout expired

499 Client Closed Request

No one. nginx only logs it

The client closed the connection before nginx sent the response headers

502 Bad Gateway

A gateway or reverse proxy

The upstream refused or closed the connection, or sent an invalid response

503 Service Unavailable

The server or a load balancer

The server cannot handle the request now, for example when it rejects requests that waited too long in the queue

504 Gateway Timeout

A gateway or reverse proxy

The upstream did not respond within the gateway's timeout

524 A Timeout Occurred

Cloudflare

The origin did not respond within Cloudflare's proxy read timeout. nginx logs 499 for the same request

Wrapping Up

A 499 means the client stopped waiting, but your backend can keep working unless its handler checks for the client disconnect. Read $request_time before you change anything, because a cluster at one value shows which timeout expired, even after vendors change their defaults. Then order your timeouts so the innermost hop times out first and returns a status code.

When the work outlasts every client's timeout, move it to a background job instead of increasing timeouts again. For slow target sites, set _maxtimeout- per site on Evomi Core Residential, or fetch those pages with Evomi Scraper API and poll for the result.

FAQ

How to fix error 499?

Find which timeout expired. A cluster of 499s at one $request_time usually shows a client, CDN, or load balancer timeout. Then make the endpoint faster, run long work as a background job, or increase that timeout. Keep proxy_read_timeout above your p99 latency and below the client's timeout.

What is meant by 499?

Error 499 is the "client closed request" status that nginx logs but never sends. nginx logs it when the client closes the connection before nginx sends the response headers. Cloudflare, Traefik, Caddy 2.11.5 and later, and Azure Application Gateway v2 log the same code for a client disconnect.

What is the 499 service code?

As a service status code, 499 means "client closed request", so the caller disconnected before the service responded. nginx defined it, and ASP.NET Core and Azure App Service use it too. In a service dashboard, a 499 usually means a client timeout or a canceled call.

What does error 499 mean in an API?

In an API or SDK, a 499 usually means the request was canceled before a response arrived. Google APIs map the gRPC CANCELLED status to 499, so their SDKs can show it. Check your network connection, increase the client timeout for slow operations, and retry only idempotent requests, which are safe to repeat.

Is 499 a client or server problem?

A 499 can be either. The client closed the connection, but a cluster at one $request_time below your p99 latency means your slowest responses take longer than that client waits. Scattered $request_time values come from closed tabs or aborted requests, and a small rate usually needs no fix.