nginx Upstream Handoff: Where TTFB Actually Goes

When TTFB is 900 ms and the application code has already been profiled, the remaining time is usually not in the application at all. It is in the handoff between nginx and whatever process is listening on a local port — a worker that was busy, a connection that had to be re-established, or a response that nginx buffered to disk before sending a single byte to the client. The application log reports 30 ms of work and the browser measures 900 ms, and both are correct.

This article covers the nginx side of that boundary for application servers reached over FastCGI, HTTP/1.1 on localhost, or a Unix socket. The settings below are the ones that change TTFB measurably; the goal is to stop guessing which of them matters by measuring the split between upstream wait time and connection setup time.

Split the Request Into Upstream Wait and Everything Else

nginx can log the exact time spent waiting for the upstream separately from the time spent serving the client. Enable a timing-oriented log format before changing any configuration, otherwise you are tuning against intuition.

log_format timing '$request $status '
  'urt=$upstream_response_time uct=$upstream_connect_time '
  'uht=$upstream_header_time rt=$request_time '
  'cs=$upstream_cache_status';
access_log /var/log/nginx/timing.log timing;

Compare uht (time to first byte from the upstream’s perspective) with urt (time for the upstream to finish the whole body). When uht is close to urt, the application is slow before it writes anything: queueing, connection setup, or a blocking call in the request path. When urt is far larger, the response body is being generated slowly while headers are already out, which is a different fix entirely.

Reuse Upstream Connections Instead of Dialling Per Request

By default, nginx opens a new connection to the upstream for every proxied request. On loopback that costs a TCP handshake and, more importantly, forces the application server to spawn a fresh worker or accept a new socket on each request. Keepalive to the upstream removes both, and on a small instance it is usually worth 20 to 60 ms of TTFB on the p95.

upstream app_backend {
    server unix:/run/app/gunicorn.sock;
    keepalive 32;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

location / {
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_pass http://app_backend;
}

The Connection "" line is mandatory: without it nginx sends Connection: close and keepalive silently does nothing, which is one of the most common misconfigurations in copy-pasted upstream blocks. keepalive 32 is a per-worker pool, so with four nginx workers you get up to 128 idle upstream connections; on a 1 GB instance that is fine, on a 512 MB one it is worth halving.

Kill Nagle and Delayed ACK With TCP_NODELAY

Loopback is not free of Nagle’s algorithm. Small header-then-body writes from the application server can be held for up to 40 ms waiting for an acknowledgement, which is exactly the kind of latency that shows up as p99 TTFB with a healthy median.

location / {
    proxy_set_header Connection "";
    tcp_nodelay on;
    proxy_socket_keepalive on;
}

tcp_nodelay is on by default in modern nginx, but it applies to the client side; the upstream side honours the kernel’s per-socket behaviour. If you control the application server, enable its own TCP_NODELAY. If you do not, a Unix socket bypasses TCP entirely and removes the question — which is often the single best change for a local app server.

Close the Connections You No Longer Need

A tuning guide that only adds settings is incomplete; two defaults actively add latency on small instances. proxy_connect_timeout and proxy_read_timeout default to 60 seconds, meaning a hung worker ties up a client connection and a worker for a full minute. And keepalive_timeout for clients at 75 seconds holds file descriptors far longer than most sites need.

keepalive_timeout 20s;
keepalive_requests 500;

proxy_connect_timeout 3s;
proxy_send_timeout 15s;
proxy_read_timeout 20s;

Shorter timeouts convert a stall into a fast 502, which is visible in monitoring, rather than an invisible slow request that never reaches your error budget. That trade is almost always correct for application servers, which should answer quickly or fail loudly. A single hung worker then produces one orphaned connection instead of a traffic queue behind it.

Stop Buffering Responses to Disk

nginx buffers upstream responses by default and spills to a temporary file once the buffer is full. For generated HTML in the tens of kilobytes this adds a write and a read on the same request path, and the file I/O competes with everything else on the disk.

proxy_buffering on;
proxy_buffers 8 8k;
proxy_buffer_size 8k;
proxy_busy_buffers_size 16k;
proxy_max_temp_file_size 0;   # never spill to disk

# streaming endpoints only:
# proxy_buffering off;

Disabling buffering entirely is the wrong lever for normal page requests: it makes nginx forward bytes as soon as they arrive, so a slow application holds a client connection longer and the client sees a partially rendered page. Keep buffering on, keep it in memory, and reserve proxy_buffering off for server-sent events and long-polling endpoints in their own location block.

Fix the Compression and Static Path First

Before tuning the upstream handoff, confirm the request is not hitting an application server at all when it should be served from disk. A surprising share of slow TTFB reports trace to static assets being proxied through the app because a location regex did not match.

location ~* \.(css|js|woff2|png|jpg|svg)$ {
    root /var/www/app/static;
    expires 30d;
    access_log off;
    try_files $uri =404;
}

gzip on;
gzip_types text/css application/javascript application/json;
gzip_min_length 1024;

Compression should be applied to text responses, never to already-compressed media, and gzip_min_length prevents spending CPU on tiny responses where the header overhead exceeds the saving. On a CPU-constrained instance, compressing responses above 1 KB only, at a modest level, is the right balance. If the stack is WordPress, the interaction between this layer and the application cache is the larger factor, and it is covered in cloud VPS benefits for cache-heavy workloads.

Validate the Change With a Load Generator, Not a Browser

Browser timing includes DNS, TLS and client-side work, all of which blur the server-side picture. Use a fixed-concurrency generator against a specific URL and compare p50 and p95 across configuration variants, changing one setting at a time.

wrk -t2 -c20 -d30s https://example.com/
curl -s -o /dev/null -w '%{time_starttransfer}\n' https://example.com/
# then read up= values from the timing log for the same window

Keep the results in a file next to the configuration. When a future deploy or a provider network change shifts TTFB again, a recorded baseline is the difference between a ten-minute diagnosis and an afternoon of re-benchmarking. Choosing an instance class whose CPU is not shared in a way that pollutes these numbers is a separate consideration covered in how VPS and dedicated server CPU guarantees compare.

Leave a Reply