Go is Giving Developers a Sharper Knife: ClientConn

 

Go is Giving Developers a “Sharper Knife”: ClientConn

Decoding the New net/http “ClientConn API” Proposal: Implications, Controversies, and Future Coding Patterns

In a recent proposal within the official Go repository (#75772), the Go team released an update that seems minor at first glance but could significantly influence how cloud-native backend services are written in the future: adding “connection level (ClientConn)” operational capabilities to net/http.

This proposal has recently been marked as Proposal-Accepted and is expected to be released in Go 1.27.

This signifies a significant shift:

Go is preparing to officially expose the capability to “acquire an HTTP client connection and control it directly” within the standard library.

For regular users who only write REST APIs, you don’t care about connections at all.

But for engineers working on:

  • High-performance HTTP clients
  • Custom connection pools
  • IoT long connections
  • Proxies and tunnels
  • HTTP/2 / HTTP/3 stream multiplexing

This capability is like suddenly gaining superpowers.
This article will explore this change at a high level.

The Starting Point: The Transport Pool is a “Black Box” That Has Stymied Advanced Users for Years

First, let’s look at a diagram explaining the current model of net/http:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
flowchart LR
    subgraph app["Your Code"]
        C["http.Client"]
    end

    C -->|Do / RoundTrip| T["http.Transport"]

    subgraph pool["Transport (internal)"]
        direction LR
        Conn1["conn #1 (HTTP/1 / h2)"]
        Conn2["conn #2"]
        ConnN["conn #N"]
    end

    T --> Conn1
    T --> Conn2
    T --> ConnN

    Conn1 --> S1["server A"]
    Conn2 --> S1
    ConnN --> S2["server B"]

What You Can See:

  • Client → Transport → Do()
    What You Cannot See:
  • Which connection is being used?
  • Can I pin multiple requests to the same fixed connection?
  • What is the concurrency limit of an HTTP/2 connection?
  • Has this connection just received a GOAWAY from the server?
  • Can this connection still accept more streams?

For ordinary users, not seeing this is fine. But for advanced users, it has been excruciating.

Over the past few years, you might have seen these hacky approaches:

  • DisableKeepAlives: true —— Forcing a new connection every time.
  • Setting MaxConnsPerHost to “barely” manage the connection count.
  • Copying the DefaultTransport and stuffing it with custom logic.
  • Manually hacking into x/net/http2 to retrieve a ClientConn.

But none of these are solutions for “getting a connection I can control.”

Go has historically avoided exposing “connection layer” capabilities because that is “expert mode.”
However, as HTTP/2 moves into the standard library and HTTP/3 matures, this wall can no longer hold.

The New Go Proposal: Here is a ClientConn, You Decide How to Use It

In the proposal, the new API (draft) looks something like this:

1
2
3
4
5
6
7
func (t *Transport) NewClientConn(ctx context.Context, scheme, address string) (*ClientConn, error)
type ClientConn struct{}
func (cc *ClientConn) RoundTrip(req *http.Request) (*http.Response, error)
func (cc *ClientConn) Close() error
func (cc *ClientConn) Err() error
func (cc *ClientConn) RequestLimit() int64
func (cc *ClientConn) RequestsCompleted() int64

Several key points:

1. This is a “Single Connection” and it Absolutely Does not Enter the Transport pool.

Even if the Transport already has 5 connections to example.com, NewClientConn() will still establish a brand new one.
This is exactly what advanced users want:

“I don’t want your pool; I want my own pool.”

2. Sending Requests on This Connection: cc.RoundTrip(req)

  • HTTP/1: Serial reuse; req2 will wait until req1’s Body is closed.
  • HTTP/2: Concurrent streams.
  • HTTP/3: Will also be able to enter this same abstraction seamlessly in the future.

3. RequestLimit / RequestsCompleted Are Key Control variables.

You can understand these as “how many more requests the connection can handle” and “how many have already been handled.”
For HTTP/2, this might correspond to:

  • MAX_CONCURRENT_STREAMS
  • GOAWAY sent by the server
  • Internal error states
  • Flow control windows, etc.
    In the future, you can write your own “scheduler” based on this data.

3. This Is Not a Small Update; It’s a Major Change to Go’s Connection Model

Pushing this API forward means Go has to solve several fundamental challenges:

How to Unify the “connection semantics” of HTTP/1 / HTTP/2 / HTTP/3?

  • HTTP/1: One at a time.
  • HTTP/2: Multiple streams, multiple resource limits.
  • HTTP/3: QUIC streams, dynamic limits.

Providing a unified interface means:

Go needs to stuff protocol differences into a single “capability view (RequestLimit)” model.

This is very complex, but also very elegant.

How to Draw the Boundary between the Transport Pool and ClientConn?

  • The two must not be confused.
  • Users cannot be allowed to create an infinite number of connections and overload the system.
  • Dial/TLS/Proxy logic needs to be shared without polluting each other.

Go needs to perform abstract-layered refactoring.

The Migration Issue with x/net/http2

Currently, HTTP/2 client and server implementations still reside in x/net/http2.

This proposal is practical:

The necessary path to truly moving HTTP/2 into the standard library.

In future versions:

  • x/net/http2 will become a transition layer.
  • net/http will become a truly unified HTTP stack.

4. The Crucial Part — Code Comparison: “Status Quo” vs. “Future”

Now: You Have no way to “lock a connection”

You can only write code like this:

1
2
client := &http.Client{}
resp, _ := client.Get("https://example.com")

You have absolutely no idea which connection was used.
If you want to create a new connection every time, you have to rely on destructive hacks:

1
2
3
tr := &http.Transport{
    DisableKeepAlives: true, // Crude and has significant side effects
}

Future: You Can Truly Acquire a Connection and Send Multiple Requests over it

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ctx := context.Background()

// Assuming DefaultTransport, need to type assert to *http.Transport
tr := http.DefaultTransport.(*http.Transport)

// Key: Create a "single connection"
cc, err := tr.NewClientConn(ctx, "https", "example.com:443")
if err != nil { log.Fatal(err) }
defer cc.Close()

// All requests are pinned to run over this specific connection
do := func(path string) {
    req, _ := http.NewRequest("GET", "https://example.com"+path, nil)
    resp, _ := cc.RoundTrip(req)
    // Important: For H1, the connection isn't reusable until Body is closed
    defer resp.Body.Close() 
}

do("/api/a")
do("/api/b")
do("/api/c")

log.Println("limit:", cc.RequestLimit())
log.Println("done:", cc.RequestsCompleted())

This is a radically different capability.

You Can even Implement Your Own “user-space Connection pool”

Below is a highly condensed yet complete example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type pooledConn struct {
    cc    *http.ClientConn
    inUse int64
}

type ConnPool struct {
    mu    sync.Mutex
    conns []*pooledConn
    tr    *http.Transport
    addr  string
}

func (p *ConnPool) get(ctx context.Context) (*pooledConn, error) {
    p.mu.Lock()
    defer p.mu.Unlock()

    // Try to find an available connection in the existing pool
    for _, pc := range p.conns {
        if pc.cc.Err() != nil {
            continue
        }
        // Check if the connection can handle more requests (crucial for H2/H3)
        if pc.inUse < pc.cc.RequestLimit() {
            pc.inUse++
            return pc, nil
        }
    }

    // If no connection is available, create a new connection explicitly
    cc, err := p.tr.NewClientConn(ctx, "https", p.addr)
    if err != nil {
        return nil, err
    }

    pc := &pooledConn{cc: cc, inUse: 1}
    p.conns = append(p.conns, pc)
    return pc, nil
}

func (p *ConnPool) put(pc *pooledConn) {
    p.mu.Lock()
    defer p.mu.Unlock()
    pc.inUse--
}

You will notice:

This is already approaching the level of “user-space connection scheduling” found in Java Netty or Rust’s hyper.

Go simply couldn’t do this before.

5. Mermaid: A Complete View of the Future Connection Model

Dual Paths: Default Pool & Manual Connections

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
flowchart LR
    subgraph app["Your App / HTTP Client Library"]
        CL["http.Client (Default Usage)"]
        ADV["Advanced Usage: Custom Conn Pool"]
    end

    subgraph std["net/http Standard Lib"]
        TR["http.Transport"]

        subgraph pool["Transport Default Pool"]
            direction LR
            PConn1["pool conn #1"]
            PConn2["pool conn #2"]
        end

        subgraph userpool["Your Managed ClientConn Set"]
            direction LR
            UConn1["ClientConn #1"]
            UConn2["ClientConn #2"]
        end
    end

    CL -->|"Do (Normal Request)"| TR
    TR --> PConn1
    TR --> PConn2

    ADV -->|"Call Transport.NewClientConn"| TR
    TR --> UConn1
    TR --> UConn2

    ADV -->|"cc.RoundTrip"| UConn1
    ADV -->|"cc.RoundTrip"| UConn2

RequestLimit Behavior under HTTP/1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
sequenceDiagram
    participant App as Your Code
    participant CC as ClientConn
    participant S as Server

    Note over CC: RequestLimit = 1<br/>RequestsCompleted = 0

    App->>CC: RoundTrip(req1)
    CC->>S: Send req1
    S-->>CC: Return resp1

    App->>CC: RoundTrip(req2)
    Note over CC: req2 is blocked (HTTP/1 restriction)

    App->>App: Close(resp1.Body)
    Note over CC: RequestsCompleted = 1<br/>RequestLimit = 2 (Now ready for next)

    CC->>S: Send req2

6. Summary

Go is Unifying the Client Connection Layer Abstraction for HTTP/1/2/3.

This is a long-term engineering project, and ClientConn is a critical piece of the puzzle.

Advanced Users Will Receive Officially Supported “connection-level control” for the First time in history.**

You will be able to:

  • Write your own connection pools.
  • Decide your own scheduling strategies.
  • Manually manage keep-alive, error migration, GOAWAY, and stream concurrency.

Previously, you either couldn’t do this or had to rely on hacks with side effects.

Average Users Won’t Be Affected, but the Ecosystem Will Become More Professional.**

In the future, you will see more:

  • High-performance HTTP client libraries.
  • Specialized load balancers.
  • Custom proxies and API gateways.
  • Stronger observability tools.

This is a Sharp Knife, Meant only for Those Who Know how to Use It.

The Go team’s attitude is clear:

We won’t make the Transport more complex, but we will give “real control” to the people who need it.

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy