{"id":1459,"date":"2026-10-21T23:04:58","date_gmt":"2026-10-21T23:04:58","guid":{"rendered":"https:\/\/virtualserversvps.com\/blog\/?p=1459"},"modified":"2026-10-21T23:04:58","modified_gmt":"2026-10-21T23:04:58","slug":"instrumenting-nginx-request-timing-p99-latency-vps","status":"publish","type":"post","link":"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/","title":{"rendered":"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A slow page is rarely one slow thing. It is a sum of intervals: time queued in the accept backlog, time waiting for a free PHP-FPM worker, time spent in the application, time waiting on the database, and time spent copying the response back through the socket. Nginx can record most of these intervals per request through <code>log_by_lua<\/code> and the built-in timing variables, which turns p99 latency debugging from guesswork into arithmetic. This tutorial builds that instrumentation, then shows how to read the output.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything below assumes nginx 1.24+ with <code>ngx_http_lua_module<\/code> (the <code>libnginx-mod-http-lua<\/code> package on Debian and Ubuntu). It works identically on a <a href=\"https:\/\/virtualserversvps.com\/\">virtual server running the standard nginx build<\/a> or on any provider with the module installed.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What the built-in timing variables already tell you<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before adding any Lua, know which timings nginx exposes natively. Each is milliseconds, and each starts at a different point in the request lifecycle.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Variable<\/th><th>What it measures<\/th><th>Typical suspect when high<\/th><\/tr><\/thead><tbody><tr><td>$request_time<\/td><td>First byte read to last byte sent<\/td><td>Total, including client slowness<\/td><\/tr><tr><td>$upstream_response_time<\/td><td>Connect + send + receive from backend<\/td><td>PHP-FPM queue, DB, app logic<\/td><\/tr><tr><td>$upstream_connect_time<\/td><td>TCP connect to backend<\/td><td>Backlog saturation, keepalive disabled<\/td><\/tr><tr><td>$upstream_header_time<\/td><td>Up to first response header byte<\/td><td>Application time before any output<\/td><\/tr><tr><td>$request_length<\/td><td>Bytes received from client<\/td><td>Large uploads, abuse<\/td><\/tr><tr><td>$body_bytes_sent<\/td><td>Bytes sent to client<\/td><td>Uncompressed payloads<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The gap <code>$request_time &minus; $upstream_response_time<\/code> is nginx-side overhead plus client transfer time. On a healthy LAN-proxied stack that gap should stay under 5 ms for small responses. If it exceeds 50 ms while <code>$upstream_response_time<\/code> is flat, your bottleneck is the network path or client-side transfer, not the application.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Instrument the two gaps nginx does not expose<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two intervals matter and are invisible in the default variables: time spent in the rewrite\/access phase before proxying, and time from upstream completion to the end of the request. Capture them with a small Lua block in the <code>http<\/code> context.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>lua_shared_dict phase_timing 10m;<br><br>init_by_lua_block { require \"resty.core\" }<br><br>log_by_lua_block {<br>&nbsp;&nbsp;local now = ngx.now() * 1000<br>&nbsp;&nbsp;local start = ngx.ctx.app_start<br>&nbsp;&nbsp;local up_end = ngx.ctx.upstream_end<br>&nbsp;&nbsp;if start and up_end then<br>&nbsp;&nbsp;&nbsp;&nbsp;local pre = (start - ngx.req.start_time() * 1000)<br>&nbsp;&nbsp;&nbsp;&nbsp;local post = now - up_end<br>&nbsp;&nbsp;&nbsp;&nbsp;local dict = ngx.shared.phase_timing<br>&nbsp;&nbsp;&nbsp;&nbsp;dict:incr(\"pre_ms\", math.floor(pre), 0)<br>&nbsp;&nbsp;&nbsp;&nbsp;dict:incr(\"post_ms\", math.floor(post), 0)<br>&nbsp;&nbsp;&nbsp;&nbsp;dict:incr(\"req_count\", 1, 0)<br>&nbsp;&nbsp;end<br>}<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the server or location block, mark the boundaries around the proxy call:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>set_by_lua_block $app_mark { ngx.ctx.app_start = ngx.now() * 1000; return \"\" }<br>log_by_lua_block { ngx.ctx.upstream_end = ngx.now() * 1000 }<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">These are coarse averages, not percentiles, but they are enough to answer the first question in any investigation: is latency being added before the backend, inside it, or after it? Expose the counters on a private status endpoint with <code>content_by_lua_block { ngx.say(require(\"cjson\").encode(ngx.shared.phase_timing:get_keys(0))) }<\/code> restricted to <code>127.0.0.1<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Build a structured log format for percentile analysis<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Human-readable logs cannot be aggregated. Emit JSON with every timing field so you can compute percentiles with <code>jq<\/code> or ship them to Prometheus via a log exporter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>log_format timing escape=json '{\"t\":\"$time_iso8601\",\"uri\":\"$uri\",\"status\":$status,'<br>' \"rt\":$request_time,\"urt\":$upstream_response_time,'<br>' \"uct\":$upstream_connect_time,\"uht\":$upstream_header_time,'<br>' \"sz\":$body_bytes_sent,\"up\":\"$upstream_addr\"}';<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reload with <code>nginx -t &amp;&amp; systemctl reload nginx<\/code>, generate load for ten minutes, then compute p50 and p99 directly:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>jq -r 'select(.urt != null) | .urt' \/var\/log\/nginx\/timing.log | sort -n | awk '{a[NR]=$1} END {print \"p50=\"a[int(NR*0.5)]\" p99=\"a[int(NR*0.99)]\" n=\"NR}'<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note that <code>$upstream_response_time<\/code> can contain a comma-separated list when a request is retried against multiple upstreams. For percentile maths you need the total, so either sum the values in Lua or configure <code>proxy_next_upstream off<\/code> for the specific location you are measuring.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Separate queue time from service time<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On a small instance the most common cause of rising p99 is PHP-FPM pool saturation: requests sit in the listen backlog until a worker frees up. Nginx sees that wait as upstream connect time, because the connection is accepted but no worker reads it. Cross-check the two sides:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>In nginx, p99 of <code>$upstream_connect_time<\/code> above 20 ms on loopback is a red flag.<\/li>\n\n\n\n<li>In PHP-FPM, set <code>request_slowlog_timeout = 5s<\/code> and read the slowlog for requests that never even reach the application.<\/li>\n\n\n\n<li><code>ss -lnt '( sport = :9000 )'<\/code> &mdash; a non-zero Send-Q on the listen socket confirms a full accept queue.<\/li>\n\n\n\n<li>During the test, run <code>pidstat -u -p $(pgrep -d, php-fpm8.3) 1 10<\/code> to see whether workers are CPU-busy or blocked in <code>D<\/code> state.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If connect time is high and workers are blocked in <code>D<\/code>, the problem is disk I\/O, and no amount of nginx tuning will help. If workers are busy on CPU, the fix is more children or a faster path; if the accept queue is full and CPU is idle, you have an I\/O or lock contention problem hiding behind it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Attribute the gap to database calls with request IDs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Generate a request ID at the edge and pass it to PHP, then log it in your database layer:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>map $request_id $proxied_req_id { default $request_id; }<br>proxy_set_header X-Request-ID $proxied_req_id;<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In PHP, log <code>$_SERVER['HTTP_X_REQUEST_ID']<\/code> alongside each query&#8217;s duration. Now a slow nginx entry maps to a specific set of queries without guesswork. This is the single highest-value change when a page shows high <code>$upstream_header_time<\/code> but flat CPU usage &mdash; almost always a query waiting on a lock or a table scan.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Turn the numbers into a decision rule<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After a week of data, classify each slow request against the timing table above. Requests with high <code>$upstream_header_time<\/code> and low CPU are application or database problems. Requests with high <code>$upstream_connect_time<\/code> are pool sizing problems. Requests with a large gap between <code>$request_time<\/code> and <code>$upstream_response_time<\/code> are network or client problems, frequently large uncompressed payloads &mdash; check whether <code>gzip<\/code> or <code>brotli<\/code> is enabled for that content type. Requests with high post-upstream time on small responses point to <code>sendfile<\/code> or TLS record sizing issues.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the raw JSON logs for at least 72 hours and rotate them by size, not by day, because high-traffic hours can produce a gigabyte in a few hours. The measurement layer described here complements the broader approaches in the <a href=\"https:\/\/virtualserversvps.com\/vps-vs-shared-hosting\">hosting tier performance comparison<\/a> &mdash; instrumentation is what makes a plan upgrade decision provable instead of speculative.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A slow page is rarely one slow thing. It is a sum of intervals: time queued in the accept backlog, time waiting for a free PHP-FPM worker, time spent in&#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":0,"footnotes":""},"categories":[1],"tags":[],"class_list":["post-1459","post","type-post","status-publish","format-standard","hentry","category-vps-guides-tutorials"],"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>Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From - Virtual Servers VPS Blog<\/title>\n<meta name=\"description\" content=\"A slow page is rarely one slow thing. It is a sum of intervals: time queued in the accept backlog, time waiting for a free PHP-FPM worker, time spent in\" \/>\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\/instrumenting-nginx-request-timing-p99-latency-vps\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From\" \/>\n<meta property=\"og:description\" content=\"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From\" \/>\n<meta property=\"og:url\" content=\"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/\" \/>\n<meta property=\"og:site_name\" content=\"Virtual Servers VPS Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-21T23:04:58+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\/instrumenting-nginx-request-timing-p99-latency-vps\/\",\"url\":\"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/\",\"name\":\"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From - Virtual Servers VPS Blog\",\"isPartOf\":{\"@id\":\"https:\/\/virtualserversvps.com\/blog\/#website\"},\"datePublished\":\"2026-10-21T23:04:58+00:00\",\"author\":{\"@id\":\"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/82a299a8284a66ff49f97c74684724a0\"},\"description\":\"A slow page is rarely one slow thing. It is a sum of intervals: time queued in the accept backlog, time waiting for a free PHP-FPM worker, time spent in\",\"breadcrumb\":{\"@id\":\"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/virtualserversvps.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From\"}]},{\"@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":"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From - Virtual Servers VPS Blog","description":"A slow page is rarely one slow thing. It is a sum of intervals: time queued in the accept backlog, time waiting for a free PHP-FPM worker, time spent in","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\/instrumenting-nginx-request-timing-p99-latency-vps\/","og_locale":"en_US","og_type":"article","og_title":"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From","og_description":"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From","og_url":"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/","og_site_name":"Virtual Servers VPS Blog","article_published_time":"2026-10-21T23:04:58+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\/instrumenting-nginx-request-timing-p99-latency-vps\/","url":"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/","name":"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From - Virtual Servers VPS Blog","isPartOf":{"@id":"https:\/\/virtualserversvps.com\/blog\/#website"},"datePublished":"2026-10-21T23:04:58+00:00","author":{"@id":"https:\/\/virtualserversvps.com\/blog\/#\/schema\/person\/82a299a8284a66ff49f97c74684724a0"},"description":"A slow page is rarely one slow thing. It is a sum of intervals: time queued in the accept backlog, time waiting for a free PHP-FPM worker, time spent in","breadcrumb":{"@id":"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/virtualserversvps.com\/blog\/instrumenting-nginx-request-timing-p99-latency-vps\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/virtualserversvps.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Instrumenting nginx Request Timing to Find Where p99 Latency Actually Comes From"}]},{"@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\/1459","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=1459"}],"version-history":[{"count":1,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/posts\/1459\/revisions"}],"predecessor-version":[{"id":1462,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/posts\/1459\/revisions\/1462"}],"wp:attachment":[{"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/media?parent=1459"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/categories?post=1459"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/virtualserversvps.com\/blog\/wp-json\/wp\/v2\/tags?post=1459"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}