How to figure out nginx status code 499 (and fix it)

By Published 9 min read VPS
How to figure out nginx status code 499 (and fix it)

Why is nginx logging 499 when your Laravel app never returns that code? Because nobody returned it. Nginx writes 499 in its access log when the client hangs up before the response is ready. By the end of this guide you'll know how to figure out nginx status code 499 from your own logs: which URLs trigger it, who disconnected, and what to change.

Nginx status code 499 means "client closed request": the browser, load balancer or script disconnected before nginx sent a response. To figure out the cause, log $request_time and $upstream_response_time, group 499s by URL, and look at how long clients waited. A fixed wait points to a timeout in front of nginx; short random waits mean users or JavaScript cancelled.

What nginx 499 "client closed request" actually means

499 isn't an HTTP standard code. It's nginx's own label for "I was still working on this when the other side closed the connection." The client never receives it, because there's nobody left to receive it. It only exists in your access log.

That's the part that confuses people. A 499 is not an error your app produced. It's a symptom: something on the client side gave up waiting. So the question is never "how do I stop nginx returning 499", it's "why did the client stop waiting?"

The usual answers:

  • The backend was slow, and a timeout in front of nginx fired first (Cloudflare, an AWS load balancer, a client library).
  • A real person closed the tab, refreshed, or clicked a different link.
  • Frontend code cancelled the request (an AbortController, axios cancellation, a search box firing a new request on every keystroke).
  • A health check or uptime monitor with a short timeout hit a slow endpoint.

Running example for the rest of this guide: a Laravel app on a VPS, nginx in front of PHP-FPM, Cloudflare in front of nginx. Users click "Export" on /reports/export, and the access log fills with 499s on that URL.

How to figure out nginx status code 499, step by step

Step 1: Reproduce a 499 on purpose

Before chasing real 499s, make one you fully understand. Add a temporary route in routes/web.php:

use Illuminate\Support\Facades\Route;

Route::get('/slow-test', function () {
    sleep(5);
    return 'done';
});

Call it with a client that gives up after two seconds:

curl -sS -o /dev/null --max-time 2 https://example.com/slow-test

curl prints something like:

curl: (28) Operation timed out after 2001 milliseconds with 0 bytes received

Now check the access log:

sudo tail -n 3 /var/log/nginx/access.log

You'll see a line for GET /slow-test with status 499. That's the whole mechanism: a client timeout (2 seconds) shorter than the backend's work (5 seconds).

Step 2: Log how long the client waited

The default combined log format doesn't record timing, and timing is what tells you who hung up. Add this to the http block in /etc/nginx/nginx.conf:

log_format timing '$remote_addr - [$time_local] "$request" $status '
                  'rt=$request_time urt=$upstream_response_time '
                  'us=$upstream_status ua="$http_user_agent"';

Then use it in your site's server block:

access_log /var/log/nginx/timing.log timing;

Test and reload:

sudo nginx -t && sudo systemctl reload nginx

What the fields mean:

  • rt is how long the client was connected, in seconds with millisecond precision.
  • urt is how long nginx waited on PHP-FPM. If the word "upstream" here is unfamiliar, what upstream means in nginx explains it.
  • us is the status PHP-FPM returned. On a 499 it's usually -, because the backend never got to answer.

Run the curl test from Step 1 again. The new line should show 499 rt=2.00x, matching curl's --max-time.

Step 3: Find which URLs produce the 499s

Let real traffic run for a while, then count 499s per URL. With the timing format above, the status is field 8 and the path is field 6:

awk '$8 == 499 {print $6}' /var/log/nginx/timing.log | sort | uniq -c | sort -rn | head

If you're still on the default combined format, the status is field 9 and the path is field 7:

awk '$9 == 499 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

In the running example the output looks like:

    214 /reports/export
     37 /api/search
      9 /

Don't treat every 499 the same. A handful on / is normal (people close tabs). Hundreds on one heavy endpoint is a real problem.

While you're here, check the IP column. If every 499 comes from a few Cloudflare or load balancer addresses instead of visitor IPs, fix that first so you can tell real users from proxies; the X-Real-IP fix for nginx covers it.

Step 4: Read the timing pattern

This is the step that actually answers "who hung up". Group the 499s for one URL by how many whole seconds the client waited:

grep '/reports/export' /var/log/nginx/timing.log \
  | awk -F'rt=' '/ 499 rt=/ {split($2, a, " "); printf "%d\n", a[1]}' \
  | sort -n | uniq -c

Then read the result like this:

  • Almost all 499s land on the same number (say every one is 59 or 60 seconds, or every one is 99 or 100): a timeout in front of nginx is cutting the connection. Find which layer uses that value. Cloudflare's proxy has its own read timeout and shows the visitor a 524 page when it expires, while nginx logs 499. AWS load balancers have an idle timeout you set on the load balancer. Check the actual configured values rather than assuming defaults.
  • The times are scattered and mostly short (0 to 5 seconds): people or frontend code are cancelling. On /api/search this is usually a search box sending a new request per keystroke and aborting the previous one. That's expected behaviour, not a bug.
  • The user agent is a health checker or monitor: filter with grep 499 /var/log/nginx/timing.log | grep -o 'ua="[^"]*"' | sort | uniq -c. The fix is a lighter health endpoint or a longer timeout in the monitor.

In the running example, the export 499s all sit around one fixed value. That's the Cloudflare timeout firing while PHP is still building the file.

Step 5: Fix the slow request in Laravel

Raising timeouts everywhere is the tempting fix, and I'd avoid it. The visitor still stares at a spinner, and on Cloudflare you can't raise the proxy timeout on every plan anyway. The real fix is to stop doing slow work inside the request.

For the export, dispatch a job and return immediately:

use App\Jobs\GenerateReport;
use App\Models\Export;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/reports/export', function (Request $request) {
    $export = Export::create([
        'user_id' => $request->user()->id,
        'status'  => 'pending',
    ]);

    GenerateReport::dispatch($export);

    return response()->json(['id' => $export->id], 202);
});

The frontend then polls a status endpoint or the user gets an email with the download link. If the export itself runs out of memory in the job, exporting large data in chunks with Laravel-Excel handles that side.

The other common slow cause is an outbound API call with no sensible timeout. Laravel's HTTP client waits 30 seconds by default. Set your own:

use Illuminate\Support\Facades\Http;

$response = Http::connectTimeout(3)
    ->timeout(10)
    ->get('https://api.example.com/rates');

Failing after 10 seconds with a clear error beats hanging until something upstream kills the connection.

Step 6: Line up the timeouts so the failure is visible

Even after fixing the slow endpoint, set the timeout chain in a deliberate order. Standard practice is that each layer gives up slightly after the one behind it, so the innermost layer fails first and you get a real error message instead of a silent 499:

  1. PHP max_execution_time (or request_terminate_timeout in the PHP-FPM pool) shortest.
  2. nginx fastcgi_read_timeout (or proxy_read_timeout for Octane) a bit longer. nginx's default is 60 seconds.
  3. Cloudflare or load balancer timeout longest.
location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php-fpm.sock;
    fastcgi_read_timeout 60s;
}

If nginx times out first you'll see 504 with upstream timed out in the error log. If the front layer times out first you get 499, which tells you far less. A 504 is easier to debug, so I'd rather have that.

Should you turn on fastcgi_ignore_client_abort?

You'll find advice to add fastcgi_ignore_client_abort on; (or proxy_ignore_client_abort on; for proxied apps). It tells nginx to keep the backend connection open after the client leaves.

My view: don't set it server-wide. It doesn't help the user who already left, it keeps PHP-FPM workers busy on responses nobody will read, and it hides the 499 pattern you just learned to read. PHP also tends to keep running a script after the client disconnects until it tries to send output, so the flag often changes less than people expect.

The one place I'd consider it is a specific location for webhooks, where the sender may hang up early but you want the handler to complete. Even then, a queued job is the more reliable answer.

Verify the 499s are gone

Repeat the reproduction against the real endpoint, using a timeout shorter than your front layer:

curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' \
  -X POST https://example.com/reports/export \
  -H 'Cookie: laravel_session=YOUR_SESSION' -H 'X-CSRF-TOKEN: YOUR_TOKEN'

You want 202 and a time well under a second. Then rerun the count from Step 3 after a day of traffic. Occasional 499s on normal pages will remain, and that's fine. A cluster on one URL at one fixed wait time should be gone. Remove /slow-test once you're done.

Next steps after fixing nginx 499 errors

Keep the timing log format permanently. It costs almost nothing and makes the next 499, 502 or 504 a two-minute investigation instead of guesswork. If the pattern points somewhere you can't trace, contact HeadsNTail with a few sample log lines.

FAQ

What does status code 499 mean in nginx?

It means "client closed request". The client disconnected before nginx finished sending a response, so nginx logged 499 instead of the real status. The visitor never sees this code.

Is a 499 an error on my server?

Not directly. The server didn't fail, the client gave up. But a large number of 499s on one URL usually means that request is too slow, which is a server-side problem worth fixing.

Why does Cloudflare show 524 when nginx logs 499?

Cloudflare's proxy stopped waiting for your origin and showed the visitor its 524 timeout page. From nginx's point of view, Cloudflare (the client) closed the connection, so it logs 499. Both entries describe the same event from different sides.

How is 499 different from 504 Gateway Timeout?

A 504 means nginx itself gave up waiting on the backend, and you'll find upstream timed out in the error log. A 499 means the client gave up before nginx did. If you see 499 at a fixed time, something in front of nginx has a shorter timeout than nginx.

Can I return 499 from my Laravel app?

You can send any status number, but don't. 499 is meant to describe a client disconnect in nginx's logs, and using it as a response code would only confuse your monitoring.

Once you know how to figure out nginx status code 499 from the timing pattern, check your other heavy endpoints with the Step 4 command before they show up in the log.

Free tools for VPS work

All free tools →

More VPS articles