Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
What actually happens when you call http.ListenAndServe: TCP listen and accept, the goroutine-per-connection model, the Handler interface, routing, and the timeouts a real server needs — with diagrams and a runnable example.
TL;DR
http.ListenAndServe opens a TCP socket, accepts connections in a loop, and hands each one to its own goroutine — that goroutine parses the request, finds a matching route, calls your handler's ServeHTTP, and writes the response back over the same connection.
What You'll Learn
- What actually happens, step by step, when you call http.ListenAndServe
- Why every connection gets its own goroutine, and what that means for your handler code
- The Handler interface and why it's the one abstraction the whole net/http package is built on
- How to route requests with http.ServeMux, including Go 1.22+'s method-aware patterns
- What http.Request and http.ResponseWriter actually represent
- Which timeouts a production server must set, and what breaks without them
- How middleware is just a function that wraps a Handler
Prerequisites
- Basic Go syntax (variables, functions, structs)
- Goroutines and Channels in Go
The problem
Most Go tutorials start an HTTP server with five lines:
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}It works, and that's exactly the problem — it works well enough to hide everything actually happening underneath: a TCP socket getting opened, an accept loop running forever, a new goroutine per client, and a specific interface your handler function is quietly satisfying. Understanding that machinery is what turns "I can copy-paste a server" into "I can reason about why my server is slow, leaking connections, or falling over under load."
This article builds that understanding from the TCP socket up, then puts it back together as a real, minimal API endpoint.
What ListenAndServe actually does
http.ListenAndServe(":8080", nil) does three distinct things, in order:
- Bind and listen. It asks the operating system for a TCP socket bound to port 8080, and puts that socket into a "listening" state — ready to accept incoming connections, but not yet talking to anyone.
- Accept, forever. It runs a loop: block until a client connects, accept that connection, then immediately go back to blocking for the next one.
- One goroutine per connection. Every time a connection is accepted, Go launches a new goroutine to handle everything about that specific connection — reading the request, running your handler, writing the response — while the main accept loop moves on to the next client.
Rendering diagram…
That third point is the one that matters most day-to-day: your handler
code runs concurrently, once per connection, without you writing a single
go keyword. If you've already read Goroutines and Channels in
Go, this is the same model — the
net/http package is simply the one launching the goroutines for you.
Multiple clients, at the same time
Here's what that looks like with two clients hitting the server at once — each gets its own goroutine, and neither blocks the other:
Rendering diagram…
This is also why a single slow handler is usually a contained problem, not a server-wide outage — the goroutine stuck waiting on a slow database query only blocks the one client waiting on that same request. It becomes a server-wide problem only when enough handlers are stuck at once that you run out of database connections, memory, or file descriptors — which is exactly why the timeouts covered later in this article exist.
The one interface everything is built on
Strip away routing and middleware, and net/http is built on a single
interface:
type Handler interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}That's it — anything with a ServeHTTP(w, r) method can handle an HTTP
request. http.ListenAndServe's second argument is a Handler; when you
pass nil, Go uses a built-in default one (http.DefaultServeMux).
Writing a type that satisfies Handler directly works fine:
type helloHandler struct{}
func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
}
func main() {
http.ListenAndServe(":8080", helloHandler{})
}But writing a struct just to get one method is usually more ceremony than
you want for a single endpoint, which is why net/http gives you an adapter:
type HandlerFunc func(w http.ResponseWriter, r *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r) // just calls the underlying function
}HandlerFunc is a function type that also has a ServeHTTP method — the
method just calls the function itself. That's the whole trick behind
http.HandleFunc and every plain func(w, r) { ... } handler you've ever
written: it's a HandlerFunc in disguise, satisfying Handler through that
one-line adapter.
Routing with ServeMux
A real server needs more than one route, and http.ServeMux is the standard
library's router. Since Go 1.22, patterns can include the HTTP method and
path variables directly:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /health", healthCheck)
http.ListenAndServe(":8080", mux)func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // reads {id} from the matched pattern
fmt.Fprintf(w, "user id: %s", id)
}A GET /users/42 request only matches the GET /users/{id} pattern — a
POST /users/42 doesn't match it at all, and ServeMux picks the most
specific pattern when more than one could match. For a lot of Go backends,
this removes the reason to reach for a third-party router at all.
What request and response actually are
Two values arrive in every handler, and both are worth knowing exactly:
*http.Requestrepresents the incoming request:r.Method,r.URL,r.Header, andr.Body(anio.ReadCloserstreaming the request body — read it before it's gone, and close it, thoughnet/httpcloses it for you after the handler returns).http.ResponseWriteris how you build the response: callw.Header().Set(...)to set headers,w.WriteHeader(status)to set the status code, andw.Write(bytes)(orfmt.Fprintf(w, ...)) to write the body. Order matters — headers must be set before the firstw.WriteHeaderorw.Writecall, because writing the body implicitly sends a200 OKstatus if you haven't set one yet.
A small JSON endpoint using both, tying the whole article together:
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user, ok := lookupUser(id) // pretend this hits a database
if !ok {
http.Error(w, "user not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}http.Error is worth knowing on its own — it sets the status code, sets
Content-Type: text/plain, and writes the message, in the right order, so
you don't have to remember the sequencing yourself for the common
error-response case.
The timeouts a real server needs
http.ListenAndServe(":8080", mux) is convenient but has no timeouts at
all — a client that connects and then sends bytes one at a time, forever,
can hold a goroutine and a connection open indefinitely. Enough clients doing
that (deliberately, as an attack, or accidentally, from a bad mobile network)
exhausts your server's goroutines and file descriptors even though request
volume looks low. Configure an explicit http.Server instead of calling the
package-level shortcut:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // time to read just the request headers
ReadTimeout: 10 * time.Second, // time to read the full request
WriteTimeout: 10 * time.Second, // time to write the response
IdleTimeout: 120 * time.Second, // how long a keep-alive connection may sit idle
}
srv.ListenAndServe()ReadHeaderTimeout alone closes off the classic "Slowloris" attack (sending
headers one byte at a time to hold a connection open); the other three bound
how long any single connection can occupy a goroutine. Graceful Shutdown in
Go HTTP Servers picks up right where this leaves
off — stopping this same *http.Server cleanly on SIGTERM instead of
dropping in-flight requests.
Middleware is just a wrapping function
Once you see Handler as "anything with ServeHTTP," middleware stops
looking special — it's a function that takes a Handler and returns a new
one that does something extra, then calls the original:
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // call the wrapped handler
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
http.ListenAndServe(":8080", withLogging(mux))withLogging(mux) wraps the whole router; wrapping an individual route
(mux.Handle("/admin", withAuth(adminHandler))) works the same way. Chaining
several of these is how logging, auth, and recovery middleware stack in real
Go APIs, without a framework doing anything magic underneath it.
Common mistakes
- Using
http.ListenAndServe(orhttp.DefaultServeMux) in production. No timeouts, and a global mux that any imported package can silently register routes onto viainit(). Build an explicit*http.Serverwith your own*http.ServeMuxinstead. - Writing to
wafter the handler returns, from another goroutine.ResponseWriterisn't safe to use onceServeHTTPhas returned — if a goroutine you launched needs to send a response, it has to finish (or send its result back over a channel) before the handler itself returns. - Forgetting that
r.Bodyis a stream, not a byte slice. Reading it twice without buffering it yourself returns nothing the second time — it'sio.ReadCloser, consumed once. - Not setting
ReadHeaderTimeout/ReadTimeout/WriteTimeout. This is the single most common gap between a tutorial server and a production one.
Summary
http.ListenAndServe binds a TCP socket, accepts connections forever, and
hands each one to its own goroutine — which parses the request, matches a
route in ServeMux, calls your Handler's ServeHTTP, and writes the
response back. Every handler you've written, whether a plain function or a
struct method, is just something satisfying that one-method interface.
Understanding that chain is what makes routing, middleware, and — critically
— the timeouts a production server needs stop looking like framework magic
and start looking like five specific, reasoned-about decisions.
Key Takeaways
- http.ListenAndServe does three things: bind+listen on a TCP port, accept connections in a loop, and spawn a goroutine per connection
- http.Handler is one method — ServeHTTP(w, r) — and everything in net/http, including your own code, is built on satisfying it
- Each connection's goroutine runs your handler; a slow or blocking handler only holds up that one connection, not the whole server
- http.ServeMux with Go 1.22+ patterns ("GET /users/{id}") gives you method and path-variable routing without a third-party router
- A server with no ReadTimeout, WriteTimeout, or IdleTimeout set is vulnerable to slow-client attacks that exhaust its goroutines and connections
- Middleware is just a function that takes a Handler and returns a new Handler that wraps it
Go Fundamentals Series
Article 9 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Go Interfaces: Design Small, Testable Components
Design small, focused Go interfaces for dependency injection, mocking, and testable backend code, with realistic service and repository examples.
Graceful Shutdown in Go HTTP Servers
Implement graceful shutdown for Go HTTP servers: handling SIGTERM and SIGINT, draining active requests, shutdown timeouts, and Kubernetes readiness.
Building a Production-Ready REST API with Go
A clean, minimal-abstraction project structure for production Go REST APIs: routing, configuration, handlers, services, repositories, validation, error handling, logging, middleware, and health checks.