HTTP/2 removes HTTP response-order constraints by multiplexing streams, but those streams still share a TCP connection and its failure modes. One useful boundary is cancellation: terminating a request need not destroy its sibling requests or the reusable connection. That is a scoped capability, not a guarantee that streams never affect each other.

A stream is not the socket

HTTP/2 identifies individual streams within the connection. RFC 9113 defines RST_STREAM to terminate one stream, either for cancellation or because of a stream error. It is not a TCP reset of the whole socket. An implementation can stop one exchange while other streams continue, provided the connection itself remains usable.

This differs from HTTP/1.1 persistence, which already allows multiple exchanges on a connection. HTTP/2 adds interleaving and stream-specific lifecycle control; it does not invent connection reuse. Shared TCP ordering, congestion, connection-level DATA flow control, and server resources still bound progress. A connection failure can disrupt several requests together even when a request-scoped cancellation does not.

Cancellation also does not reverse completed work. The server might already have committed a transaction or called another service. A canceled client read cannot establish whether that operation happened. Retry decisions therefore still need application semantics, idempotency guarantees, or a way to inspect the outcome.

Headers are not a completed response

A successful response status precedes the body. A server can send and flush HTTP 200, then wait indefinitely or fail while sending content. Treat status, body completion, and business outcome as separate observations. A request is not successfully consumed merely because Client.Do returned a response object.

In Go, the outgoing request context controls cancellation. For an incoming HTTP/2 request, cancellation can cancel the server’s request context without closing the entire connection. Handlers must observe that context when waiting or calling dependencies; the runtime cannot automatically undo arbitrary application work. Client.Timeout also covers body reading after headers arrive.

When it breaks

Canceling a shared parent context can intentionally stop several requests. Closing the underlying connection also has broader scope than canceling one request. Choose request-owned cancellation for request-owned deadlines; do not impose a socket-wide deadline and describe its effect as isolated to one HTTP/2 stream.

Abandoned bodies can retain resources or consume flow-control credit. Read responses to completion when appropriate, and close every body when finished or canceled. Library-specific buffering and window updates determine whether unread data impedes siblings. Stream limits, connection credit, and backend capacity remain relevant; successful cancellation in a tiny fixture does not establish behavior under saturation.

Check it quickly

Save this as cancel_probe.go and run go run cancel_probe.go with Go 1.23 or newer. A local server negotiates HTTP/2 over verified TLS 1.3. Two handlers flush 200 headers before withholding their bodies. The client then cancels the first request, while the sibling remains open on the same numbered TCP connection.

package main

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"net/http/httptrace"
	"runtime"
	"sync/atomic"
	"time"
)

type connectionKey struct{}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func main() {
	cancelObserved, releaseSurvivor, stop := make(chan struct{}), make(chan struct{}), make(chan struct{})
	var connections, handled atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connectionKey{})))
		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusOK)
		w.(http.Flusher).Flush()
		switch r.URL.Path {
		case "/cancel":
			select {
			case <-r.Context().Done():
				close(cancelObserved)
			case <-stop:
			}
		case "/survive":
			select {
			case <-releaseSurvivor:
				fmt.Fprint(w, "survived")
			case <-r.Context().Done():
			case <-stop:
			}
		case "/after":
			fmt.Fprint(w, "ready")
		}
	}))
	server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
		return context.WithValue(ctx, connectionKey{}, connections.Add(1))
	}
	server.EnableHTTP2 = true
	server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
	server.StartTLS()
	defer server.Close()
	defer close(stop)
	config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
	check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
	transport := &http.Transport{TLSClientConfig: config, ForceAttemptHTTP2: true, MaxConnsPerHost: 1}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
	open := func(ctx context.Context, route string) *http.Response {
		request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+route, nil)
		must(err)
		response, err := client.Do(request)
		must(err)
		check(response.StatusCode == 200 && response.Proto == "HTTP/2.0", "HTTP/2 response required")
		check(response.Header.Get("X-Connection-ID") == "1", "streams must share the first TCP connection")
		check(response.TLS.Version == tls.VersionTLS13 && response.TLS.NegotiatedProtocol == "h2", "TLS 1.3 and h2 required")
		check(len(response.TLS.VerifiedChains) > 0, "verified server certificate required")
		return response
	}
	firstContext, cancel := context.WithCancel(context.Background())
	defer cancel()
	first := open(firstContext, "/cancel")
	survivor := open(context.Background(), "/survive")
	cancel()
	body, readError := io.ReadAll(first.Body)
	closeError := first.Body.Close()
	must(closeError)
	check(errors.Is(readError, context.Canceled) && len(body) == 0, "first stream must report cancellation")
	select {
	case <-cancelObserved:
	case <-time.After(2 * time.Second):
		panic("server did not observe stream cancellation")
	}
	close(releaseSurvivor)
	body, readError = io.ReadAll(survivor.Body)
	closeError = survivor.Body.Close()
	must(readError)
	must(closeError)
	check(string(body) == "survived", "sibling stream must complete")
	var reused atomic.Bool
	var handshakes atomic.Int64
	trace := &httptrace.ClientTrace{
		GotConn:           func(info httptrace.GotConnInfo) { reused.Store(info.Reused) },
		TLSHandshakeStart: func() { handshakes.Add(1) },
	}
	after := open(httptrace.WithClientTrace(context.Background(), trace), "/after")
	body, readError = io.ReadAll(after.Body)
	closeError = after.Body.Close()
	must(readError)
	must(closeError)
	check(string(body) == "ready" && reused.Load() && handshakes.Load() == 0, "connection must remain reusable")
	check(connections.Load() == 1 && handled.Load() == 3, "three streams must use one TCP connection")
	fmt.Println("cancel: context canceled; server observes cancellation")
	fmt.Println("survive: body complete on connection 1")
	fmt.Println("after: connection 1 reused, no new TLS handshake")
	fmt.Println(runtime.Version())
}

The first body’s read must return an error matching context.Canceled and no bytes. The server must independently observe cancellation of that request’s context. Only then does the program release the sibling, whose complete body must equal survived. Thus two successful header responses produce different body outcomes without requiring separate connections.

A third request must return ready on connection one. GotConn reports reuse, TLSHandshakeStart remains zero for that request, and the server asserts three handled requests but only one accepted TCP connection. These observations distinguish a surviving connection from a client silently replacing a broken one.

All assertions passed on macOS with Go 1.27.0. Each request has a two-second timeout, server cancellation observation has its own two-second bound, and cleanup releases waiting handlers. Certificate verification stays enabled; the program neither changes public services nor installs a trust root.

This checks observable Go behavior, not captured wire frames: it does not decode or assert a particular RST_STREAM error code. The protocol explanation comes from RFC 9113. It also does not test packet loss, flow-control exhaustion, connection failure, production side effects, or throughput. Those require separate experiments before drawing stronger isolation or retry conclusions.

Further reading

Sources

  • RFC 9113: HTTP/2, stream termination, shared flow control, and TCP limitations, checked 2026-09-09.
  • Go net/http, request cancellation, response bodies, transport reuse, and timeouts, checked 2026-09-09.
  • Go crypto/tls, protocol negotiation and verified certificate chains, checked 2026-09-09.