Early Hints
Browsers can start fetching critical assets before the final HTML arrives. Normally a Link header on the response points them at those assets, but if your backend is slow to render the page, 103 Early Hints is a better fit.
It lets the server send preload and preconnect hints right away, while the application is still building the page. Here’s how I wired it up behind the Caddy reverse proxy.
Link headers
With a normal Link header on the final 200 OK response, the flow looks like this:
sequenceDiagram
participant B as Browser
participant C as Caddy
participant A as Backend
B->>C: Request page
C->>A: Forward request
Note over A: DB queries, template rendering,<br/>other middleware work
A-->>C: 200 OK + Link headers + HTML
C-->>B: 200 OK + Link headers + HTML
Note over B: Browser sees Link headers,<br/>then starts CSS/JS/preconnect
Note over B: Browser parses HTML and renders
The browser can’t act on those hints until the final response headers arrive. If your handler spends 50ms building the page, the browser sits idle that whole time.
Early hints
With 103 Early Hints, the server sends the Link headers before the final response is ready:
sequenceDiagram
participant B as Browser
participant C as Caddy
participant A as Backend
B->>C: Request page
C->>A: Forward request
A-->>C: 103 Early Hints + Link headers
C-->>B: 103 Early Hints + Link headers
Note over B: Browser immediately starts CSS/JS preload<br/>and preconnect work
Note over A: Backend continues DB queries,<br/>template rendering, etc.
A-->>C: 200 OK + HTML
C-->>B: 200 OK + HTML
Now the browser fetches assets and opens connections while the backend is still rendering.
Support for early hints is good but uneven. Chrome and Edge have handled both hint types since version 103, and Firefox added preload support in 123 earlier this year. Safari 17 only acts on preconnect.
Browsers also ignore early hints over HTTP/1.1, which isn’t really a problem behind Caddy since it serves HTTP/2 and HTTP/3 by default.
Implementation
Caddy can send a 103 on its own with the header and respond 103 directives, and it forwards any 103 your backend sends. Static hints in the Caddyfile work fine until your asset URLs need to change with every release. I wanted a version query parameter on each asset so caches bust on deploy. Only the application knows the current version, so I put the hints in middleware.
Here’s a simple example using the Go standard library:
func earlyHints(next http.Handler) http.Handler {
v := getAssetsVersion()
linkHeader := "<https://assets.example.com>; rel=preconnect, " +
"<https://media.example.com>; rel=preconnect, " +
"</app.css?v=" + v + ">; rel=preload; as=style, " +
"</app.js?v=" + v + ">; rel=preload; as=script"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isAssetRequest(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Link", linkHeader)
w.WriteHeader(http.StatusEarlyHints)
next.ServeHTTP(w, r)
})
}
The middleware has to run before the main handler starts its expensive work. That’s what gives the browser time to start the preconnect and preload requests while the rest of the response is still being generated.
Once it’s deployed, the 103 shows up in the devtools network panel:

Why it helps
The most you can save is your backend’s render time. If a page takes 50ms to produce, the browser gets a 50ms head start on preloading CSS and JavaScript, and on opening connections to your asset and image domains.
The connections are usually the bigger win. DNS resolution and the TLS handshake can take tens of milliseconds on their own, and much longer on mobile or high-latency networks. If the browser does that work while your backend is rendering, the connections are already open by the time the HTML asks for images or fonts.
preload helps too, mostly on slower networks. On a fast local connection you probably won’t notice a 50ms head start, but on a phone with a weak signal it can bring first paint forward.