← All weekly notes

15 min read

How Go's HTTP server works

Following a request through net/http, from the TCP accept loop and runtime netpoller to ServeHTTP, keep-alive and graceful shutdown.

Go · HTTP · Networking · Concurrency

When I started working with Go in 2019, I was surprised by how little code was needed to start an HTTP server:

mux := http.NewServeMux()mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {	fmt.Fprintln(w, "hello")}) http.ListenAndServe(":8080", mux)

Those few lines hide the TCP listener, connection management, HTTP parsing, concurrency and response writing. I wanted to go under the hood and follow one request from the moment a connection arrives until the handler finishes.

Phase 1: TCP and the runtime netpoller

Calling http.ListenAndServe(addr, handler) creates an http.Server, opens a TCP listener and calls Server.Serve with that listener.

http.ListenAndServe    ↓Server.ListenAndServe    ↓net.Listen("tcp", address)    ↓Server.Serve(listener)

The accept loop

Server.Serve stays in a loop calling Accept:

for {	rawConn, err := listener.Accept()	if err != nil {		// Stop, or retry a temporary error with backoff.	} 	conn := server.newConn(rawConn)	go conn.serve(connectionContext)}

The real implementation also creates a connection context, tracks its state and handles temporary accept errors. The essential idea is still simple: accept a connection, start a service goroutine for it and return to accepting the next connection.

Why blocking Accept does not require a blocked thread

The API looks blocking, but Go does not need to reserve one operating-system thread for every goroutine waiting on network I/O.

The runtime netpoller integrates with the operating system's event mechanism: epoll on Linux, kqueue on macOS and BSD systems and IOCP on Windows. When the socket is not ready, the runtime parks the goroutine. The OS watches the file descriptor or handle and notifies the runtime when progress is possible.

goroutine calls Accept or Read            ↓socket is not ready            ↓runtime parks the goroutine            ↓OS reports readiness or completion            ↓scheduler makes the goroutine runnable

The Go scheduler then assigns the runnable goroutine to an available OS thread. This is how Go keeps a straightforward blocking programming model while efficiently handling many mostly idle network connections.

One service goroutine per connection

After Accept returns a net.Conn, Server.Serve starts conn.serve in a new goroutine.

The unit is the connection, not always the request. An HTTP/1.1 connection can carry several requests through keep-alive, so the same goroutine may handle multiple requests sequentially. Different connections have different service goroutines.

HTTP/2 is different. It multiplexes several streams on one connection and can run requests concurrently within that connection. The HTTP/2 implementation therefore has more coordination than the HTTP/1.x loop described below.

Phase 2: HTTP parsing and connection management

The accepted connection enters the internal conn.serve method. This is where a raw byte stream becomes HTTP requests and responses.

TLS and protocol selection

For HTTPS, the service goroutine first performs the TLS handshake. Application-Layer Protocol Negotiation can select HTTP/2 when it is enabled. If the connection continues as HTTP/1.x, conn.serve enters its normal request loop.

The keep-alive loop

HTTP/1.1 uses persistent connections by default. The high-level connection loop looks like this:

for {	// Read and parse one request.	// Call the configured handler.	// Finish and flush the response.	// Reuse the connection or close it.}

The server tracks states such as new, active, idle and closed. These states are available through ConnState and are also important during graceful shutdown.

Buffered I/O

Go wraps the connection in buffered readers and writers. A buffer lets the parser consume request lines and headers without making a system call for every small read. Response bytes can also be collected before they are flushed to the socket.

Buffering reduces system-call overhead, but it does not mean the entire request body is loaded into memory. The body normally remains a stream connected to the underlying request data.

Parsing the request

The server reads the request line, protocol version, headers and message framing. It validates details such as the HTTP version, header names, header values and the required Host header for HTTP/1.1. It then builds an http.Request containing the method, URL, headers, body, remote address, TLS state and context.

Malformed requests receive an error response when possible. An oversized header can produce 431 Request Header Fields Too Large before the connection closes.

Limits and timeouts

Several http.Server fields protect the server from clients that are too slow or send too much header data:

  • ReadHeaderTimeout limits the time spent reading request headers.
  • ReadTimeout limits reading the complete request, including the body.
  • WriteTimeout limits response writing.
  • IdleTimeout limits waiting for the next request on a keep-alive connection.
  • MaxHeaderBytes limits the size of request headers.

These settings matter for both capacity and security. Without suitable timeouts, a client can send bytes very slowly and retain a connection and goroutine for a long time.

Phase 3: Routing through a Handler

After parsing, the server passes the request to its configured Handler. The key interface is deliberately small:

type Handler interface {	ServeHTTP(ResponseWriter, *Request)}

The handler may be an http.ServeMux, a third-party router, middleware or the application handler itself. net/http only needs the interface.

How ServeMux matched routes in this note

When I wrote this note, the standard ServeMux kept registered patterns in a map and kept subtree patterns ending in / in a slice ordered from longest to shortest.

Matching worked in two useful steps:

  1. Look for an exact match in the map.
  2. Otherwise scan the ordered subtree patterns and choose the first valid longest-prefix match.

For example, /images/ matches /images/cat.png, while a more specific /images/icons/ pattern wins for /images/icons/menu.svg.

The router has evolved since then. Go 1.22 added method-aware patterns and wildcards such as GET /users/{id}, backed by more structured routing logic. The durable part of the model is that ServeMux is only a Handler: it selects another handler and calls its ServeHTTP method.

Phase 4: Handler execution and ResponseWriter

Once routing is complete, application code runs:

handler.ServeHTTP(w, r)

For HTTP/1.x, this handler runs in the connection service goroutine. Go does not normally start another goroutine for each HTTP/1.1 request. It completes the response before reading the next request on the same connection so responses remain ordered.

How request data reaches the handler

Before Go can decode a request, the data moves through several layers:

network interface      ↓kernel TCP receive buffers      ↓socket read      ↓Go user-space buffers      ↓r.Body      ↓decoder, processor or storage client

The operating system receives TCP packets and keeps their data in kernel-managed buffers. When the Go connection reads from the socket, bytes are copied into user-space memory. If the socket has no data ready, the runtime netpoller parks the goroutine until reading can continue.

net/http uses a buffered reader for request parsing. Its HTTP/1.x connection buffer is small, but this should not be understood as a promise that every body always moves in fixed 4 KB chunks. Larger reads can bypass the internal buffer and the operating system can return different amounts of data. The important property is that the body is read incrementally.

The request body is a stream

r.Body is an io.ReadCloser. Raw JSON, Protobuf, XML or binary bodies are not automatically stored as one value in memory and they are not automatically spilled to disk. The handler decides what happens as it consumes the stream.

There are three common reading models.

Read the complete body

body, err := io.ReadAll(r.Body)

io.ReadAll grows a byte slice until it reaches the end. The complete raw body is then available in RAM. Growth can require additional allocations and copying before the final slice is produced.

This is convenient for small payloads, logging or signature verification, but it creates memory usage proportional to the input size.

Decode one complete value

var payload ExecutionPayload decoder := json.NewDecoder(r.Body)err := decoder.Decode(&payload)

json.Decoder reads incrementally and avoids requiring a separate complete raw-body slice. However, decoding into a normal struct, slice or map still materialises the resulting value in memory. A large transaction list becomes a large Go slice even though the decoder read the JSON as a stream.

The decoder can also process a sequence or individual tokens incrementally. That only keeps memory bounded if the application handles and discards each part rather than building one complete object.

The same distinction applies to Protobuf: parsing a complete message normally creates the complete message in memory, while a framed stream of smaller messages can be processed one at a time.

Stream to another destination

limited := http.MaxBytesReader(w, r.Body, 10<<20)defer limited.Close() if _, err := io.Copy(destination, limited); err != nil {	// Handle an oversized body or destination failure.}

io.Copy moves bounded chunks from the request to an io.Writer. The full payload does not have to exist as one in-memory value. Backpressure still matters: when the destination becomes slow, reading from the client also slows down.

Protecting a JSON API from oversized bodies

A typical API processes its decoded request in memory. That is normal, but the server must bound the amount of work one request can create. Without a limit, several unexpectedly large requests can exhaust a container's memory.

const maxBodySize = 10 << 20 // 10 MiB func handlePayload(w http.ResponseWriter, r *http.Request) {	r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) 	var payload ExecutionPayload	decoder := json.NewDecoder(r.Body) 	if err := decoder.Decode(&payload); err != nil {		var tooLarge *http.MaxBytesError		if errors.As(err, &tooLarge) {			http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)			return		} 		http.Error(w, "invalid JSON", http.StatusBadRequest)		return	} 	processPayload(r.Context(), payload)}

MaxBytesReader reports an error when reading crosses the limit. It does not reject the request based only on the declared Content-Length, so the limit still works when the length is missing or dishonest. A production handler may also reject an obviously excessive Content-Length early, but it should keep the reader limit as the real enforcement point.

The exported http.MaxBytesError type is available in modern Go. Code tied to an older Go release may need to handle the returned error without that type.

For a relay or another high-throughput API, the memory calculation must include concurrency:

memory per decoded request × active requests

A 10 MiB limit does not mean the process uses only 10 MiB. Parsed objects, temporary decoder allocations, response data and many simultaneous requests all contribute. Request-size limits should be paired with concurrency limits, timeouts and load shedding.

The multipart exception

Multipart form parsing has explicit memory-to-disk behaviour:

r.Body = http.MaxBytesReader(w, r.Body, 100<<20) if err := r.ParseMultipartForm(32 << 20); err != nil {	// Handle an oversized body, malformed form or storage failure.}

ParseMultipartForm(maxMemory) keeps up to the configured amount of file data in memory. File data beyond that threshold is written to temporary files. Internally, mime/multipart creates a temporary file and copies the remaining file data to it in bounded chunks.

The maxMemory argument is not a total request-size limit. Form values, metadata and files beyond the in-memory threshold still consume resources, so ParseMultipartForm should be combined with MaxBytesReader.

When a request finishes, Go's HTTP server calls RemoveAll on the parsed multipart form and removes its temporary files. Calling RemoveAll explicitly can still release them earlier when the handler no longer needs them.

Multipart temporary files inside Docker

Inside a Linux container, temporary files usually go to the container's default temporary directory, commonly /tmp.

  • With the normal writable container layer, uploads consume container filesystem storage. That data disappears when the container is removed and is not shared with other replicas.
  • A volume can provide persistent or shared storage when local filesystem semantics are genuinely required.
  • If /tmp is mounted as tmpfs, temporary files primarily consume memory rather than the writable layer. That memory counts toward the container memory limit and may still be swapped by the host.
  • Storage exhaustion, inode exhaustion or a read-only filesystem can make multipart parsing fail.

For container workloads, the practical controls are a total request limit, a deliberate temporary-storage mount and error handling for failed writes.

Is writing to local disk always wrong?

No. Instance-local disk is a poor durable source of truth for a horizontally scaled service, but it is still useful as bounded scratch space.

Keeping an uploaded object only on one web-server instance creates three problems:

  1. The data can disappear when the container or node is replaced.
  2. Another request may reach a different replica that cannot see the file.
  3. Unbounded uploads can exhaust filesystem space or inodes.

Local temporary files are reasonable for work that requires seeking, virus scanning, media transcoding, external tools or controlled buffering. The application should bound usage, clean up files and place persistent data on a suitable volume or remote store.

Production pattern 1: stream through the server to object storage

A server can pass the request reader to an object-storage client instead of first creating a complete local file:

func upload(w http.ResponseWriter, r *http.Request) {	r.Body = http.MaxBytesReader(w, r.Body, 100<<20) 	uploader := manager.NewUploader(s3Client, func(u *manager.Uploader) {		u.PartSize = 5 << 20		u.Concurrency = 2	}) 	_, err := uploader.Upload(r.Context(), &s3.PutObjectInput{		Bucket: aws.String(uploadBucket),		Key:    aws.String(serverGeneratedObjectKey(r)),		Body:   r.Body,	})	if err != nil {		http.Error(w, "upload failed", http.StatusBadGateway)		return	} 	w.WriteHeader(http.StatusCreated)}

This avoids a complete in-memory copy and local durable storage, but it does not mean zero memory. AWS's Go upload manager buffers multipart data when Body is a non-seekable io.Reader. Its approximate buffer requirement per upload is PartSize × Concurrency, and several concurrent handler calls multiply that amount.

The server should generate or validate object keys, authenticate the caller, enforce quotas and propagate r.Context() so an abandoned request can cancel the upload. Incomplete multipart uploads also need suitable storage lifecycle rules.

Production pattern 2: use a presigned upload

At larger scale, the application server can stay out of the file-data path:

  1. The client asks the application for permission to upload.
  2. The application authenticates the client, chooses a specific object key and creates a short-lived presigned request.
  3. The client uploads directly to object storage.
  4. The application confirms completion through a client callback, an object metadata check or a storage event.

This keeps the application server's RAM, bandwidth and disk out of the large transfer. A presigned URL is a bearer credential, so it should be short-lived and restricted to the required operation and object key. The server must not treat the client's “upload complete” message as proof without checking the stored object when correctness matters.

Production pattern 3: stream through processors

Go's io.Reader and io.Writer interfaces make it possible to process data while it moves. A checksum can be calculated at the same time data is sent to its destination:

limitedBody := http.MaxBytesReader(w, r.Body, 100<<20)defer limitedBody.Close() hasher := sha256.New()reader := io.TeeReader(limitedBody, hasher) if err := objectStore.Put(r.Context(), objectKey, reader); err != nil {	return err} checksum := hex.EncodeToString(hasher.Sum(nil))

The same shape can feed compression, encryption, validation or a parser. Some operations require the complete input or random access, so streaming is a design choice rather than a universal replacement for memory or temporary files.

What ResponseWriter contains

http.ResponseWriter is an interface. For a normal HTTP/1.x request, it is backed by an unexported response object connected to the buffered writer for that connection.

Calling Header().Set changes an in-memory header map:

w.Header().Set("Content-Type", "application/json")

The response is committed when the handler calls WriteHeader or writes the first body bytes:

w.WriteHeader(http.StatusCreated)w.Write(responseBody)

If Write comes first, Go implicitly commits 200 OK. A later WriteHeader(500) cannot replace it because the status has already been chosen. Go logs the superfluous call and keeps the original status.

For a small response, the server may buffer enough data to determine a Content-Length. When the length is unknown, an HTTP/1.1 response may use chunked transfer encoding so the connection can remain open while the client can still identify the end of the response.

Phase 5: Finishing the request

When the handler returns, control goes back to the connection loop.

Flush and decide whether to reuse

The server finishes the response, flushes buffered data, closes the request body and removes multipart temporary files created by form parsing. It then decides whether the connection can carry another request.

Reuse depends on details such as:

  • Whether the protocol permits keep-alive.
  • Whether the client or server requested Connection: close.
  • Whether request parsing or response writing failed.
  • Whether an unread request body can be handled safely.
  • Whether the server is shutting down.

If the connection is reusable, the server marks it idle and waits for the next request. If not, it closes the socket, marks the connection closed and lets the service goroutine return.

Context cancellation

Every incoming request has a context available through r.Context(). For a server request, it is cancelled when the client connection closes, when an HTTP/2 request is cancelled or when ServeHTTP returns.

Passing the context to downstream operations connects their lifetime to the request:

rows, err := db.QueryContext(r.Context(), query)

Cancellation is a signal rather than a forced termination. A database call, outgoing request or child goroutine stops promptly only if it observes that context. Code that ignores the context continues running until it finishes by itself.

Incoming connections are not the client pool

The server and client solve different connection problems:

  • http.Server accepts incoming connections and starts a service goroutine for each one. Keep-alive lets that accepted socket serve more requests.
  • http.Client normally uses http.Transport, which maintains idle outbound connections and reuses them for later requests to suitable destinations.

The client pool avoids repeating TCP and TLS handshakes. It is managed by Transport's connection-tracking structures; it is not simply a sync.Pool of sockets.

Graceful shutdown

Calling Server.Shutdown(ctx) starts an orderly shutdown:

  1. Close listeners so no new connections are accepted.
  2. Close idle connections.
  3. Wait for active connections to become idle and close.
  4. Return when the server becomes quiet or the context expires.

Shutdown does not forcibly terminate active handlers. If its context expires, it returns the context error. An application that needs a hard stop can separately call Server.Close.

Hijacked connections such as some WebSocket connections are outside the normal HTTP lifecycle. The application must track and shut them down separately.

What I want to remember

The whole HTTP/1.x path can be reduced to this:

TCP listener    ↓accept loop    ↓service goroutine per connection    ↓runtime netpoller while waiting    ↓parse bytes into http.Request    ↓route through Handler    ↓call ServeHTTP    ↓commit and flush the response    ↓reuse or close the connection

net/http feels simple because the public API hides the socket, protocol and lifecycle work. The runtime netpoller makes blocking-looking network calls scalable. The server manages parsing, timeouts, buffering and connection state while the application focuses on ServeHTTP.

Further reading