Blog / Go

  • go
  • net-http
  • http2
  • server-push
  • early-hints
  • performance

Go's http.Pusher Still Compiles, but Browsers Killed HTTP/2 Push

I went looking for the moment http.Pusher was removed from Go, because that is what I half remembered: a deprecation, a release note, something. It isn't there. The interface is still in net/http, it has no deprecation marker on pkg.go.dev, and code that calls it compiles happily. What actually died is the other end of the wire. Chrome removed HTTP/2 push in 2022, and Firefox followed in version 132 (October 2024). So the Go API is a working lever attached to nothing.

That is arguably worse than a clean removal, because nothing tells you. Your handler calls Push, gets nil back or an ErrNotSupported you probably ignore, and the page loads exactly as fast as it would have without it.

What the old code looks like

For reference, this is the pattern from the Go 1.8 era:

func page(w http.ResponseWriter, r *http.Request) {
	if p, ok := w.(http.Pusher); ok {
		if err := p.Push("/assets/site.css", nil); err != nil {
			log.Printf("push failed: %v", err)
		}
	}
	// ... render the page
}

The docs for Push say it returns ErrNotSupported if the client has disabled push or the connection can't do it (so, any HTTP/1.1 connection). Since modern browsers have no push support, you should expect that branch, or a push the client cancels or never uses. The server-side cost is real: it builds a synthetic request and runs it through your handler, in its own goroutine, for a response nobody wants.

The replacement: 103 Early Hints

The thing that took push's place is a interim response, 103 Early Hints, defined in RFC 8297. The idea is simpler than push. Instead of the server sending the resource itself, it sends the client a few Link headers early, while the handler is still busy working out the real response. The browser then decides whether to fetch /assets/site.css, and crucially it does so through its normal cache, so a file it already has is not sent again. That was the big flaw with push: the server had to guess what the client held.

Go has supported this since 1.19, and there is no new API to learn. WriteHeader accepts 1xx codes; you can call it several times, and only a final (non-1xx) status ends the sequence. Whatever is in the header map at the time of the 1xx call is what gets sent.

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func page(w http.ResponseWriter, r *http.Request) {
	h := w.Header()
	h.Add("Link", "</assets/site.css>; rel=preload; as=style")
	h.Add("Link", "</assets/site.js>; rel=preload; as=script")
	w.WriteHeader(http.StatusEarlyHints)

	time.Sleep(200 * time.Millisecond) // pretend to query a database

	h.Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, "<!doctype html><h1>hello</h1>")
}

func main() {
	http.HandleFunc("/", page)
	log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil))
}

The sleep is the whole point. Early Hints only earns its keep when the server needs time (a database call, a template that does upstream requests) before it can send the first byte of the real response. During that gap the browser would otherwise sit idle; with hints it can start fetching the stylesheet.

Checking it works

I ran the above on Go 1.22 with a throwaway self-signed certificate and looked at both protocols with curl:

curl -sk --http2 -i https://localhost:8443/
curl -sk --http1.1 -i https://localhost:8443/

Both show a 103 block with the two Link headers, followed by the 200. Over HTTP/2 the status line reads HTTP/2 103, over HTTP/1.1 it reads HTTP/1.1 103 Early Hints. curl prints interim responses with -i, which makes it a decent first check before you open browser dev tools.

Things that behave oddly

Actually, this bit is the one that surprised me. The Link headers are still there in the final 200 response. Go does not clear the header map after a 1xx write, so anything you set for the hint gets sent again with the real response. For preload links that is mostly harmless, since the browser dedupes them, but if you put something in for the 103 only, delete it before the final write:

w.WriteHeader(http.StatusEarlyHints)
h.Del("Link")

A few other cautions, some from the spec and some from general experience rather than anything I measured here:

  • A 103 tells the client "the final response is still coming". Intermediaries that don't understand 1xx responses may drop them or, less pleasantly, mishandle them. If you sit behind a CDN or reverse proxy, test through it, not just against localhost.
  • Browser handling is narrower than the header suggests. As I understand it, Chrome acts on the first Early Hints response for a navigation, and only for preload and preconnect style hints. Don't build anything that depends on a second 103 doing something.
  • Only send hints you are confident the page will use. A preload the page never references wastes bandwidth and, in some browsers, logs a console warning.
  • Send hints for the main document only. The handler for /assets/site.css has no reason to send a 103.

Should you delete the Pusher code?

Yes, probably. It costs a goroutine and a synthetic request per push, it can't help the browsers that have removed it, and the failure is silent. If you truly serve a non-browser HTTP/2 client that honours push (some internal tooling does), keep it and gate it behind the type assertion as before. For everything else, a two-line Link header and a WriteHeader(http.StatusEarlyHints) call gets you most of the intended benefit, and lets the browser cache do its job.

One caveat on the premise: I checked the current documentation and a search for any Go-side deprecation, and found none. If the interface does get formally deprecated, that will show up in the release notes; until then the honest description is "supported, and pointless".