Skip to content
GoBeginner8 min read

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.

GoBackend.dev
GoHTTPnet/httpWeb ServersBackend

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

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:

  1. 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.
  2. Accept, forever. It runs a loop: block until a client connects, accept that connection, then immediately go back to blocking for the next one.
  3. 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.Request represents the incoming request: r.Method, r.URL, r.Header, and r.Body (an io.ReadCloser streaming the request body — read it before it's gone, and close it, though net/http closes it for you after the handler returns).
  • http.ResponseWriter is how you build the response: call w.Header().Set(...) to set headers, w.WriteHeader(status) to set the status code, and w.Write(bytes) (or fmt.Fprintf(w, ...)) to write the body. Order matters — headers must be set before the first w.WriteHeader or w.Write call, because writing the body implicitly sends a 200 OK status 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 (or http.DefaultServeMux) in production. No timeouts, and a global mux that any imported package can silently register routes onto via init(). Build an explicit *http.Server with your own *http.ServeMux instead.
  • Writing to w after the handler returns, from another goroutine. ResponseWriter isn't safe to use once ServeHTTP has 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.Body is a stream, not a byte slice. Reading it twice without buffering it yourself returns nothing the second time — it's io.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