HTTP 103 Early Hints is an informational response that a server sends before the final 200 OK. It carries Link headers with rel=preload or rel=preconnect, so the browser can start downloading critical CSS and fonts, or open connections to other origins, while the backend is still generating the HTML. On pages with a slow backend this can take hundreds of milliseconds off Largest Contentful Paint. In this tutorial you will build a small Node.js backend that emits Early Hints and put Nginx in front of it on Ubuntu 24.04, using the early_hints directive added in Nginx 1.29.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • A domain name, your_domain, with a DNS A record pointing to the server's public IP.
  • Ports 80 and 443 open. With UFW: sudo ufw allow 80,443/tcp.
  • curl 7.x or later on your local machine for testing.

How Early Hints work

A normal request looks like this: the browser asks for a page, waits while the application queries databases and renders templates, then receives headers and body together. Only then does it discover the stylesheet and fonts it needs.

With Early Hints the server answers in two parts on the same stream:

  1. Immediately: 103 Early Hints with headers such as Link: </static/app.css>; rel=preload; as=style.
  2. When the page is ready: the usual 200 OK with the HTML.

A few rules shape how you deploy this:

  • Chromium-based browsers (Chrome, Edge, Opera) act on Early Hints only over HTTP/2 or HTTP/3 and only for page navigations. Firefox and Safari support is more limited; check caniuse.com/mdn-http_status_103 for the current state. Browsers that ignore 103 simply process the final response, so the feature is safe to deploy.
  • Some older HTTP/1.1 clients mishandle 1xx responses, which is why you will only forward Early Hints to HTTP/2 and HTTP/3 clients.
  • Nginx does not invent 103 responses from add_header. The application behind it sends the 103, and Nginx 1.29 and later forward it to the client when the early_hints directive allows it.

Step 1 - Installing Nginx from the nginx.org repository

Ubuntu 24.04 ships Nginx 1.24, which drops 103 responses from upstream servers. Install a current version from the official nginx.org repository instead. First remove the Ubuntu package if it is installed (your files in /etc/nginx are kept):

sudo apt remove nginx nginx-common

Install the tools needed to add the repository:

sudo apt update
sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring

Download the nginx.org signing key into /etc/apt/keyrings:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://nginx.org/keys/nginx_signing.key | sudo gpg --dearmor -o /etc/apt/keyrings/nginx.gpg

Add the mainline repository for your Ubuntu release:

echo "deb [signed-by=/etc/apt/keyrings/nginx.gpg] http://nginx.org/packages/mainline/ubuntu $(lsb_release -cs) nginx" | sudo tee /etc/apt/sources.list.d/nginx.list

Give the nginx.org packages priority over Ubuntu's own nginx package:

printf 'Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n' | sudo tee /etc/apt/preferences.d/99nginx

Install Nginx and start it:

sudo apt update
sudo apt install nginx
sudo systemctl enable --now nginx

Check the version:

nginx -v
nginx version: nginx/1.29.x

Any version from 1.29.0 onward supports early_hints. If the nginx.org stable branch is 1.30 or later when you read this, you can use packages/ubuntu instead of packages/mainline/ubuntu in the repository line.

Step 2 - Obtaining a TLS certificate

Browsers only use HTTP/2 over TLS, so you need a certificate. Install Certbot with its Nginx plugin:

sudo apt install certbot python3-certbot-nginx

Request a certificate without letting Certbot edit your configuration, since you will write the server block yourself:

sudo certbot certonly --nginx -d your_domain
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/your_domain/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/your_domain/privkey.pem

Certbot installs a systemd timer that renews the certificate automatically.

Step 3 - Creating a backend that sends Early Hints

The backend is where Early Hints belong: it knows which resources each page needs and it is the part that is slow. Node.js has a built-in response.writeEarlyHints() method. Install Node.js from the Ubuntu repositories:

sudo apt install nodejs
node --version
v18.19.1

Create a directory for the demo application and its static files:

sudo mkdir -p /opt/hints-demo /var/www/hints-demo/static

Create the stylesheet that the page will preload:

sudo nano /var/www/hints-demo/static/app.css
body { font-family: system-ui, sans-serif; margin: 4rem; color: #1f2937; }
h1 { font-size: 2.5rem; }

Now create the application:

sudo nano /opt/hints-demo/server.js
const http = require('node:http');

const server = http.createServer((req, res) => {
  if (req.url !== '/') {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Not found\n');
    return;
  }

  // Tell the browser what to fetch before the page is ready
  res.writeEarlyHints({
    link: [
      '</static/app.css>; rel=preload; as=style',
      '<https://fonts.gstatic.com>; rel=preconnect; crossorigin',
    ],
  });

  // Simulate 400 ms of database and template work
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Early Hints demo</title>
  <link rel="stylesheet" href="/static/app.css">
</head>
<body><h1>Hello from a slow backend</h1></body>
</html>`);
  }, 400);
});

server.listen(3000, '127.0.0.1');

Only preload what the page actually uses in the first render: the main stylesheet, the LCP image or a web font. Every hint competes for bandwidth with the HTML itself.

Run the application as a systemd service so it restarts on failure and at boot:

sudo nano /etc/systemd/system/hints-demo.service
[Unit]
Description=Early Hints demo backend
After=network.target

[Service]
ExecStart=/usr/bin/node /opt/hints-demo/server.js
Restart=on-failure
DynamicUser=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now hints-demo

Confirm the backend sends the 103 directly:

curl -sv -o /dev/null http://127.0.0.1:3000/ 2>&1 | grep '^< '
< HTTP/1.1 103 Early Hints
< Link: </static/app.css>; rel=preload; as=style, <https://fonts.gstatic.com>; rel=preconnect; crossorigin
< HTTP/1.1 200 OK
< Content-Type: text/html; charset=utf-8

Step 4 - Forwarding Early Hints through Nginx

By default Nginx discards 103 responses from upstream servers. The early_hints directive takes one or more values and forwards the 103 when at least one of them is non-empty and not 0. The built-in variables $http2 and $http3 are non-empty only for HTTP/2 and HTTP/3 connections, and the Sec-Fetch-Mode: navigate request header identifies page navigations, so a map can restrict hints to exactly the requests browsers use them for.

Remove the default site that the package installed, then create a server block:

sudo rm /etc/nginx/conf.d/default.conf
sudo nano /etc/nginx/conf.d/your_domain.conf
# Forward 103 only for page navigations over HTTP/2 or HTTP/3
map $http_sec_fetch_mode $early_hints {
    navigate $http2$http3;
}

server {
    listen 80;
    listen [::]:80;
    server_name your_domain;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name your_domain;

    ssl_certificate     /etc/letsencrypt/live/your_domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;

    location /static/ {
        root /var/www/hints-demo;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    location / {
        early_hints $early_hints;

        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Test the configuration and reload:

sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Step 5 - Verifying Early Hints

From your local machine, request the page over HTTP/2 with the header a browser sends for navigations:

curl -sv --http2 -o /dev/null -H 'Sec-Fetch-Mode: navigate' https://your_domain/ 2>&1 | grep -E '^< (HTTP|link)'
< HTTP/2 103
< link: </static/app.css>; rel=preload; as=style, <https://fonts.gstatic.com>; rel=preconnect; crossorigin
< HTTP/2 200

Now repeat without the Sec-Fetch-Mode header, or with --http1.1. The 103 line disappears and only HTTP/2 200 or HTTP/1.1 200 is shown, which confirms the map works.

To see the effect in a browser, open the page in Chrome, open DevTools, go to the Network tab and reload. The request for app.css starts before the document response finishes, and its Initiator column shows early-hints. Compare the waterfall with the one you get after commenting out early_hints and reloading Nginx: without hints, app.css only starts after the 400 ms delay.

Using Early Hints behind a CDN

If a CDN terminates connections in front of your server, the CDN decides what the browser receives. Cloudflare, for example, can generate 103 responses itself: when Early Hints is enabled in the zone's speed settings, it remembers the Link: rel=preload and rel=preconnect headers from your cached 200 responses and sends them as a 103 on later requests. In that setup, add the Link header to your final response (the backend or add_header Link ... in Nginx) and let the CDN handle the 103. Always test against your origin directly as well, so you know whether a missing 103 comes from your server or from the CDN.

Troubleshooting

nginx -t reports unknown directive "early_hints". You are still running Nginx older than 1.29. Check nginx -v and apt policy nginx to make sure the nginx.org package was installed and the pin in /etc/apt/preferences.d/99nginx is in place.

The backend sends 103 but Nginx does not forward it. Make sure early_hints is in the same location as proxy_pass, that you tested over HTTP/2 (--http2) and that the request carried Sec-Fetch-Mode: navigate.

curl shows no HTTP/2 at all. Check that http2 on; is in the 443 server block and that your curl was built with HTTP/2 support (curl -V lists HTTP2 under Features).

The browser ignores the hints. Chrome ignores preloads whose as value does not match how the resource is later used, and it drops hints for cross-origin redirects. Check the DevTools console for warnings about unused preloads.

Conclusion

You installed Nginx 1.29 from the official repository, built a backend that announces its critical resources with writeEarlyHints(), and configured Nginx to forward the 103 only to HTTP/2 and HTTP/3 page navigations. The same pattern applies to any framework that can write 1xx responses. As next steps, move the hint list into your application's routing so each page preloads its own LCP image, reduce backend time itself with Nginx compression and FastCGI or proxy caching, and watch LCP in PageSpeed Insights to confirm the gain for real users.