Fix Nginx 499 Client Closed Request with Django

Understand and fix Nginx 499 status codes in front of Django: find the slow endpoints and impatient clients, align load balancer and proxy timeouts, and move long work to background tasks.

Problem statement

The Nginx access log shows status 499 for some requests:

198.51.100.7 - - [25/Sep/2026:10:14:03 +0000] "POST /reports/export/ HTTP/1.1" 499 0 "-" "Mozilla/5.0 ..."

499 is not an HTTP status code a client ever receives. It is Nginx's own log code meaning "the client closed the connection before Nginx sent a response". Something gave up waiting: a browser tab was closed, a user clicked again, a mobile connection dropped, a load balancer or CDN hit its timeout, or a health checker's short timeout expired.

Occasional 499s are normal. Many 499s on the same endpoints, or a sudden increase, mean responses are too slow for whoever is waiting. This guide finds the slow requests, identifies the impatient client, and fixes the mismatch.

Who gave up firstClient or LBtimeout expiresNginxlogs 499Gunicorn workerstill running viewWasted workresult discardedA 499 means the waiting side left first; the Django view may still be running.
  1. Log $request_time and $upstream_response_time in Nginx, then list the endpoints with the most 499s and their durations.
  2. Identify who closed: user agents and client IPs show browsers, load balancers, health checkers, or bots.
  3. If durations cluster at a fixed number (for example 60 s or 100 s), an upstream component's timeout is closing connections; align timeouts so outer layers wait longer than inner ones.
  4. Make the slow endpoints fast or move their work to background tasks.
  5. Fix health checks that time out because they hit slow pages or wait behind busy workers.
  6. Decide whether aborted requests should keep running (proxy_ignore_client_abort), usually no.

Step-by-step solution

1. Log timings

log_format timed '$remote_addr - $remote_user [$time_local] "$request" '
                 '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                 'rt=$request_time urt=$upstream_response_time';

access_log /var/log/nginx/access.log timed;
timings show who gave up"GET /reports/annual/ HTTP/1.1"4990 …rt=30.001urt=-1231The client closed before Nginx answered.2Gave up at 30 s: a client or LB timeout.3Django had not responded yet.A round number like 30.0 or 60.0 points at a configured timeoutdjango-deployment.com
Add rt and urt to the log format once; they answer most timing questions.
sudo nginx -t && sudo systemctl reload nginx

After some traffic, summarise 499s by path and duration:

awk '$9 == 499 {split($7, p, "?"); print p[1]}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
grep '" 499 ' /var/log/nginx/access.log | grep -o 'rt=[0-9.]*' | cut -d= -f2 | sort -n | uniq -c | sort -rn | head

(The field numbers assume the log format above; adjust them if yours differs.)

2. Identify the client that disconnects

  • Durations clustered exactly at one value (for example many 499s at rt=60.0) point to a timeout upstream of Nginx: a load balancer idle timeout (AWS ALB defaults to 60 seconds), Cloudflare's origin response timeout (100 seconds on most plans), or an API client's timeout.
  • User agents like ELB-HealthChecker, kube-probe, or monitoring services show health checks timing out.
  • Short, random durations from browsers usually mean users navigating away or double-submitting; these are normal in small numbers.

3. Align the timeout chain

Each layer should wait slightly longer than the layer behind it, so the innermost layer fails first with a clear error:

LayerSettingExample
Client, CDN, or load balanceridle or response timeout75 s
Nginxproxy_read_timeout65 s
Gunicorn--timeout60 s
Django codeDB statement_timeout, HTTP client timeouts30 s
the outer timeout is too shortLoad balancer idle timeout30 s: gives up firstNginx proxy_read_timeout60 sGunicorn --timeout120 s0 s30 s60 s90 s120 ssecondsOuter layers should wait longer than inner ones, not shorterdjango-deployment.com
Better still, make slow endpoints fast or asynchronous.

With the outer layer shortest (for example a 60-second load balancer in front of a 120-second Nginx timeout), the load balancer disconnects first: Nginx logs 499, the client gets the load balancer's generic 504, and Gunicorn keeps working on a request nobody will read. Inverting the order gives you a proper 504 from Nginx or a traceback from Gunicorn's timeout, which point at the slow code.

4. Make slow endpoints fast, or asynchronous

For the endpoints at the top of the 499 list:

  • Profile them (query counts, external API calls, large responses).
  • Add pagination to list views and exports.
  • Move work that cannot be fast into a background task and return immediately:
from django.http import JsonResponse
from django.db import transaction


def start_export(request):
    export = Export.objects.create(user=request.user, status="pending")
    transaction.on_commit(lambda: build_export.delay(export.pk))
    return JsonResponse({"export_id": export.pk, "status_url": f"/exports/{export.pk}/"}, status=202)

The client polls a status URL or receives an email when the file is ready. No connection has to stay open for minutes.

5. Fix health check 499s

Health checks usually have short timeouts (2 to 5 seconds). They time out when the check endpoint does real work (database queries, cache calls, rendering templates) or when all Gunicorn workers are busy and the check waits in the queue. Use a lightweight endpoint that does only what is needed to prove the process is alive, and consider a separate readiness check with dependency checks and a longer timeout. Persistent health check failures on busy workers are a capacity signal: add workers or make requests faster.

6. Decide what happens to abandoned requests

By default, when the client disconnects, Nginx closes its connection to Gunicorn. A sync Gunicorn worker does not notice until it tries to write the response, so the view usually runs to completion and the result is discarded.

the client left; the view did notBy defaultthe view keeps runningwrites and emails still happen✕user retries: work done twiceDesigned for it✓idempotency keys on writes✓long work moved to tasks✓status endpoint to check progressA 499 in the log does not mean the action did not happendjango-deployment.com
Double submissions after a 499 are a common source of duplicate orders.

proxy_ignore_client_abort on; tells Nginx to keep the upstream connection open and read the full response anyway. That can help for endpoints with side effects you want logged cleanly, but it does not save any work. For long operations, the background task pattern is the real fix.

Verification check:

awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
grep '" 499 ' /var/log/nginx/access.log | tail -20

After the changes, 499s should drop to a small, steady background level, and slow operations should show up as fast 202 responses followed by background task execution.

Explanation

Every request crosses several components, each with its own patience. When the component closest to the user gives up before Nginx has a response, Nginx records 499 because no status was ever sent. The code tells you where to look (the client side) but not why the response was slow. Combining request times, client identity, and the configured timeouts along the chain turns 499s into a precise diagnosis: a slow endpoint, a misaligned timeout, or an overloaded worker pool.

Edge cases / notes

Streaming and long polling

Server-sent events, long polling, and large downloads legitimately keep connections open. 499s there often just mean the user left the page. Exclude these paths from 499 alerts.

Retries multiplying load

Some clients retry after a timeout, sending the same slow request again while the first is still running. That can snowball into worker exhaustion. Idempotency keys and background processing prevent duplicate work.

When to automate this

Track the 499 rate per endpoint as a metric and alert on sudden increases, which often reveal a slow deployment or a failing dependency before 5xx errors appear. Keep timeouts for every layer in one place in your configuration templates so they stay aligned.

Next steps

FAQ

What does Nginx status 499 mean?

The client closed the connection before Nginx sent a response. It is a log code, not a status sent to the client.

Are 499 errors a problem with Django?

A few are normal. Many 499s on the same endpoints mean responses are slower than the client, load balancer, or CDN is willing to wait.

Does Django keep processing after a 499?

Usually yes. A sync Gunicorn worker keeps running the view until it tries to write the response, then discards it.

How do I stop load balancer timeouts causing 499s?

Make the load balancer's timeout longer than Nginx's and Gunicorn's, and move long operations to background tasks.

Keep going