Go’s net/http server is surprisingly efficient at handling requests because it doesn’t actually do much itself.
Let’s watch it in action. Imagine this simple server:
package main
import (
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from %s!", r.URL.Path)
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Starting server on :8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
panic(err)
}
}
When you run this and hit http://localhost:8080/test with curl, you get "Hello from /test!". Behind that simple interaction, Go is orchestrating a sophisticated dance involving goroutines, channels, and a few key data structures.
The http.ListenAndServe call is your entry point. It doesn’t immediately start accepting connections. Instead, it creates a net.TCPListener and then enters a loop. In this loop, it waits for new incoming TCP connections. For every single incoming connection, ListenAndServe launches a new goroutine. This is the first major point: Go’s concurrency model is fundamental here. You’re not dealing with a thread-per-request model; you’re dealing with potentially thousands of goroutines, which are much lighter weight.
Once a new connection is accepted, the server enters another loop for that specific connection. Inside this loop, it reads from the connection to obtain HTTP requests. When a request is read, it doesn’t process it directly. Instead, it creates an http.Request struct and an http.Response struct, and then it dispatches these to a request-handling goroutine. This is typically done via a channel. The http.Server struct has a Conn field which is a ServerConn type, and this ServerConn is responsible for managing the lifecycle of a single connection, including reading requests and sending them to a processing pipeline.
The core of request processing happens in the ServeHTTP method of the http.Handler. When you use http.HandleFunc, Go internally creates a handler that wraps your function. This handler then looks up the appropriate route (e.g., / in our example) and calls its associated function. The http.Request and http.ResponseWriter you see in your handler are not direct interfaces to the network socket. They are abstractions that manage buffering, header manipulation, and writing data back to the client through the connection managed by the ServerConn goroutine. The ResponseWriter interface, in particular, allows for flexible output, including setting headers and status codes before any body content is written.
The http.Server struct itself is where most of the configuration and control lies. Fields like ReadTimeout, WriteTimeout, MaxHeaderBytes, and IdleTimeout directly influence how the server behaves at the network and request-parsing level. For instance, ReadTimeout prevents a single slow client from holding a connection open indefinitely by not sending data. If the timeout is hit while reading the request, the connection is closed with a http.ErrHandlerTimeout. IdleTimeout is crucial for keeping a connection open for subsequent requests while ensuring that connections that have been idle for too long are eventually closed to free up resources.
The http.Server also manages a pool of goroutines for request processing. This isn’t a fixed-size pool in the traditional sense like some other frameworks. Instead, net/http is designed to spawn goroutines as needed, but it’s smart about it. The Serve method of http.Server is what’s called by ListenAndServe. It’s responsible for accepting connections and for each connection, it calls server.ServeConn. ServeConn then reads requests and for each request, it calls server.handle. The handle method is where the logic for dispatching to the correct handler and managing request lifecycle resides. It will typically spawn a new goroutine to execute the handler.
The http.Request struct is populated with details from the incoming TCP stream. It includes the method (GET, POST, etc.), URL, headers, and body. The http.Response struct, which is passed to your handler via the ResponseWriter, is what your handler populates with the response status, headers, and body. The net/http machinery then takes this Response and serializes it back into the HTTP protocol format, writing it to the underlying TCP connection.
One critical aspect is how net/http handles keep-alive connections. When a client sends a request with Connection: keep-alive, the server will attempt to reuse the TCP connection for subsequent requests from the same client. This is managed by the ServerConn goroutine’s loop. After processing a request and sending the response, it checks if the connection should be kept alive based on the client’s headers and the server’s ReadTimeout and WriteTimeout configurations. If it’s kept alive, it goes back to reading the next request on the same connection. If not, the connection is closed.
What’s often overlooked is how http.Server’s ErrorLog field is used. It’s not just for panics. Any error encountered during request processing, connection handling, or even timeouts will be logged here if an io.Writer is provided. This is your primary tool for debugging issues that don’t manifest as explicit application errors but rather as connection drops or unexpected behavior.
Ultimately, the net/http server’s power comes from its efficient use of goroutines and its well-defined interfaces. It delegates the heavy lifting of network I/O and request parsing to the standard library’s low-level networking and HTTP packages, allowing your application code to focus on business logic.
The next thing you’ll likely encounter is managing graceful shutdowns, ensuring that in-flight requests complete before the server stops listening.