HTTP/2 Push Is Dead, but http.Pusher Still Compiles
http.Pusher was never removed from Go, which surprised me. I went looking for the deprecation, a release note, something, because that is what I half remembered. 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.
The old push pattern still costs you work
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.
103 Early Hints lets the browser decide
The thing that took push's place is an interim response, 103 Early Hints, defined in RFC 8297. The idea is simpler than push. Instead of sending the resource itself, the server sends 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 it since 1.19 with no new API
WriteHeader accepts 1xx codes, and the rules are short:
- You can call it several times.
- 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 with curl
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.
The hint headers leak into the final response
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")
Proxies, browsers and other cautions
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.csshas no reason to send a 103.
Yes, delete the Pusher code (probably)
The case against keeping it is short:
- It costs a goroutine and a synthetic request per push.
- It can't help the browsers that have removed it.
- 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 searched 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".