Skip to content

Go 1.28: HTTP/2 Connection Parking

 · 14 min read

I run a small Go API that serves somewhere between 300k and 500k requests a day. Spread over 24 hours that is only a handful of requests per second, which means that most of the time, most of the connections to it are doing nothing at all. Go 1.28 brings a change aimed exactly at that situation: an idle HTTP/2 connection no longer pins a goroutine in the server’s serve loop. I wanted to understand what the change actually does and measure it myself, on a real router with real middleware instead of a bare handler.

Two goroutines per connection, one of them doing nothing

When the net/http server hands a connection over to HTTP/2, the connection gets two long lived goroutines. One of them, readFrames, sits in a blocking read on the socket and forwards every frame it decodes. The other one runs the serve loop: it owns the connection state, receives frames from the reader, schedules writes, starts handlers and reacts to timers. As long as the connection is open, both goroutines exist, even if the client hasn’t sent anything for an hour.

The reader has to stay, someone has to be blocked on the socket. The serve loop doesn’t. Its only job while the connection is idle is to wait on a select for something to happen, and that waiting costs a goroutine with its own stack. There was a TODO about this at the top of the HTTP/2 server since 2014, and it was finally implemented in CL 831084 for issue #80735, which landed in September and will ship with Go 1.28. The draft Go 1.28 release notes cover it in the net/http section, under minor changes to the library:

The HTTP/2 server now lets a connection’s serve goroutine exit while the connection has nothing to do, restarting it on demand.

What parking means

The release note doesn’t name it, but the implementation calls it parking. When the serve loop finds nothing runnable (nothing being written or waiting to be flushed, nothing queued in the write scheduler, no shutdown in progress) it marks the connection as parked and the goroutine simply returns. It isn’t blocked on anything, it is gone, and its stack goes back to the runtime.

Waking up is the interesting half. Every path that sends something into the serve loop’s channels goes through a small gate first, beginServeSend in the source. The gate counts in-flight sends and, if the loop is parked, starts it again on a fresh goroutine before the message is delivered. A frame from the client, a handler writing its response, a request body read, a timer firing, a graceful shutdown: any of them wakes the connection up. The parking itself is guarded the same way. If a send is already on its way, the loop refuses to park and keeps serving, so a message can’t get stuck in a channel nobody is reading.

Two details from the commit message are worth knowing. First, open streams don’t prevent parking. A handler that sits in a long poll or an SSE stream for hours keeps its own goroutine, but the serve loop next to it can go away, and the handler’s next Write or Flush brings it back. Second, parking only happens when the caller of the HTTP/2 server is prepared for ServeConn to return early, which the commit signals through a new OnClose hook. net/http’s own server sets it on both of its HTTP/2 paths, TLS with ALPN and unencrypted HTTP/2 with prior knowledge. If you wire up HTTP/2 yourself through some other route, check that path before counting on the savings. And, obviously, none of this applies to HTTP/1.1 connections: if a proxy in front of your service talks HTTP/1.1 to it, there is nothing to park.

There is also an escape hatch. As a temporary safety measure, GODEBUG=http2serveparking=0 brings back the old behaviour. The setting is undocumented on purpose, you won’t find it on the official GODEBUG page, and it is meant to go away (#81524), so don’t build anything on it. It is, however, exactly what you want for a benchmark, because it lets the same binary run with and without parking.

The setup

This isn’t a synthetic benchmark with a hello-world handler. The server is my production API, built from the same code that serves those few hundred thousand requests a day: the same router, the same middleware chain (error reporting, panic recovery, access logging) and the same handlers. The API already serves HTTP/2, so the benchmark runs it exactly as it is configured. The clients hit an endpoint that doesn’t touch the database or the cache, so MySQL and Redis latency don’t end up in the numbers. For idle connections the handler hardly matters, but for the busy scenarios it does: every request goes through the full middleware chain, so the cost of waking a parked connection is measured against the work a real request does, not against an empty response.

If your server isn’t on HTTP/2 yet, there isn’t much to set up. Over TLS, ListenAndServeTLS negotiates HTTP/2 through ALPN out of the box. When TLS ends at a proxy in front of the service, Go 1.24 and later can serve unencrypted HTTP/2 (h2c) through http.Protocols, no x/net import needed:

1
2
3
4
5
6
7
8
9
var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)

srv := &http.Server{
	Addr:      ":8080",
	Handler:   router,
	Protocols: &protocols,
}

Keeping HTTP/1 enabled next to it means clients that don’t speak HTTP/2 still work. Both paths go through net/http’s own HTTP/2 server, so both get parking.

The one setting I changed for the benchmark is IdleTimeout, raised to 30 minutes so that idle connections outlive the measurement. Keep that in mind when reading the numbers: a sane idle timeout is still the cheapest way to not pay for idle connections at all. Next to the server, on a separate port, a tiny HTTP/1 handler reports VmRSS from /proc/self/status, runtime.NumGoroutine() and StackInuse and HeapAlloc from runtime.MemStats.

The client is a separate process, always built with Go 1.27.3, so it never changes between runs. It creates one http.Transport per simulated client, which forces one TCP connection each, and talks HTTP/2 with prior knowledge:

1
2
3
var p http.Protocols
p.SetUnencryptedHTTP2(true)
client := &http.Client{Transport: &http.Transport{Protocols: &p}}

Each connection makes one request to prove it’s really HTTP/2 and then, depending on the scenario, either goes quiet or keeps working. I compared three configurations:

  • Go 1.27.3, the current release;
  • Go 1.28, parking off: a toolchain built from revision 5c51011e, the parking commit itself, run with GODEBUG=http2serveparking=0;
  • Go 1.28, parking on: the same binary without the setting.

The server ran with GOMAXPROCS=2 and the default GOGC=100, on a laptop with a Ryzen 7 PRO 7840U, 16 threads and Linux 7.2. Every idle data point is the median of five rounds, each round being a fresh server process, five seconds to settle and five samples one second apart. The busy scenarios ran three rounds each.

Idle connections

This is the case parking was built for, and the result is very clear.

Server RSS with idle HTTP/2 connectionsGo 1.27.3: 0→17.4, 1,000→54, 2,500→102, 5,000→183, 10,000→348; Go 1.28, parking off: 0→17.1, 1,000→51.7, 2,500→99.3, 5,000→180, 10,000→338; Go 1.28, parking on: 0→17.1, 1,000→43.6, 2,500→75.1, 5,000→128, 10,000→237Server RSS with idle HTTP/2 connectionsMedian RSS after the connections settle, GOMAXPROCS=2010020030040001,0002,5005,00010,000idle HTTP/2 connectionsMiBGo 1.27.3 · 0 connections: 17.4 MiBGo 1.27.3 · 1,000 connections: 54 MiBGo 1.27.3 · 2,500 connections: 102 MiBGo 1.27.3 · 5,000 connections: 183 MiBGo 1.27.3 · 10,000 connections: 348 MiBGo 1.27.3348 MiBGo 1.28, parking off · 0 connections: 17.1 MiBGo 1.28, parking off · 1,000 connections: 51.7 MiBGo 1.28, parking off · 2,500 connections: 99.3 MiBGo 1.28, parking off · 5,000 connections: 180 MiBGo 1.28, parking off · 10,000 connections: 338 MiBGo 1.28, parking off338 MiBGo 1.28, parking on · 0 connections: 17.1 MiBGo 1.28, parking on · 1,000 connections: 43.6 MiBGo 1.28, parking on · 2,500 connections: 75.1 MiBGo 1.28, parking on · 5,000 connections: 128 MiBGo 1.28, parking on · 10,000 connections: 237 MiBGo 1.28, parking on237 MiB
Median server RSS with N idle HTTP/2 connections, 5 rounds per point.
Idle connectionsGo 1.27.3Go 1.28, parking offGo 1.28, parking on
017.4 MiB17.1 MiB17.1 MiB
1,00054.0 MiB51.7 MiB43.6 MiB
2,500101.6 MiB99.3 MiB75.1 MiB
5,000182.6 MiB180.2 MiB127.7 MiB
10,000348.4 MiB337.9 MiB236.6 MiB

With parking off, Go 1.28 behaves like Go 1.27 within a few MiB, so the difference really comes from this one change and not from the rest of the release. With parking on, the server needs about 30% less memory once there are a few thousand idle connections: 52 MiB less at 5,000, 101 MiB less at 10,000. The goroutine count tells the same story without any noise. It is two per connection on Go 1.27 and with parking off, 20,004 at 10,000 connections, and exactly one per connection with parking on, 10,004.

The relative saving depends on how much else a connection costs, and the per connection breakdown shows where the memory goes:

What one idle connection costsRSS: Go 1.27.3 33.9 KiB, Go 1.28, parking off 32.8 KiB, Go 1.28, parking on 22.5 KiB; Goroutine stacks: Go 1.27.3 12.1 KiB, Go 1.28, parking off 12.1 KiB, Go 1.28, parking on 4.1 KiB; Go heap: Go 1.27.3 17.5 KiB, Go 1.28, parking off 16.5 KiB, Go 1.28, parking on 16.7 KiBWhat one idle connection costsKiB per connection, from 0 to 10,000 idle connections010203040RSSRSS · Go 1.27.3: 33.9 KiB33.9RSS · Go 1.28, parking off: 32.8 KiB32.8RSS · Go 1.28, parking on: 22.5 KiB22.5Goroutine stacksGoroutine stacks · Go 1.27.3: 12.1 KiB12.1Goroutine stacks · Go 1.28, parking off: 12.1 KiB12.1Goroutine stacks · Go 1.28, parking on: 4.1 KiB4.1Go heapGo heap · Go 1.27.3: 17.5 KiB17.5Go heap · Go 1.28, parking off: 16.5 KiB16.5Go heap · Go 1.28, parking on: 16.7 KiB16.7Go 1.27.3Go 1.28, parking offGo 1.28, parking on
Marginal cost of one idle connection, computed between 0 and 10,000 connections.

An idle connection costs about 34 KiB of RSS on Go 1.27 and 22.5 KiB with parking, roughly 11 KiB less each. Most of that comes straight out of goroutine stacks, which drop from 12 KiB to 4 KiB per connection: one goroutine instead of two. The Go heap doesn’t move. About 17 KiB per connection is connection state and buffers, and parking doesn’t touch any of that. The bigger that fixed part is in your server, the smaller the relative win, but the absolute saving per connection stays about the same.

When connections actually do something

Parking isn’t free. Every time a parked connection gets a frame, the runtime has to start a goroutine to resume the serve loop. The commit keeps that path allocation free and reports no change in the standard library’s HTTP/2 benchmark, but I wanted to see it with real handlers in two less friendly shapes.

The first one keeps many clients connected but makes them all talk now and then: 5,000 connections, each sending one request every five seconds with a random offset. That adds up to about 920 requests per second, far more than my API sees on an average day, spread across connections that are idle most of the time. Here a connection parks and unparks around every single request, which is the worst case for the wake-up path.

5,000 connections, one request every 5 s eachRSS (MiB): Go 1.27.3 229.7 , Go 1.28, parking off 227.4 , Go 1.28, parking on 166.7 ; CPU (% of a core): Go 1.27.3 24.9 , Go 1.28, parking off 25.4 , Go 1.28, parking on 27.4 ; p99 latency (ms): Go 1.27.3 1.4 , Go 1.28, parking off 1.4 , Go 1.28, parking on 1.55,000 connections, one request every 5 s each~920 req/s spread over mostly idle connections; each row has its own scaleRSS (MiB)RSS (MiB) · Go 1.27.3: 229.7229.7RSS (MiB) · Go 1.28, parking off: 227.4227.4RSS (MiB) · Go 1.28, parking on: 166.7166.7CPU (% of a core)CPU (% of a core) · Go 1.27.3: 24.924.9CPU (% of a core) · Go 1.28, parking off: 25.425.4CPU (% of a core) · Go 1.28, parking on: 27.427.4p99 latency (ms)p99 latency (ms) · Go 1.27.3: 1.41.4p99 latency (ms) · Go 1.28, parking off: 1.41.4p99 latency (ms) · Go 1.28, parking on: 1.51.5Go 1.27.3Go 1.28, parking offGo 1.28, parking on
5,000 connections, one request every 5 s each, 60 s per run, median of 3 runs.
Go 1.27.3Go 1.28, parking offGo 1.28, parking on
RSS229.7 MiB227.4 MiB166.7 MiB
CPU24.9% of a core25.4%27.4%
p50 latency0.56 ms0.57 ms0.60 ms
p99 latency1.36 ms1.35 ms1.50 ms

This is the trade-off in one table. Memory drops by 61 MiB, about 27%, because between requests the connections are as idle as before. In exchange, the server burns about 2 points more CPU, around 8% relative, and the median request takes 30 µs longer, which is the cost of waking the serve loop on every request. The p99 moves by about 0.15 ms but wobbles between runs, while the p99.9 actually came out lower with parking, so I wouldn’t read much into the tail.

The second shape is the opposite: 32 connections with 4 concurrent streams each, hammering the endpoint in a closed loop for 20 seconds. The connections are never idle, but parking still kicks in between frames whenever the write side drains, so this is where a regression on the hot path would show.

Busy connections: 32 connections × 4 streams, closed loopThroughput (k req/s): Go 1.27.3 35.10 , Go 1.28, parking off 35.60 , Go 1.28, parking on 39.60 ; p50 latency (ms): Go 1.27.3 3.54 , Go 1.28, parking off 3.52 , Go 1.28, parking on 3.16 ; p99 latency (ms): Go 1.27.3 8.41 , Go 1.28, parking off 8.04 , Go 1.28, parking on 7.29Busy connections: 32 connections × 4 streams, closed loopParking happens between frames of active requests; each row has its own scaleThroughput (k req/s)Throughput (k req/s) · Go 1.27.3: 35.1035.10Throughput (k req/s) · Go 1.28, parking off: 35.6035.60Throughput (k req/s) · Go 1.28, parking on: 39.6039.60p50 latency (ms)p50 latency (ms) · Go 1.27.3: 3.543.54p50 latency (ms) · Go 1.28, parking off: 3.523.52p50 latency (ms) · Go 1.28, parking on: 3.163.16p99 latency (ms)p99 latency (ms) · Go 1.27.3: 8.418.41p99 latency (ms) · Go 1.28, parking off: 8.048.04p99 latency (ms) · Go 1.28, parking on: 7.297.29Go 1.27.3Go 1.28, parking offGo 1.28, parking on
32 connections × 4 streams, closed loop, 20 s per run, median of 3 runs.

There is no regression. If anything, parking came out ahead in all three rounds, with 39.6k requests per second against 35.6k with parking off and a lower p50 and p99. I’m not going to sell that as a speedup. Client and server shared the same laptop, the server was capped at two Ps, and with parking on it managed to use a bit more of its CPU allowance (1.77 cores against 1.69), which explains a good part of the throughput. What I take from it is that the hot path didn’t get slower for this workload, which is what the commit message promised.

Trying it yourself

Go 1.28 is expected in February, but the change is on the development branch today. The quickest way is gotip:

1
2
3
go install golang.org/dl/gotip@latest
gotip download
gotip build -o ./bin/api ./cmd/api

Then compare the binary with itself:

1
2
./bin/api                                # parking on
GODEBUG=http2serveparking=0 ./bin/api    # parking off

Watch VmRSS and the goroutine count while you open connections. If the goroutine count doesn’t drop to about one per HTTP/2 connection with parking on, your connections are either not HTTP/2, or they don’t go through net/http’s own HTTP/2 path.

Results

Parking is a small idea with a clear effect: an HTTP/2 connection with nothing to do no longer keeps a goroutine waiting for it, and gets one back the moment something arrives. With mostly idle HTTP/2 clients, Go 1.28 cut memory by about 30%, roughly 11 KiB per connection, without touching a line of application code. For connections that wake up regularly, parking costs a little CPU and a few tens of microseconds per request. Under sustained load I couldn’t find a cost at all.

For an API like mine, a few hundred thousand requests a day with long quiet stretches in between, that is a good trade: memory is what idle connections cost, and CPU is something the server has plenty of between requests. Before you upgrade expecting savings, though, check which protocol actually reaches your Go process and how long your idle timeout keeps connections around. Then measure, because the per connection cost that parking doesn’t touch depends entirely on your server.