Problem

When working with networked services in Go (e.g., making HTTP requests), many developers assume that creating an http.Client immediately opens a TCP connection and that each client.Do(req) always opens a new connection. They also often misunderstand how HTTP/2 and gRPC scale under load.

This misunderstanding can lead to inefficient networking behavior, unnecessary connections, high CPU/TLS handshake costs, and poor scaling.

The goal here is to explain how Go’s HTTP client actually manages connections, how HTTP/2 multiplexing works, how that differs from HTTP/1.1, and why gRPC scales better — with clear examples and deep reasoning.


Definitions

Before we dive deeper, here are key concepts:

  • TCP Connection: A connection established between client and server over TCP, usually with a three-way handshake and (for HTTPS) a TLS handshake.
  • HTTP/1.1: A version of HTTP where each connection typically handles only one request/response at a time.
  • HTTP/2: A binary, frame-based HTTP version that supports multiple requests/responses concurrently on a single TCP connection via multiplexing.
  • Multiplexing: Sending multiple independent streams (requests/responses) within the same TCP connection simultaneously.
  • gRPC: A high-performance RPC framework built on HTTP/2 and Protocol Buffers (binary serialization) that encourages connection reuse and streaming.

Go’s HTTP Client Does Not Open a TCP Connection on Creation

http.Client vs http.Transport

In Go:

client := &http.Client{}

👉 This does not open a TCP connection yet. The http.Client is just a struct with config.

Connections are actually managed by:

client.Transport

which defaults to http.DefaultTransport.

The Transport controls the connection pool, DNS resolution, TCP handshakes, and reuse.

Summary:

  • Creating a client does not open a connection.
  • A connection is opened only when you make your first request.

TCP Handshakes Only Happen When a Request Is Made

When you do:

resp, err := client.Get("https://example.com")

Go:

  1. Resolves DNS
  2. Opens a TCP connection (three-way handshake)
  3. Performs TLS handshake (HTTPS)
  4. Sends the HTTP request

Only now does the TCP connection get established.


HTTP/1.1 Behavior

No Multiplexing

HTTP/1.1 supports:

  • Keep-Alive: reusing a connection for multiple sequential requests
  • But only one in-flight request per connection

Example (sequential):

Client → Request1 → Response1 → next request...

This leads to Head-of-Line Blocking:

If Request1 takes long, subsequent requests must wait.


Multiple Connections for Concurrency

When you do:

for i := 0; i < 100; i++ {
    go client.Do(req)
}

Go may open:

  • Many TCP connections,
  • Almost one per goroutine, because HTTP/1.1 cannot handle multiple concurrent requests on a single connection.

http.Transport has adjustable limits:

transport := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 100,
    MaxConnsPerHost:     0, // 0 means unlimited
}

If MaxConnsPerHost is low, extra requests wait.

Summary:

  • HTTP/1.1 needs multiple TCP connections to do concurrent requests.
  • Each connection has handshake cost and resource overhead.

HTTP/2 Multiplexing Explained

What is Multiplexing?

Instead of:

Client sends request → waits → receives response → next request

HTTP/2 breaks requests/responses into small frames and tags them with stream IDs.

Frames are interleaved:

| Stream1 Frame | Stream2 Frame | Stream3 Frame | … |

This means:

  • Multiple requests/responses go through the same connection at the same time.
  • No waiting for previous responses.
  • One TCP/TLS handshake for many parallel streams.

The magic: It supports multiplexed streams within one TCP connection.


Key Difference: With vs Without Multiplexing

Without Multiplexing (HTTP/1.1)

TCP Connection
--------------------------
| Req1→Res1 | Req2→Res2 |
--------------------------

Requests are sequential on a connection.

With Multiplexing (HTTP/2)

Single TCP Connection
----------------------------------------
| Req1 | Req2 | Req3 | Res2 | Res1 | … |
----------------------------------------

All streams are processed without waiting.


TCP Head-of-Line Blocking Revisited

HTTP/2 removes blocking at the HTTP level, but:

  • If a TCP packet is lost, TCP delays delivery of later packets.
  • This is still network-level blocking, but not protocol (application) blocking.

This is why HTTP/3 (QUIC) was designed later:

👉 It runs over UDP and removes this constraint.


How Go Uses HTTP/2

Go’s default http.Client:

  • Will use HTTP/2 automatically if the server supports it
  • Uses one TCP connection per host
  • Supports many concurrent streams

Example:

for i := 0; i < 1000; i++ {
    go client.Do(req)
}

If the server supports HTTP/2:

  • Go will likely use one TCP connection
  • Many Go routines send requests as many streams
  • Only one handshake cost

If a server caps concurrent streams, extra streams wait but reuse the connection.


Why gRPC Scales Better

gRPC builds on HTTP/2 and adds several advantages:

1. Persistent Connections

  • gRPC keeps a long-lived channel open.
  • Hundreds/thousands of RPCs reuse one connection.

2. Binary Protocol (Protobuf)

  • Smaller messages than JSON,
  • Faster to serialize/deserialize,
  • Less bandwidth, less CPU.

3. Built-in Streaming

gRPC supports:

  • Unary calls
  • Server streaming
  • Client streaming
  • Bidirectional streaming

This avoids repeated polling and connection churn.

4. Flow Control & Backpressure

HTTP/2 has per-stream flow control:

  • Prevents flooding
  • Efficient resource usage

Compared to REST over HTTP/1.1, this is much more scalable.

5. Lower Resource Overhead

gRPC avoids:

  • Multiple TCP/TLS handshakes
  • Many ephemeral ports
  • Many OS file descriptors

Detailed Advantages Summary

AspectHTTP/1.1 + RESTHTTP/2 (Go)gRPC
Handshake CostManyFewVery few
ConcurrencyMultiple connectionsMultiplexed streamsMultiplexed & streaming
Protocol OverheadHigh (text/JSON)Lower (binary framing)Very low (Protobuf)
Flow ControlNone at app levelYesYes
StreamingNoYesYes, built-in

Best Practice in Go

Always reuse the same client:

var client = &http.Client{}

Don’t do this:

for i := 0; i < 100; i++ {
    client := &http.Client{}  // BAD
}

If using HTTP/1.1 and high concurrency

Limit connection count:

transport := &http.Transport{
    MaxConnsPerHost: 100,
}

If using gRPC

Use one gRPC channel and make RPC calls through it.


Final Takeaways

http.Client creation alone does not open TCP connections ✔ HTTP/1.1: needs multiple connections for concurrency ✔ HTTP/2: supports multiplexing — concurrent streams on one connection ✔ Go automatically uses HTTP/2 if supported by server ✔ gRPC scales best because of HTTP/2 + binary frames + streaming support


If you’d like, I can turn this into a cheatsheet, a template you can copy into your own notes, or example code demonstrating connection reuse tuning in real Go projects.