Site icon GetPageSpeed

NGINX Early Hints: HTTP 103 Benchmarked on Enterprise Linux

NGINX Early Hints: HTTP 103 Benchmarked on Enterprise Linux

NGINX early hints support landed in NGINX 1.29.0 as the early_hints directive. Every stock Enterprise Linux, Debian and Ubuntu package still ships something older, so the directive is rejected outright. This article installs a current NGINX from GetPageSpeed, configures the safe HTTP/2 gate, and benchmarks what HTTP 103 actually does to LCP: 184 ms faster at the median, plus one configuration mistake that returns 502.

Short answer: yes, but only when your origin has real think-time. Across 60 interleaved cold-cache runs with a 400 ms origin delay, passing HTTP 103 cut LCP from 748 ms to 564 ms at the median and from 748 ms to 568 ms at p75. Repeat the same test against a zero-delay origin and the measured difference is exactly zero.

The catch is that early_hints requires NGINX 1.29.0 or newer, and no distribution ships that yet.

Install a current NGINX

RHEL / CentOS / AlmaLinux / Rocky Linux / Amazon Linux

sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx

That gives you nginx-1.30.4 on EL8, EL9 and EL10, verified at the time of writing. Check it:

nginx -v
# nginx version: nginx/1.30.4

Debian / Ubuntu

First, set up the GetPageSpeed APT repository, then:

sudo apt-get update
sudo apt-get install nginx

The APT repository carries 1.30.4 for Ubuntu jammy and noble, and for Debian bookworm.

Why your distribution’s NGINX will not do this

The early_hints directive appeared in NGINX 1.29.0. Every currently supported stock package predates it. These versions were read from the distributions’ own repositories:

Distribution Stock NGINX early_hints
RHEL / Rocky / Alma 8 module streams 1.14 to 1.24 No
RHEL / Rocky / Alma 9 1.20.1 No
RHEL / Rocky / Alma 10 1.26.3 No
Debian 12 (bookworm) 1.22.1 No
Debian 13 (trixie) 1.26.3 No
Ubuntu 22.04 LTS 1.18.0 No
Ubuntu 24.04 LTS 1.24.0 No
Ubuntu 26.04 LTS 1.28.3 No
GetPageSpeed 1.30.4 Yes

Even Ubuntu 26.04 LTS “Resolute Raccoon”, released in April 2026, stops at 1.28.3. On a clean Rocky Linux 10 box running its own nginx-1.26.3-6.el10_2.5, the directive fails the config test immediately:

nginx: [emerg] unknown directive "early_hints" in /etc/nginx/conf.d/eh.conf:9
nginx: configuration file /etc/nginx/nginx.conf test failed

After installing nginx-1.30.4-62.el10.gps from the GetPageSpeed repository, the same config file passes. That version difference is the entire prerequisite.

What NGINX early hints actually does

This is the part most write-ups get wrong, so it is worth stating plainly.

NGINX does not generate early hints. It has no directive that turns a list of assets into Link headers. What early_hints does is pass through a 103 Early Hints response that your upstream already produced, unmodified. The NGINX documentation is explicit: 103 responses received from an upstream server are passed to a client as is, without interpretation.

Two consequences follow:

  1. Your application has to emit the 103 itself. Node.js has had response.writeEarlyHints() since v18.11.0, and Rails exposes request.send_early_hints. Elsewhere it may mean reaching for a lower-level API or a middleware. Either way it is opt-in work in your code, not a config flag in NGINX.
  2. Only proxy_pass and grpc_pass forward early hints. In the NGINX source, pass_early_hints is hardcoded to 1 in the proxy and gRPC modules only. FastCGI, uwsgi and SCGI upstreams do not forward interim responses, so a plain PHP-FPM site cannot use this without a proxy layer in front.

If your upstream sends no 103, adding early_hints changes nothing. That was verified directly: with the origin’s 103 suppressed, the client receives only the final 200 even with early_hints enabled.

Configuring NGINX early hints safely

The early_hints directive takes one or more values and passes the 103 when at least one of them is non-empty and not "0". It is valid in http, server and location contexts.

Do not enable it unconditionally. Interim responses are a compatibility hazard on HTTP/1.1, where intermediaries and older clients may mishandle them, and RFC 8297 recommends sending 103 only over HTTP/2 or later. Browsers act on early hints only for top-level navigations anyway, so hinting a fetch or XHR is wasted work.

This is the gate from the official documentation, and it handles both concerns at once:

map $http_sec_fetch_mode $early_hints {
    navigate  $http2$http3;
}

server {
    listen 443 ssl;
    http2 on;

    location / {
        early_hints $early_hints;

        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_pass http://127.0.0.1:8090;
    }
}

$http_sec_fetch_mode is navigate only for top-level document loads. $http2 and $http3 are empty on HTTP/1.1, so the concatenation is empty and the predicate is false. The result is that hints go out on HTTP/2 and HTTP/3 navigations, and nowhere else.

Verify it before you trust it

Run these against your own server. All four were confirmed on the Rocky Linux 10 test host.

An HTTP/2 navigation gets the 103 first, then the 200:

curl -k --http2 -H "Sec-Fetch-Mode: navigate" -o /dev/null -D- https://example.com/

HTTP/2 103
link: </assets/app.css>; rel=preload; as=style
link: </assets/hero.jpg>; rel=preload; as=image

HTTP/2 200
server: nginx/1.30.4

The same request over HTTP/1.1 must show no interim response at all:

curl -k --http1.1 -H "Sec-Fetch-Mode: navigate" -o /dev/null -D- https://example.com/
# HTTP/1.1 200 OK   (and nothing before it)

A non-navigation request over HTTP/2 must also skip the hints:

curl -k --http2 -H "Sec-Fetch-Mode: cors" -o /dev/null -D- https://example.com/
# HTTP/2 200   (and nothing before it)

In Chrome DevTools, the confirmation is the initiator: a hinted resource shows an initiator type of early-hints rather than the HTML element that references it.

The benchmark

Configuration snippets prove syntax. They do not prove value. So the setup below measures it.

Method. The NGINX early hints benchmark isolates one variable. A fixed origin emits the same 103 with the same two Link headers, waits a fixed think-time, then returns the same HTML. Two NGINX server blocks are byte-identical except that one carries early_hints. The arm without it behaves exactly like stock NGINX: the 103 arrives and is dropped. Each run uses a fresh browser context with a cleared cache, so every run is cold.

Two design details matter more than the run count. First, the arms are interleaved: each round runs every arm once, in shuffled order, so that drift on the test host cannot land on one arm and masquerade as an effect. Second, there is an A/A control, a third arm pointing at the same server block as the “before” arm under a different label. The gap between those two identical arms is the noise floor, and it came out at 0 ms median and 4 ms at p75 across 20 rounds. Any claimed effect has to clear that.

Environment. Rocky Linux 10.1, NGINX 1.30.4 from GetPageSpeed, headless Chromium via Playwright 1.57, and a real 40 ms egress delay applied with tc qdisc ... netem delay 40ms for a measured RTT of about 42 ms. The hinted assets are a 610 byte render-blocking stylesheet and a 259 KB hero image, which is the LCP element. 20 rounds per condition.

Every run in the enabled arms recorded earlyHintsSeen: true and an image initiator of early-hints; every run in the disabled arms recorded neither. The arms are not mislabelled.

Origin think-time 400 ms:

Metric Stock behaviour With early_hints Change
LCP median 748 ms 564 ms -184 ms (-25%)
LCP p75 748 ms 568 ms -180 ms (-24%)
Hero image ready (median) 736 ms 531 ms -205 ms
Stylesheet ready (median) 572 ms 531 ms -41 ms
HTML arrived (median) 527 ms 527 ms 0 ms
Raw TTFB (median) 527 ms 125 ms -402 ms (artifact, see below)

Control, origin think-time 0 ms:

Metric Stock behaviour With early_hints Change
LCP median 344 ms 344 ms 0 ms
LCP p75 344 ms 344 ms 0 ms
Hero image ready (median) 334 ms 333 ms -1 ms

The control is the important table, and it is worth being blunt about it: the benefit does not shrink, it disappears. Early hints buy the browser the use of your origin’s dead time. Remove the dead time and there is nothing left to reclaim.

That result is also a cautionary tale about method. An earlier block-ordered version of this benchmark, running all of one arm and then all of the other, reported a 44 ms gain in this same zero-delay control. Interleaving the arms and adding the A/A control showed that 44 ms was drift on the test host, not the feature.

Two results that are easy to misread

Early hints does not make your origin faster. Look at the “HTML arrived” row: 527 ms in both arms, identical to the millisecond. Nothing about the final response improved. The browser simply spent the waiting period fetching assets instead of idling.

Raw TTFB becomes a misleading metric. Navigation Timing responseStart fell from 527 ms to 125 ms once hints were enabled, an apparent 402 ms improvement, purely because the 103 is now the first response head to arrive. The HTML still showed up at 527 ms in both arms. That is a measurement artifact, not a speed-up, and it is more than twice the size of the real LCP gain. If you enable early_hints and your RUM dashboard reports a dramatic TTFB win, this is what you are looking at. Judge this feature on LCP, not on TTFB.

The mistake that returns 502

Early hints headers are read into the same buffer as the final response headers, and that buffer is proxy_buffer_size. A generously sized Link set overflows it.

With an origin emitting 200 Link headers and the default buffer, the whole request fails:

curl -k --http2 -H "Sec-Fetch-Mode: navigate" -o /dev/null -w "%{http_code}\n" https://example.com/
502

And in the error log:

upstream sent too big header while reading response header from upstream

The error names the response header, not the hints, which makes this genuinely confusing to diagnose. Raising the buffer fixes it:

location / {
    early_hints $early_hints;
    proxy_buffer_size 32k;
    proxy_buffers    8 32k;
    proxy_http_version 1.1;
    proxy_pass http://127.0.0.1:8090;
}

The same request then returns the 103 and a 200. See our guide on tuning proxy_buffer_size in NGINX for how to size it properly.

There is a second, subtler limit inside NGINX: it tracks the cumulative size of early hints across a request and stops forwarding them once that total exceeds proxy_buffer_size, logging upstream sent too big early hints at info level. Since most deployments run error_log at warn or above, that message is invisible by default. If hints silently stop arriving, raise the log level before assuming the directive is broken.

Note also that proxy_hide_header applies to hint headers too, because the same hide-headers hash filters them.

What to hint, and what not to

The benchmark gain came from hinting exactly two things: the render-blocking stylesheet and the LCP image. Keep that discipline.

Keep the list short. Every hint competes for the same connection as the HTML you are about to send.

When to skip NGINX early hints entirely

If your stack does not clear that bar, spend the effort on the origin’s response time instead. That helps every metric, including this one.

Conclusion

NGINX early hints is a narrow tool with a real payoff inside its niche. Where an application server needs a few hundred milliseconds to produce HTML, handing the browser a correct Link set at the start of that window cut LCP by 184 ms at the median and 180 ms at p75 in a controlled, interleaved cold-cache benchmark whose noise floor measured 0 ms. Where the origin is already fast, the honest answer is that it does nothing at all: the zero-delay control moved LCP by exactly 0 ms.

The prerequisite is unavoidable: early_hints needs NGINX 1.29.0 or newer, and no stock Enterprise Linux, Debian or Ubuntu package ships that. The GetPageSpeed repository does, at 1.30.4, across EL8, EL9, EL10, Debian bookworm, and Ubuntu jammy and noble.

A directive this version-sensitive is one upgrade away from silently changing behaviour. A routine package update can turn the config you just benchmarked into a config that no longer does what you measured. GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. Drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).

Get a current NGINX from the GetPageSpeed repository, which also carries 100+ dynamic modules for the same subscription. If you are weighing NGINX early hints against bundling your assets, our write-up on the NGINX concat module covers the other side of that trade.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Exit mobile version