Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~40 min read

Intermediate

What you will learn

  1. The timeout triad — connect, send, read — and what each one guards.

  2. Response buffering: the default, the streaming exceptions, and the memory model behind them.

  3. proxy_intercept_errors — when the edge should own backend failures.

  4. client_max_body_size and the 413 contract.

  5. 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):

nginx
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 successes

The 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):

nginx
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:

bash
$ 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…
504

The 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:

text
buffered:   backend ──▶ [NGINX memory/disk buffers] ──▶ slow client
unbuffered: backend ──────────▶ client        (backend waits for the client!)

The directive and its friends:

nginx
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:

nginx
location /stream {
    proxy_pass http://127.0.0.1:9001;
    proxy_buffering  off;      # chunks reach the client as generated
}
bash
$ 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:

nginx
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:

bash
$ 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

nginx
location /upload/ {
    client_max_body_size  1k;      # anything larger dies at the door
    proxy_pass http://127.0.0.1:9001;
}
bash
$ 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-trip

Three facts that make this directive worth a whole section:

  1. 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-alive dance), which is why uploads "fail weird." Size it deliberately per route (Lesson 2.5's ladder).

  2. 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."

  3. It composes with buffering — bodies larger than client_body_buffer_size spill to client_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

connect() failed (111: Connection refused), invalid response

backend dead, wrong port, or speaking the wrong dialect (Lesson 4.3's uwsgi story)

504 Gateway Timeout

upstream too silent

proxy_read_timeout/ proxy_connect_timeout fired

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 backup servers

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

  1. The timeout A/B (§1): hit /slowok/?delay=3 (200 — wait for it) and /timeout/ (504 at 1s). Time both with curl -w '%{time_total}' and read the error-log autopsy.

  2. 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.

  3. Intercept your own failure. Add a location proxying to another dead port with a custom error_page 502 of your own; verify your page (and only for the statuses you listed).

  4. 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.

  5. 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_page make the edge the authority on failure UX — deliberately, and never over structured API errors.

  • client_max_body_size is 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.