The first request
You write six lines of Go, run the file, and your machine starts listening on port 8080. A browser hits the address, and text appears on screen. No frameworks, no configuration files, no dependency downloads. Just the standard library doing exactly what you asked. That moment usually happens on day one of learning Go. It also hides a lot of machinery that becomes obvious once you start building real services.
How the standard library handles it
The net/http package ships with Go because the language treats network I/O as a first-class citizen. Instead of wrapping a third-party router, you get a multiplexer built into the language toolchain. Think of the HTTP server as a reception desk. Incoming requests walk through the door, and the multiplexer checks a list of rules to decide which handler gets the conversation. The handler writes back to the visitor, and the connection closes or stays open depending on the protocol. The whole system runs on goroutines, so each request gets its own lightweight execution thread without blocking the others.
The standard library does not hide the network. It hands you the socket and expects you to write to it.
A minimal server
Here is the smallest program that accepts TCP connections and responds to HTTP traffic.
package main
import (
"fmt"
"net/http"
)
// main starts the HTTP listener on port 8080.
func main() {
// Register a handler for the root path and all subpaths.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Write the response body directly to the network connection.
fmt.Fprintf(w, "Hello, World!")
})
// Start listening on TCP port 8080 with the default mux.
http.ListenAndServe(":8080", nil)
}
The http.HandleFunc call attaches a closure to the default ServeMux. The closure receives two arguments: a ResponseWriter and a pointer to a Request. The ResponseWriter implements io.Writer, which means you can stream bytes to it exactly like you would stream to a file. The Request struct holds everything the client sent: headers, query parameters, method, and body. When ListenAndServe runs, it blocks the main goroutine forever. The server stays alive until you send an interrupt signal or the process exits.
Goroutines handle the concurrency. The mux handles the routing. You handle the response.
What happens under the hood
When you call http.ListenAndServe(":8080", nil), the standard library opens a TCP socket on the specified port. It then enters a loop that accepts incoming connections. Each accepted connection gets upgraded to an HTTP stream. The library parses the request line, reads the headers, and hands the parsed data to the ServeMux. The mux walks through its registered patterns from most specific to least specific. If it finds a match, it spawns a new goroutine and calls your handler inside it.
Your handler runs in isolation from other requests. It receives a fresh Request struct and a dedicated ResponseWriter. When you call fmt.Fprintf(w, ...), the bytes travel through the ResponseWriter, get buffered, and flush to the TCP socket. If you never write a status code, the server automatically sends 200 OK. If you never set a Content-Type, the server guesses based on the first few bytes of your response. This automatic behavior keeps simple examples short, but it also means you need to be explicit in production code.
The standard library manages connection pooling, keep-alive headers, and TLS upgrades behind the scenes. You do not need to write a single line of socket code to handle concurrent traffic. The trade-off is that you must respect the handler contract: read from the request, write to the response, and return when done. Blocking inside a handler ties up a goroutine and eventually exhausts the server's concurrency limit.
Headers travel first. The body follows. The connection closes when the handler returns.
Real-world shape
Production code rarely writes raw strings to the response. You usually set status codes, adjust headers, and return structured data. You also separate routing from business logic so tests can run handlers in isolation.
Here is how a typical endpoint looks when you apply those conventions.
package main
import (
"encoding/json"
"net/http"
)
// healthCheck responds with a JSON status payload.
func healthCheck(w http.ResponseWriter, r *http.Request) {
// Set the content type so clients know how to parse the body.
w.Header().Set("Content-Type", "application/json")
// Prepare a map that marshals cleanly to JSON.
payload := map[string]string{
"status": "ok",
"env": r.URL.Query().Get("env"),
}
// Encode the map directly to the response writer.
json.NewEncoder(w).Encode(payload)
}
// main wires the handler and starts the listener.
func main() {
http.HandleFunc("/health", healthCheck)
http.ListenAndServe(":8080", nil)
}
Notice the order of operations. w.Header().Set must run before any body write. The HTTP protocol sends headers first, then the body. Once you call json.NewEncoder(w).Encode(payload), the header block is sealed. Calling Set after that point does nothing and silently fails. This is a runtime rule, not a compiler rule. The compiler cannot track the order of method calls on an interface.
The ServeMux matches paths using prefix and subtree rules. A pattern ending in / matches all paths that start with that prefix. A pattern without a trailing slash matches only that exact path. This design keeps routing fast and predictable. You do not get regex matching out of the box. You get exact matches and prefix matches. That limitation forces you to think about URL structure before you write code.
In real applications, handlers usually accept a context.Context as their first parameter. The context carries deadlines, cancellation signals, and request-scoped values. You pass it through database calls, HTTP clients, and background tasks. The convention is strict: ctx goes first, named ctx, and every long-lived function respects it. You do not need to add it to the http.HandlerFunc signature because the request already carries a context via r.Context(). You extract it when you need it.
Context is plumbing. Run it through every long-lived call site.
Common traps and compiler feedback
The compiler catches signature mismatches before the program runs. If you pass a function that takes only *http.Request to http.HandleFunc, the compiler rejects it with cannot use func(r *http.Request) { ... } as value of type func(http.ResponseWriter, *http.Request) in argument to http.HandleFunc. The error is verbose but precise. Fix the signature and the program compiles.
Runtime mistakes are harder to spot. Writing to the response after calling w.WriteHeader(404) panics with http: superfluous response.WriteHeader call. The server already sent the status line and headers. You cannot change them mid-stream. The fix is to check for early returns and ensure every code path writes exactly one status and one body.
Another common issue is ignoring the error return from json.NewEncoder(w).Encode(payload). The community accepts silent error drops in simple examples because Encode rarely fails when writing to a ResponseWriter. In production, you log the error and return early. The convention is if err != nil { log.Printf("encode failed: %v", err); return }. The boilerplate exists because it makes the unhappy path visible. You do not wrap it in a helper unless you are building a framework.
Path matching also trips up beginners. Registering /api and /api/ creates two separate routes. The first matches only /api. The second matches /api/users, /api/health, and everything else under that prefix. If you want a catch-all, use the trailing slash. If you want an exact match, drop it. The mux does not redirect automatically. You must write the redirect yourself or accept the 404.
The compiler catches signature mismatches. The runtime catches header ordering mistakes. Test both.
When to reach for what
Go gives you several ways to structure HTTP servers. The right choice depends on your routing complexity, middleware needs, and dependency tolerance.
Use http.HandleFunc with the default mux when you need a quick prototype or a single endpoint that returns static data. Use a custom http.ServeMux instance when you want to separate routing logic from handler registration and pass the mux to tests. Use a third-party router like chi or gin when you need regex path matching, structured logging, or a middleware stack that chains cleanly. Use net/http directly when you want zero dependencies and predictable performance across Go versions.
Start with the standard library. Add complexity only when the routing rules outgrow the built-in mux.