{"id":1536,"date":"2026-10-28T22:41:43","date_gmt":"2026-10-28T22:41:43","guid":{"rendered":"https:\/\/virtualserversvps.com\/blog\/?p=1536"},"modified":"2026-10-28T22:41:43","modified_gmt":"2026-10-28T22:41:43","slug":"nginx-upstream-ttfb-upstream-keepalive-tuning","status":"publish","type":"post","link":"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/","title":{"rendered":"nginx Upstream Handoff: Where TTFB Actually Goes"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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 \u2014 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Split the Request Into Upstream Wait and Everything Else<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><code>log_format timing '$request $status '\n  'urt=$upstream_response_time uct=$upstream_connect_time '\n  'uht=$upstream_header_time rt=$request_time '\n  'cs=$upstream_cache_status';\naccess_log \/var\/log\/nginx\/timing.log timing;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Compare <code>uht<\/code> (time to first byte from the upstream&#8217;s perspective) with <code>urt<\/code> (time for the upstream to finish the whole body). When <code>uht<\/code> is close to <code>urt<\/code>, the application is slow before it writes anything: queueing, connection setup, or a blocking call in the request path. When <code>urt<\/code> is far larger, the response body is being generated slowly while headers are already out, which is a different fix entirely.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Reuse Upstream Connections Instead of Dialling Per Request<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><code>upstream app_backend {\n    server unix:\/run\/app\/gunicorn.sock;\n    keepalive 32;\n    keepalive_requests 1000;\n    keepalive_timeout 60s;\n}\n\nlocation \/ {\n    proxy_http_version 1.1;\n    proxy_set_header Connection \"\";\n    proxy_pass http:\/\/app_backend;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>Connection \"\"<\/code> line is mandatory: without it nginx sends <code>Connection: close<\/code> and keepalive silently does nothing, which is one of the most common misconfigurations in copy-pasted upstream blocks. <code>keepalive 32<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Kill Nagle and Delayed ACK With TCP_NODELAY<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Loopback is not free of Nagle&#8217;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.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><code>location \/ {\n    proxy_set_header Connection \"\";\n    tcp_nodelay on;\n    proxy_socket_keepalive on;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>tcp_nodelay<\/code> is on by default in modern nginx, but it applies to the client side; the upstream side honours the kernel&#8217;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 \u2014 which is often the single best change for a local app server.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Close the Connections You No Longer Need<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A tuning guide that only adds settings is incomplete; two defaults actively add latency on small instances. <code>proxy_connect_timeout<\/code> and <code>proxy_read_timeout<\/code> default to 60 seconds, meaning a hung worker ties up a client connection and a worker for a full minute. And <code>keepalive_timeout<\/code> for clients at 75 seconds holds file descriptors far longer than most sites need.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><code>keepalive_timeout 20s;\nkeepalive_requests 500;\n\nproxy_connect_timeout 3s;\nproxy_send_timeout 15s;\nproxy_read_timeout 20s;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stop Buffering Responses to Disk<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><code>proxy_buffering on;\nproxy_buffers 8 8k;\nproxy_buffer_size 8k;\nproxy_busy_buffers_size 16k;\nproxy_max_temp_file_size 0;   # never spill to disk\n\n# streaming endpoints only:\n# proxy_buffering off;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>proxy_buffering off<\/code> for server-sent events and long-polling endpoints in their own <code>location<\/code> block.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Fix the Compression and Static Path First<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>location<\/code> regex did not match.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><code>location ~* \\.(css|js|woff2|png|jpg|svg)$ {\n    root \/var\/www\/app\/static;\n    expires 30d;\n    access_log off;\n    try_files $uri =404;\n}\n\ngzip on;\ngzip_types text\/css application\/javascript application\/json;\ngzip_min_length 1024;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Compression should be applied to text responses, never to already-compressed media, and <code>gzip_min_length<\/code> 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 <a href=\"https:\/\/virtualserversvps.com\/cloud-vps-benefits.html\">cloud VPS benefits for cache-heavy workloads<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Validate the Change With a Load Generator, Not a Browser<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><code>wrk -t2 -c20 -d30s https:\/\/example.com\/\ncurl -s -o \/dev\/null -w '%{time_starttransfer}\\n' https:\/\/example.com\/\n# then read up= values from the timing log for the same window<\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/virtualserversvps.com\/vps-vs-dedicated-server.html\">how VPS and dedicated server CPU guarantees compare<\/a>.<\/p>\n\n","protected":false},"excerpt":{"rendered":"<p>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&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"iawp_total_views":1,"footnotes":""},"categories":[3],"tags":[],"class_list":["post-1536","post","type-post","status-publish","format-standard","hentry","category-performance-optimization"],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v26.1 (Yoast SEO v26.1) - https:\/\/yoast.com\/wordpress\/plugins\/seo\/ -->\n<title>nginx Upstream Handoff: Where TTFB Actually Goes - Virtual Servers VPS Blog<\/title>\n<meta name=\"description\" content=\"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\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"nginx Upstream Handoff: Where TTFB Actually Goes\" \/>\n<meta property=\"og:description\" content=\"nginx Upstream Handoff: Where TTFB Actually Goes\" \/>\n<meta property=\"og:url\" content=\"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/\" \/>\n<meta property=\"og:site_name\" content=\"Virtual Servers VPS Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-28T22:41:43+00:00\" \/>\n<meta name=\"author\" content=\"Virtual-Servers-Vps-Editor\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Virtual-Servers-Vps-Editor\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"6 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/\",\"url\":\"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/\",\"name\":\"nginx Upstream Handoff: Where TTFB Actually Goes - Virtual Servers VPS Blog\",\"isPartOf\":{\"@id\":\"https:\/\/virtualserversvps.com\/blog\/#website\"},\"datePublished\":\"2026-10-28T22:41:43+00:00\",\"author\":{\"@id\":\"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/82a299a8284a66ff49f97c74684724a0\"},\"description\":\"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\",\"breadcrumb\":{\"@id\":\"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/virtualserversvps.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"nginx Upstream Handoff: Where TTFB Actually Goes\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/virtualserversvps.com\/blog\/#website\",\"url\":\"https:\/\/virtualserversvps.com\/blog\/\",\"name\":\"Virtual Servers VPS Blog\",\"description\":\"\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/virtualserversvps.com\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Person\",\"@id\":\"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/82a299a8284a66ff49f97c74684724a0\",\"name\":\"Virtual-Servers-Vps-Editor\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/d820b15f1cd028e97610d9adf536df7be5cb6423869967037d468d5355fa003f?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/d820b15f1cd028e97610d9adf536df7be5cb6423869967037d468d5355fa003f?s=96&d=mm&r=g\",\"caption\":\"Virtual-Servers-Vps-Editor\"},\"sameAs\":[\"https:\/\/virtualserversvps.com\/blog\"],\"url\":\"https:\/\/virtualserversvps.com\/blog\/author\/virtualserversvps\/\"}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"nginx Upstream Handoff: Where TTFB Actually Goes - Virtual Servers VPS Blog","description":"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","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/","og_locale":"en_US","og_type":"article","og_title":"nginx Upstream Handoff: Where TTFB Actually Goes","og_description":"nginx Upstream Handoff: Where TTFB Actually Goes","og_url":"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/","og_site_name":"Virtual Servers VPS Blog","article_published_time":"2026-10-28T22:41:43+00:00","author":"Virtual-Servers-Vps-Editor","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Virtual-Servers-Vps-Editor","Est. reading time":"6 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/","url":"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/","name":"nginx Upstream Handoff: Where TTFB Actually Goes - Virtual Servers VPS Blog","isPartOf":{"@id":"https:\/\/virtualserversvps.com\/blog\/#website"},"datePublished":"2026-10-28T22:41:43+00:00","author":{"@id":"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/82a299a8284a66ff49f97c74684724a0"},"description":"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","breadcrumb":{"@id":"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/virtualserversvps.com\/blog\/nginx-upstream-ttfb-upstream-keepalive-tuning\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/virtualserversvps.com\/blog\/"},{"@type":"ListItem","position":2,"name":"nginx Upstream Handoff: Where TTFB Actually Goes"}]},{"@type":"WebSite","@id":"https:\/\/virtualserversvps.com\/blog\/#website","url":"https:\/\/virtualserversvps.com\/blog\/","name":"Virtual Servers VPS Blog","description":"","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/virtualserversvps.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Person","@id":"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/82a299a8284a66ff49f97c74684724a0","name":"Virtual-Servers-Vps-Editor","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/d820b15f1cd028e97610d9adf536df7be5cb6423869967037d468d5355fa003f?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/d820b15f1cd028e97610d9adf536df7be5cb6423869967037d468d5355fa003f?s=96&d=mm&r=g","caption":"Virtual-Servers-Vps-Editor"},"sameAs":["https:\/\/virtualserversvps.com\/blog"],"url":"https:\/\/virtualserversvps.com\/blog\/author\/virtualserversvps\/"}]}},"_links":{"self":[{"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/posts\/1536","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/comments?post=1536"}],"version-history":[{"count":1,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/posts\/1536\/revisions"}],"predecessor-version":[{"id":1537,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/posts\/1536\/revisions\/1537"}],"wp:attachment":[{"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/media?parent=1536"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/categories?post=1536"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/tags?post=1536"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}