We will keep one scenario throughout the chapter. A small service named fetchd accepts several URLs, fetches them concurrently under one deadline, collects status codes and response sizes, and returns JSON. It is small enough to read in one sitting yet contains handoffs that appear in real services: inbound connections, request contexts, child goroutines, an outbound connection pool, socket waits, and response writes.

Reading goal: you do not need runtime knowledge. After the first half, you should be able to draw this relay: the server accepts a connection → a handler processes one request → workers make outbound requests → a worker pauses while the network is empty → it continues when data arrives. The second half maps those handoffs to net/http, internal/poll, and runtime source.

First, the source scope. Direct source claims are pinned to the public Go development snapshot, verified on September 20, 2026. Its implementation details are not promises made by released versions. Documented API behavior comes from the net/http documentation, context documentation, and Go diagnostics documentation. The request-lifecycle model in the first half is broadly useful. The detailed source path in the second half covers ordinary HTTP/1.x over pollable network descriptors. HTTP/2 takes another protocol path, and network waiting differs across Windows, macOS, and Linux. We use Linux epoll as one concrete implementation, not as a universal path.

Keep five everyday questions in mind:

  1. Does Server.Serve create one goroutine per request or one per connection?
  2. Where does Request.Context() come from, and who cancels it after the handler returns?
  3. How does client.Do acquire or establish a reusable outbound connection?
  4. When Read has no data, what happens to the goroutine, thread, and file descriptor?
  5. For a slow request, should we first inspect the handler, connection pool, netpoll, scheduler, or GC?

1. Walk One Request in Plain Language

A browser calls /fetch. The server first accepts a connection, then reads one HTTP request from it. The handler finds two URLs and starts two workers. A worker pauses while waiting for a network response and continues when data arrives. Finally, the handler gathers both results and writes JSON.

1. The listener accepts one connection
2. The connection yields one /fetch request
3. The handler reads two URLs
4. Two workers visit the upstream sites
5. The handler gathers two results
6. The connection writes JSON to the caller

No single request object performs that whole story. Several components take turns: one creates state, another decides when its lifetime ends, and another may clean it up. Learn which code is responsible at each step; the type names in the table are only the verified development snapshot's concrete implementation of this relay.

One counterexample prevents a common mix-up: one connection is not one request. A client may open one TCP connection, send request A, receive its response, and then send request B. HTTP/1 normally handles both in sequence on the same connection path. A second client connecting at the same time creates another connection path.

Layer Responsible component State it manages Typical end condition
Inbound listener http.Server Listener, server configuration, accept loop Close / Shutdown closes the listener
Connection net/http.conn Socket, buffers, TLS, keep-alive state Peer close, protocol error, idle timeout, shutdown
Request response + handler Request, request context, response state ServeHTTP returns or the connection ends
Outbound exchange Transport + persistConn Pool, dial, read/write loops, response body Body closed/read, cancellation, broken connection
Network wait internal/poll + runtime Poll descriptor, waiting goroutine, readiness event I/O ready, deadline, descriptor close

The most important fact in the table is not a type name but a different lifetime. A request may end while its connection stays reusable. A worker may wait for the network while its thread runs another goroutine. A canceled request still needs workers to observe the signal and return. The rest of the source tour explains how those three facts work.

// The request shape used throughout this series
GET /fetch?url=https://go.dev/&url=https://pkg.go.dev/net/http

incoming request
  ├─ request deadline: 1.5s
  ├─ child goroutine: fetch go.dev
  ├─ child goroutine: fetch pkg.go.dev
  └─ JSON aggregation
The same example will pass through server entry, context, Transport, netpoll, and diagnosis in later chapters.

2. Make fetchd real before reading internals

The complete example lives in go-runtime/examples/fetchd. Its core handler uses only the standard library. It derives a 1.5-second context from the inbound request, starts a bounded number of goroutines, and gives every outbound request the same context.

func (h fetchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    targets := r.URL.Query()["url"]

    ctx, cancel := context.WithTimeout(r.Context(), h.timeout)
    defer cancel()

    results := make(chan fetchResult, len(targets))
    for _, target := range targets {
        go h.fetch(ctx, target, results)
    }

    batch := make([]fetchResult, 0, len(targets))
    for range targets {
        batch = append(batch, <-results)
    }
    json.NewEncoder(w).Encode(batch)
}

Three constraints are intentional. First, at most eight URLs are accepted, so “one goroutine per input” cannot become unbounded amplification. Second, the result channel is buffered to the task count, so a future early return does not immediately strand every child at its send. Third, NewRequestWithContext carries inbound cancellation into the outbound Transport.

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxConnsPerHost = 16
transport.MaxIdleConnsPerHost = 8
transport.IdleConnTimeout = 90 * time.Second
transport.ResponseHeaderTimeout = time.Second

server := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadHeaderTimeout: 2 * time.Second,
    WriteTimeout:      3 * time.Second,
    IdleTimeout:       60 * time.Second,
}

These are not universal production defaults. They state how long each wait may last: the server limits header reads, response writes, and idle connections; the client limits per-host connections, idle reuse, and response-header waiting. Real values must follow traffic, upstream SLOs, body sizes, and retry policy.

3. Server.Serve accepts connections

ListenAndServe performs two essential actions: net.Listen("tcp", addr), then hands the listener to Server.Serve. The accept loop is where connection goroutines are created.

// net/http/server.go, reduced to the main route
ctx := context.WithValue(baseCtx, ServerContextKey, s)
for {
    rw, err := l.Accept()
    // shutdown and temporary accept-error handling omitted
    connCtx := ctx
    if cc := s.ConnContext; cc != nil {
        connCtx = cc(connCtx, rw)
    }
    c := s.newConn(rw)
    c.setState(c.rwc, StateNew, runHooks)
    go c.serve(connCtx)
}
Source: Server.Serve.

The go appears in front of c.serve, so the direct HTTP/1 fact is: the base service goroutine is created per connection, not per request. Ten sequential keep-alive requests on one TCP connection reuse that connection service path. Ten simultaneous connections create ten such paths.

Accept and newConn start go c.serve for each connection; an ordinary HTTP/1 connection goroutine runs readRequest, ServeHTTP, request cancellation, and finishRequest sequentially, then either waits for another request or closes the connection

Why the HTTP/1 handler runs in the connection goroutine

After TLS routing and buffer setup, conn.serve enters its HTTP/1 loop. The key call is not go handler.ServeHTTP(...); it calls the handler directly.

for {
    w, err := c.readRequest(ctx)
    // parsing and error responses omitted

    inFlightResponse = w
    serverHandler{c.server}.ServeHTTP(w, w.req)
    inFlightResponse = nil

    w.cancelCtx()
    w.finishRequest()
    if !w.shouldReuseConnection() {
        return
    }
    c.setState(c.rwc, StateIdle, runHooks)
    // Wait for the next request: probe briefly, return empty idle buffers,
    // and reacquire them when data arrives; branches are explained below.
}
Source: the HTTP/1 loop in conn.serve.

The source comment explains the choice: ordinary HTTP/1 cannot read the next request while the current response is still being produced, so another handler goroutine would add bookkeeping without useful protocol concurrency. Application code may create child goroutines as fetchd does, but the handler must limit their work, propagate cancellation, and collect results. HTTP/2 multiplexing follows a different path.

How an idle connection returns unused buffers

Keeping a connection alive need not keep its read and write buffers attached. After finishing a request and confirming reuse, the current HTTP/1 loop first probes with buffers attached. idleBufsReleaseDelay is 50 milliseconds, shortened when the actual idle deadline is earlier. A promptly arriving request reuses the buffers without pool churn. Only when the probe times out before the real idle deadline and both buffers are empty does the loop return roughly 8 KiB to the pools, then wait for one byte through waitReadable. When data arrives it reacquires buffers and feeds the stashed byte into the next read; a failed read ends the connection.

Three moments on one HTTP/1 server connection: prompt arrivals retain buffers; eligible idle connections return empty buffers while staying open; arriving data triggers buffer reacquisition
Three moments on the same connection. Returning buffers is a conditional idle path, not a mandatory step after every request.

The short probe timeout only means that the connection is idle. It must not cancel the connection context that parents subsequent requests. Read-error handling recognizes probing and ignores that expected timeout; the full waiting branch decides whether to keep waiting, reacquire buffers, or end the connection. The 50-millisecond threshold and approximate buffer size are implementation choices in this snapshot, not API guarantees or replacements for Server.IdleTimeout.

Where the connection goroutine catches a handler panic

A defer at the top of conn.serve recovers ordinary panics, records the current goroutine stack, and closes a non-hijacked connection. ErrAbortHandler is the special no-stack signal. This protects the HTTP connection loop; it is not a transaction that rolls back external side effects already performed by the handler.

4. How Request.Context is installed and canceled

Request cancellation does not begin at the call to r.Context(). A connection context sits above it, outbound work sits below it, and the source links the lifetimes with nested contexts.

Keep following the worker that visits go.dev. If the caller disconnects early, the server cancels the request context. The worker used that same context for its outbound request, so client.Do can observe the stop signal. Cancellation is a notification, not a runtime command that deletes a goroutine; the call must return, the response body must be cleaned up, and the worker must exit through the code that created and waits for it.

Cancellation descends from BaseContext through connection and request contexts to child tasks; connection termination calls c.cancelCtx and ServeHTTP return calls w.cancelCtx; separate I/O deadlines apply to net.Conn through SetReadDeadline and SetWriteDeadline, while completion still requires waiting

readRequest creates the request cancel function

Server.Serve obtains a root from BaseContext; ConnContext can attach values for one connection. conn.serve adds context.WithCancel and defers its cancel until the connection service ends. After parsing and validating request headers, readRequest creates the inner request context.

ctx, cancelCtx := context.WithCancel(ctx)
req.ctx = ctx
req.RemoteAddr = c.remoteAddr
req.TLS = c.tlsState

w = &response{
    conn:      c,
    cancelCtx: cancelCtx,
    req:       req,
    // reqBody is retained after its type is checked; omitted here.
    // ...
}
Source: conn.readRequest and the connection cancel in conn.serve.

The handler never receives the cancel function; response stores it. After the handler returns, the connection loop calls w.cancelCtx(). Background work that keeps the request context therefore observes Done after handler return. Work that must outlive a request belongs in a queue or worker with an independent lifetime, not in a goroutine that silently ignores cancellation.

Cancellation, request deadlines, and socket deadlines differ

fetchd adds a 1.5-second business deadline with context.WithTimeout. The connection loop sets the header-read deadline from http.Server.ReadHeaderTimeout, while readRequest sets the response-write deadline from WriteTimeout, using SetReadDeadline and SetWriteDeadline on the connection. Those socket deadlines do not guarantee that r.Context().Deadline() exposes the same time.

Signal Set by First observer Application response
Request context cancellation Handler return, disconnect, or explicit parent cancel Business work and Transport waiting on ctx.Done() Stop derived work and return the appropriate cause
Business deadline context.WithTimeout The context tree Apply one budget to the call tree
I/O deadline http.Server / net.Conn Socket read or write Turn network waiting into a timeout error; do not replace business cancellation

5. After the handler, outbound work enters Transport

serverHandler.ServeHTTP is deliberately thin. It selects the configured Server.Handler, falls back to DefaultServeMux, and calls handler.ServeHTTP. It also handles general OPTIONS * and cleans up multipart temporary files after the call. In fetchd, that enters our handler and eventually reaches h.client.Do(req).

worker calls client.Do
  → try to reuse a connection
  → otherwise queue a new dial
  → write the request once a connection arrives
  → wait for response headers and body
  → read and close the body so the connection can return to the pool

RoundTrip, getConn, and persistConn below are not three new stories. They are the standard-library names for those six steps.

// Ordinary handler selection; OPTIONS * and multipart cleanup are omitted.
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
    handler := sh.srv.Handler
    if handler == nil {
        handler = DefaultServeMux
    }
    handler.ServeHTTP(rw, req)
}
Source: serverHandler.ServeHTTP.

RoundTrip gets a connection, then selects the protocol path

Client.Do owns redirects, cookies, and other higher-level behavior. One HTTP exchange reaches Transport.RoundTrip. In this snapshot the exported method validates its receiver and enters roundTrip, whose loop checks the request and context, computes the connection method, obtains a connection, then selects HTTP/2 or HTTP/1 persistConn.

for {
    select {
    case <-ctx.Done():
        return nil, context.Cause(ctx)
    default:
    }

    treq := &transportRequest{Request: req, ctx: ctx, cancel: cancel}
    cm, err := t.connectMethodForRequest(treq)
    pconn, err := t.getConn(treq, cm)

    if pconn.alt != nil {
        resp, err = pconn.alt.RoundTrip(req) // HTTP/2 or another alternate path
    } else {
        resp, err = pconn.roundTrip(treq)    // HTTP/1
    }
}
Source: Transport.RoundTrip and Transport.roundTrip.

getConn first tries an idle connection, then queues a dial. With MaxConnsPerHost, a request may wait in a per-host queue before any DNS or socket work begins. Many goroutines near getConn in a profile do not prove slow dialing; connection limits and upstream latency may be building a queue together.

The source reveals two separate lifetime decisions. The dial context uses context.WithoutCancel to retain request values while temporarily detaching from that request's cancellation because another request may reuse the connection. wantConn still has its own cancel path, and the caller stops waiting on treq.ctx.Done(). “This request stopped waiting” and “this connection is no longer worth establishing” are related but separate decisions.

Once HTTP/1 obtains a persistConn, roundTrip sends writes to writech and response waits to reqch. The connection's read and write loops manage the socket while the calling goroutine waits for a response or cancellation. A synchronous-looking client.Do therefore sits above a pool, dial goroutines, and persistent connection loops.

Applications should still consume the response they need and close its body, but early close no longer implies inevitable connection disposal. The current HTTP/1 implementation can attempt a bounded drain within 50 milliseconds if the connection is alive, keep-alives are enabled, and the declared body length is at most 256 KiB; unknown lengths can also qualify. Only EOF plus the remaining reuse conditions can return the connection to the pool. Close may return before that drain and pool admission finish. This is an implementation recovery path, not a reuse guarantee for every body. Chapter eight follows the branches and the PutIdleConn experiment.

6. Beneath blocking Read, the goroutine returns the thread

This is the part often compressed into “Go uses nonblocking I/O.” Application and standard-library code still use a blocking interface: if no response data exists, Read waits. For a pollable network descriptor, the bottom layer first attempts a nonblocking syscall. Only EAGAIN transfers the wait to the poller.

Separate two goroutines here: the worker waits for a response inside client.Do, while the HTTP/1 connection readLoop reads the socket. When the kernel says “no data yet,” the runtime records the reading goroutine’s socket wait and lets the thread do other work. Readiness makes that goroutine runnable; it retries the read only after the scheduler selects it. EAGAIN, netpoll, and runtime_pollWait are implementation names for that pause-and-resume sequence.

Pollable socket read loop: syscall.Read returns data or waits after EAGAIN; netpoll readiness makes the reading goroutine runnable, and scheduling lets it retry

netFD delegates the connection to internal/poll.FD

net.(*netFD).Read is a thin wrapper. The retry loop lives in internal/poll.(*FD).Read.

for {
    n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
    if err != nil {
        n = 0
        if err == syscall.EAGAIN && fd.pd.pollable() {
            if err = fd.pd.waitRead(fd.isFile); err == nil {
                continue
            }
        }
    }
    return n, fd.eofError(n, err)
}
Source: netFD.Read and internal/poll.FD.Read.

If the first syscall finds data, it returns immediately and never parks the goroutine. Only when the kernel says the call would block does waitRead enter pollDesc.wait('r'). Netpoll is not a mandatory central queue for every read and write; the syscall fast path matters.

runtime_pollWait is the narrow bridge into the runtime

internal/poll declares runtime_pollWait; the runtime supplies it through go:linkname. The implementation first checks close and deadline state, then netpollblock places the current goroutine in the poll descriptor's read or write wait slot.

// internal/poll
res := runtime_pollWait(pd.runtimeCtx, mode)

// runtime
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
    if errcode := netpollcheckerr(pd, int32(mode)); errcode != pollNoError {
        return errcode
    }
    for !netpollblock(pd, int32(mode), false) {
        // recheck after deadline/readiness races
    }
    return pollNoError
}
Source: pollDesc.wait and poll_runtime_pollWait.

Parking a goroutine is not sleeping a thread. Once the G is waiting, the M can re-enter scheduling and run another runnable G on a P. This is one reason many network waits can coexist. The cost does not vanish; it moves from one-thread-per-wait to goroutine stacks, poll descriptors, connection state, timers, and wake-up scheduling.

How a readiness event reaches the scheduler

On Linux, runtime.netpoll calls epollwait, maps each event back to a pollDesc, and lets netpollready collect goroutines that can run. findRunnable performs a nonblocking network poll and, when no other work exists, may block with a delay. It transitions one G from _Gwaiting to _Grunnable and injects the remaining list into runnable queues.

// Linux: runtime/netpoll_epoll.go
n, errno := linux.EpollWait(epfd, events[:], int32(len(events)), waitms)
// ...
delta += netpollready(&toRun, pd, mode)

// scheduler: runtime/proc.go
list, delta := netpoll(0)
gp := list.pop()
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable)
Source: Linux netpoll, nonblocking poll in findRunnable, and blocking poll while idle.

When the goroutine resumes, internal/poll.FD.Read returns from waitRead and retries the syscall. Epoll readiness is not treated as a guarantee that this read must succeed; readiness can change before the G runs, so the code retries the syscall until it reads data or receives a definite error.

7. Where scheduling and GC belong on this route

This first chapter states which components do what rather than turning the scheduler and GC into encyclopedias. For our request, the scheduler runs accept, connection, handler, and Transport goroutines; switches away when they wait on channels, mutexes, timers, or netpoll; then arranges ready Gs on an executable P/M combination. schedule → findRunnable is the later entry point for G-M-P, local and global run queues, work stealing, and preemption.

GC crosses every allocation on the path. Request parsing creates headers, URLs, and response state. JSON aggregation creates slices and strings. Transport and the pool retain request and connection objects. Escape, heap growth, concurrent marking, and GC assist can affect CPU and tail latency. But observing a GC interval does not prove that GC caused one slow request; profiles, traces, and runtime metrics must establish the relationship.

A useful first split: many network waits with low CPU often produce IO wait stacks; many runnable goroutines with saturated CPU point toward scheduling or computation; rising allocation rate, heap, and GC CPU together justify a deeper allocation and collection investigation.

8. Choose production evidence by symptom

Source reading should not make an incident start at line one of server.go. Start with evidence selected for the symptom, then decide which component or wait phase can explain it.

Symptom Inspect first Likely cause Premature conclusion to avoid
Tail latency rises; CPU stays low Trace, goroutine profile, httptrace, upstream phase latency Pool queue, DNS/dial, response headers/body, netpoll “Many goroutines mean a slow scheduler”
CPU remains saturated CPU profile, trace runnable latency Handler algorithm, serialization, lock contention, GC assist “An HTTP service must be I/O-bound”
Goroutine count only grows Repeated goroutine profiles, creation stacks, blocking sites Unjoined children, channels, unclosed response bodies “The runtime garbage-collects goroutines”
Connections or FDs near the limit Connection states, Transport settings, body close behavior, OS FD metrics Listener, idle pool, upstream connections, leaks “Raising ulimit completes the fix”
Memory and GC CPU rise together Heap/alloc profiles, runtime metrics, trace GC intervals Parsing, aggregation, buffers, caches, connection state “Lower GOGC is always faster”
Shared state fails intermittently go test -race, minimal reproduction, who reads and writes shared data Handler children, shared caches or maps “Adding sleep proves there is no race”

Each diagnostic tool has limits. Pprof aggregates samples and answers “where are resources spent?” Trace preserves temporal relationships among goroutines, scheduling, network blocking, syscalls, and GC and answers “why did this work not run then?” The race detector instruments memory access for concurrency conflicts and is not a performance profile. Source suggests a mechanism; tools show what happened at runtime. A diagnosis needs both to agree.

9. Keep all seven later chapters on the same request

The series will not switch among eight unrelated demos. Later chapters keep fetchd and the same production request, enlarge one component or problem at a time, and add a reproducible experiment plus verified source.

Chapter Core question Source entry Experiment
I How one request crosses the standard library and runtime net/http, internal/poll, runtime Complete fetchd request
II What does a Go value actually copy? Slices, maps, interfaces, method sets, ABI Aliasing, growth, escape
III Where does a goroutine actually run? newproc, schedule, findRunnable Runnable latency and preemption
IV When should we use channels or locks? Channel, sema, mutex, select Backpressure, contention, starvation
V What do interfaces, generics, and reflection each solve? Interface, itab, generic shapes, reflect Typed nil, assertions, reflective writes
VI How should a request end? Context, timers, net/http cancellation Timeout, cancellation, goroutine leaks
VII Why does an allocation reach the heap? Compiler escape, allocator, GC -gcflags=-m and alloc profiles
VIII Why is net/http fast, and why does it stall? Transport, persistConn, HTTP/2, pprof, trace Connection pools, slow upstreams, and joint diagnosis

The first chapter should leave three reusable rules, not a pile of function names: identify which code creates, ends, and cleans up each lifetime; separate the synchronous API from the waiting mechanism underneath; validate source hypotheses with runtime evidence. Chapter II is now available: it begins with fetchd's URL slice, result channel, and JSON aggregation to ask which bytes Go value semantics copy and which underlying state remains shared.

Source and documentation references