4.6 Gateway Behavior: Timeouts, Buffers, Failures, Bodies
A proxy is judged by its worst backend. This lesson is the toolkit that keeps one slow, one broken, and one greedy consumer from defining your service.
Intermediate
What you will learn
The timeout triad — connect, send, read — and what each one guards.
Response buffering: the default, the streaming exceptions, and the memory model behind them.
proxy_intercept_errors— when the edge should own backend failures.client_max_body_sizeand the 413 contract.The failure taxonomy: 502 vs 504 vs 503, and what NGINX can and cannot retry.
1. The timeout triad
Every proxied request runs against three independent clocks (defaults in parentheses):
proxy_connect_timeout 60s; # (60s) TCP handshake to the upstream
proxy_send_timeout 60s; # (60s) writing the REQUEST upstream, between successes
proxy_read_timeout 60s; # (60s) reading the RESPONSE, between successesThe precision matters:
send and read measure silence, not totals. A response that trickles one chunk every second never trips a 1-second
proxy_read_timeout — only a
stall does. (Same law as the WebSocket and gRPC lessons; one mental model, three protocols.)
The Try it yourself exercise below demonstrates the whole triad with two locations against the slow backend (
b-node-slow, 2 s per response) and its
/hang route (never answers):
location /slowok/ {
proxy_pass http://127.0.0.1:9004/slow; # 2s backend, default 60s clock
}
location /timeout/ {
proxy_pass http://127.0.0.1:9004/hang; # silence, capped at 1s
proxy_read_timeout 1s;
}Real captures:
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: gw.example.test' \
http://127.0.0.1:8400/slowok/?delay=1.5
200 # slow but within the clock
$ curl -s -o /dev/null -w '%{http_code}' -H 'Host: gw.example.test' \
http://127.0.0.1:8400/timeout/ # …timed after 1.0s…
504The 504 landed after
1.0 seconds — the timeout fired, NGINX wrote the gateway-timeout response, and the error log recorded the autopsy:
upstream timed out (110: Connection timed out) while reading response header from upstream … upstream: "http://127.0.0.1:9004/hang".
2. Buffering: the default and its exceptions
By default, NGINX buffers the upstream response: the backend writes at its pace, NGINX reads into buffers, and the client is served at the client's pace. The backend finishes fast (its worker is freed), and slow clients don't hold the app hostage:
buffered: backend ──▶ [NGINX memory/disk buffers] ──▶ slow client
unbuffered: backend ──────────▶ client (backend waits for the client!)The directive and its friends:
proxy_buffering on; # default
proxy_buffer_size 4k; # the FIRST chunk: headers + start of body
proxy_buffers 8 4k; # body buffers (count × size)
proxy_busy_buffers_size 8k; # how much may stream to the client while
# the rest of the response is still arriving
proxy_max_temp_file_size 1024m; # oversized responses spill here (disk)
Turn it off (
proxy_buffering off;) for the streaming population — SSE (
text/event-stream), NDJSON/CSV pipes, progress bars, the chunked stream below:
location /stream {
proxy_pass http://127.0.0.1:9001;
proxy_buffering off; # chunks reach the client as generated
}$ curl -s -H 'Host: gw.example.test' 'http://127.0.0.1:8400/stream?count=3'
chunk 1/3 from b-node-1
chunk 2/3 from b-node-1
chunk 3/3 from b-node-1(With buffering on and a small body, the client sees the same bytes — the difference is
timing: watch with
curl -N and you'll see the chunks arrive 0.3s apart unbuffered, all-at-once buffered.)
Two more levers worth knowing now:
X-Accel-Buffering: no — a response header
the backend can send to disable buffering per-response (NGINX honors it; your apps can self-declare streaming) — and
proxy_cache's requirement for buffering (Module 8: you can't cache what you don't hold).
3.
proxy_intercept_errors — who owns the failure?
By default, backend error statuses pass through verbatim: the client sees the backend's raw 500 page, its 503 JSON, its framework's stack trace (disguised as "helpful"). The edge's alternative:
location /dead/ {
proxy_pass http://127.0.0.1:9999; # nothing listens there
proxy_intercept_errors on; # NGINX takes over error bodies
error_page 502 504 /errors/50x.html; # …with ITS page
}
location /dead-raw/ {
proxy_pass http://127.0.0.1:9999; # no interception
}The output below captures both behaviors against the same dead upstream:
$ curl -s -H 'Host: gw.example.test' http://127.0.0.1:8400/dead/ | head -3
<!DOCTYPE html>… <h1>50x</h1>
<p>Custom 50x error page … ← NGINX's branded page
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: gw.example.test' \
http://127.0.0.1:8400/dead-raw/
502 ← raw status relayed, default body
When to intercept: brand-consistent UX, information hygiene (don't leak backend internals), and uniform monitoring (the
edge is the failure authority).
When not to: when backends return meaningful error bodies (API error JSON with codes — clients parse those!). The interception only applies to statuses listed in
error_page and, by default, to responses ≥ 300 — the response’s size is irrelevant: interception is a status-code policy, applied before the body is considered. FastCGI/uWSGI/gRPC all have their
-intercept_errors twins (Lesson 4.3/4.5).
4. Bodies at the door:
client_max_body_size
location /upload/ {
client_max_body_size 1k; # anything larger dies at the door
proxy_pass http://127.0.0.1:9001;
}$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: gw.example.test' \
-X POST --data-binary "$(head -c 2048 /dev/zero | tr '\0' 'a')" \
http://127.0.0.1:8400/upload/
413 # refused before proxying
$ curl -s -X POST --data 'hello=world' … http://127.0.0.1:8400/upload/ | jq -r .body
hello=world # small bodies round-tripThree facts that make this directive worth a whole section:
The default is 1 MB — and the failure mode is ugly: browsers get a plain 413 page, and the connection is closed (no graceful
Connection: keep-alivedance), which is why uploads "fail weird." Size it deliberately per route (Lesson 2.5's ladder).It guards the app, not just NGINX — the body is rejected before any upstream byte is spent. The right number is the app's real maximum, not "generous."
It composes with buffering — bodies larger than
client_body_buffer_sizespill toclient_body_temp_path(Lesson 1.3's temp areas!) while NGINX decides; huge uploads are a disk-I/O event, which is why Module 9 revisits them.
5. The failure taxonomy
By the end of this module you have produced the big three — memorize their signatures:
Status | Meaning | NGINX-side cause | Typical fix |
|---|---|---|---|
502 Bad Gateway | upstream unreachable / protocol violation |
| backend dead, wrong port, or speaking the wrong dialect (Lesson 4.3's uwsgi story) |
504 Gateway Timeout | upstream too silent |
| slow backend, or timeouts mis-scoped |
503 Service Unavailable | no upstream available at all | pool exhausted, all peers marked down, maintenance | Module 5's failure detection — and its
|
What can be retried? Only
idempotent requests, and only before a byte of response exists:
proxy_next_upstream error timeout; (Module 5's curriculum) re-routes connect failures and header-phase timeouts to another peer. A POST is excluded unless you opt in (
non_idempotent); a timeout
mid-body is never retried (bytes already flowed). The
/timeout/ 504 came from a single fixed address, so no retry was possible — Module 5 turns the same experiment into a pool and watches it self-heal.
Try it yourself
The timeout A/B (§1): hit
/slowok/?delay=3(200 — wait for it) and/timeout/(504 at 1s). Time both withcurl -w '%{time_total}'and read the error-log autopsy.Feel the buffering difference. With
curl -N(no buffering on the client side), request/stream?count=5— chunks arrive 0.3s apart. Then re-enable buffering on that location and observe them coalesce.Intercept your own failure. Add a location proxying to another dead port with a custom
error_page 502of your own; verify your page (and only for the statuses you listed).Body-limit surgery. Set
client_max_body_size 0;(unlimited) on/upload/, upload something enormous (careful), watch the temp dir grow:ls /var/cache/nginx/client_temp/— Lesson 1.3's temp areas, earning their keep.Taxonomy safari. Produce all three big failures deliberately: 502 (
/dead-raw/), 504 (/timeout/), and — after Module 5 — a genuine 503. Your error log should end up with one annotated entry for each.
Q&A
Q1. Backend streams a response chunk every 0.5s for ten minutes;
proxy_read_timeout 5s. Does it complete? Why?
Answer
Yes — read timeout measures gaps, not totals. Every chunk resets the clock; the maximum observed silence (0.5s) never approaches 5s.
Q2. When is
proxy_buffering off the
wrong choice even for a streaming endpoint?
Answer
When you want to cache it (Module 8 needs the bytes held), when slow clients would hold backend workers hostage (the whole point of buffering), or when the "stream" is small enough that latency of first-byte vs. total time doesn't matter. Scope it to genuinely chunk-latency-sensitive routes.
Q3. Your API returns structured JSON error bodies (4xx with error codes) that clients parse. Should
proxy_intercept_errors be on?
Answer
Not globally — interception would replace those bodies. Either off, or a narrow
error_page list covering only the
infrastructure codes (502/503/504) so application 4xx/5xx semantics survive to the client.
Q4. A 2 MB upload gets 413 with
client_max_body_size 1m;. The developer asks "why did the server
close the connection instead of a normal error?" Correct answer?
Answer
That's the defined 413 behavior: NGINX (must/chooses to) terminate rather than drain a body it refuses to process — the spec-permitted response when Content-Length already exceeds the limit. Clients see a hard reset; that's expected, and the fix is the right-sized limit, not a retry strategy.
Takeaways
Three clocks per proxied request — connect (keep it short), send and read (size to legitimate silence, scope with the location ladder).
Buffering on by default protects backends from slow clients; disable per-route for genuine streaming, or let backends self-declare via
X-Accel-Buffering: no.proxy_intercept_errors+error_pagemake the edge the authority on failure UX — deliberately, and never over structured API errors.client_max_body_sizeis a contract with your app's real limits; the 1 MB default surprises everyone exactly once.502 (unreachable/protocol), 504 (silence), 503 (no peer left) — and only idempotent, pre-response failures are retryable (Module 5's job).
Module 4 complete. You can now put NGINX in front of anything that speaks: HTTP, FastCGI, uWSGI, WebSockets, gRPC — and shape how it behaves under slowness, failure, and greed. Next: Module 5 — the pool: upstream blocks, scheduling algorithms, failure detection, and connection reuse. One backend was a convenience; many is a system.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.