nginx 499: the request your user didn’t wait for
I run a small writing app where pressing one button asks a language model to write a book chapter. The model takes 15 to 40 seconds. On launch day, every single generation — four out of four — ended the same way in the nginx access log:
"POST /app/api/chapters/generate HTTP/1.1" 499 0
The application log showed nothing wrong. The chapters were all in the database, correctly written. And the person at the other end — a child on an iPhone — saw a spinner that never resolved, on a screen that had probably locked itself somewhere around second twenty.
What 499 actually is
499 is not in any RFC. It is nginx’s private bookkeeping code for client closed request: the browser (or the phone’s radio, or a proxy in between) hung up before the upstream finished answering. Nothing failed. Somebody just stopped waiting.
Three things make it nastier than a real error:
- Your backend never sees it. uvicorn, gunicorn, node — none of them log a request whose response was written into a closed socket. The worker keeps computing, finishes, writes the response to nobody, and logs success or nothing at all. The only witness is nginx.
- The work still costs you. My four chapters were four full LLM calls, paid for and completed. The failure was purely one of delivery: the result existed and no one was told.
- Mobile makes it the common case, not the edge case. Safari on iOS gives a foregrounded tab generous network timeouts, but the user does not: they lock the screen, switch apps, or the OS suspends the tab. A 30-second request on a phone is a coin flip you rig against yourself.
So the diagnosis rule, worth the price of this post on its own: a “stuck spinner” complaint is investigated in the nginx access log, not the application log. The application log only knows about requests that stayed alive long enough to matter.
grep 'POST /app/api' /var/log/nginx/access.log | awk '$9==499'
The fix is architectural, not a timeout
The tempting patches all treat the symptom. Raising proxy_read_timeout does nothing — nginx was happy to wait; the client left. Keeping the connection alive with heartbeats fights the phone’s power management and loses. The actual rule is older than nginx:
Nothing slow ever runs inside a browser request.
The standard answer is a task queue — Celery, Redis, a worker fleet. This app is one Python file on a two-vCPU box, and I wanted to keep it that way. The whole pattern fits in the framework you already have:
- The endpoint validates, records, and leaves. Everything refusable is refused synchronously — quota exceeded, empty input, book already finished — because refusals are cheap and belong in the request. Then it writes one row (“generation in progress”), hands the slow work to a thread, and returns
202 {"in_progress": true}. Measured: 0.08 s. - Progress state lives in the database, not in memory. A dict in the process would answer “is it done?” only to the process — and only until the next restart. A row survives a page reload, a second device, and a deploy. Mine is one row per book, book id as primary key, which also makes “only one generation per book” a constraint instead of a convention.
- The client polls. Every 3 seconds, against a status endpoint that reads the row. Unfashionable, robust, and it works identically for the tab that started the job and the tab that arrives ten minutes later. WebSockets would be strictly worse here: the connection they hold open is exactly the thing the phone keeps destroying.
The three bugs waiting inside the easy version
The naive version of the above works in a demo and then fails in three specific ways. I hit all three within a day, so you don’t have to.
1. A restart strands the state machine
Threads die with the process; database rows do not. Deploy while a generation is running and you get a row that says “in progress” forever — which in UI terms means a button that never comes back for that user. The fix is one line of humility at startup: mark every “in progress” row as interrupted before accepting traffic. The startup sequence is the only code that knows for certain no thread survived.
2. The second click
A user staring at a spinner for four seconds will press the button again. Two threads computing the same chapter would either duplicate it or, depending on how you assign sequence numbers, have the second silently overwrite the first. Two layers: the endpoint sees an existing “in progress” row and returns the same 202 it returned the first time — idempotent, not an error; the user did nothing wrong — and a per-book lock stands behind it for the race the row check loses.
3. The loser of a race reports the winner’s death
The subtlest one. When two threads do slip past, the loser eventually fails — and its cleanup wrote “generation failed” over a chapter the winner had just successfully delivered. The user sees an apology on top of a success. The fix: failure is recorded with a conditional update — set the error only where the in-progress row still exists. If the trace is already gone, someone finished the job properly, and the loser’s duty is silence. My test suite flagged this one as intermittent test flakiness first; it was a real production bug wearing a disguise.
One more contract: deploys must not break waiting tabs
The old client sent a POST and expected a chapter in the response body. The new server answers 202 with no chapter. Ship both at the same moment and any tab loaded before the deploy breaks. So the client was written to tolerate both shapes — if the response carries no in_progress flag it behaves exactly as before — and went out first. Boring versioning discipline, but it is the difference between a migration and an outage that lasts one browser-cache-lifetime.
The pattern in one paragraph: validate and refuse synchronously; record “in progress” in the database; do slow work in a thread; return 202 in under a tenth of a second; let the client poll; clear stranded state at startup; make the second click idempotent; make failure-reporting conditional so a loser can’t overwrite a winner. No queue, no broker, no new process — and no 499 since.
The app in question is Fablier — three questions each evening, and an AI writes your life as a book, one chapter per night. The four 499s belonged to a children’s adventure novel about an electric axe. The chapters were excellent; nobody saw them for an hour.
Previously: the errors in your journal probably don’t matter, nginx behind a Cloudflare proxy, and hardening a Debian 13 VPS.