Last Updated: May 22, 2026
Go's standard library ships with a production-grade HTTP server in the net/http package, so you don't need a framework like Express or Spring to put a web service on the network. A few lines of Go are enough to bind a port, accept requests, and write responses. This chapter walks through the smallest server you can write, the two ways to register handlers, the shape of a request and response, and the common mistakes on day one.
The smallest useful HTTP server in Go is three lines of logic inside main. The function http.ListenAndServe opens a TCP socket on the address you give it and serves HTTP forever. The function http.HandleFunc registers a callback that runs for every request matching a URL path.
Save this in main.go, run go run main.go, then open a second terminal and hit it with curl.
The address :8080 has no host before the colon, which means "listen on every network interface on port 8080". You could write 127.0.0.1:8080 to bind only to localhost, or 0.0.0.0:8080 to be explicit about all interfaces. Just :8080 is the common shorthand. Second, the second argument to ListenAndServe is nil. That's not a missing handler. Passing nil tells the server to use a default routing table called http.DefaultServeMux, which is the table http.HandleFunc writes into.
The server runs until the process dies. ListenAndServe blocks forever in normal operation and only returns if something goes wrong, like the port being already in use. A common improvement is to log that error so a startup failure doesn't disappear silently.
log.Fatal prints the error and calls os.Exit(1), which turns a silent failure into a visible one.
A ServeMux is Go's term for an HTTP routing table. It maps URL paths to handlers. The net/http package keeps a global instance called http.DefaultServeMux, and the two package-level functions http.HandleFunc and http.Handle are shortcuts that register routes into it.
The difference between the two is what they accept as the handler.
| Function | Argument | Use When |
|---|---|---|
http.HandleFunc(path, fn) | A plain function with signature func(http.ResponseWriter, *http.Request) | You have a function you want to expose at a path. |
http.Handle(path, h) | A value that satisfies the http.Handler interface | You have a type or middleware-wrapped handler. |
For now, treat HandleFunc as the common case.
Here's a server that registers two routes against the default mux, one with each function.
Both routes work. HandleFunc took a function literal and wired it to /greet. Handle took a productHandler value (which has a ServeHTTP method) and wired it to /featured. The default mux holds both registrations, and ListenAndServe(..., nil) dispatches incoming requests through it.
The default mux is a global, which is convenient for small programs and a liability in larger ones. Two independent packages that both register /health will silently overwrite or panic depending on order. Production code typically creates its own http.NewServeMux() and passes it to the server as the second argument instead of nil. The default mux is fine for examples and small tools; both styles are common.
Cost: registering two handlers on the exact same path on the default mux panics on the second call. Pick unique paths, or build your own mux to keep the global state out of it.
The handler signature is func(w http.ResponseWriter, r *http.Request). The *http.Request parameter holds everything the client sent: the HTTP method, the URL, the headers, and the body. The four most-used fields are r.Method, r.URL, r.Header, and r.Body.
This server reads the URL path to build a personal greeting and reports the request method back to the client.
The path "/greet/" ends with a slash, which tells the default mux to match every URL that starts with that prefix. Without the trailing slash, /greet would match only the exact path. The trailing-slash rule is a default-mux convention; the upgraded mux in Go 1.22 has cleaner routing patterns.
r.URL is a parsed *url.URL, so r.URL.Path gives you the path portion without the host or query string. Query parameters live on r.URL.Query(), which returns a url.Values map.
r.Header is a map[string][]string (with a friendlier http.Header type wrapping it) holding every header the client sent. The most common access pattern is r.Header.Get("Content-Type"), which returns the first value for that header or an empty string. Header names are case-insensitive on the wire, and Get handles the canonicalization for you.
r.Body is an io.ReadCloser, which means it's a stream you read from and then close. The body is whatever the client sent in the request payload, like a form submission or a JSON document. Here's a handler that echoes back whatever the client sent in the body of a POST.
Even though the Go HTTP server closes the body when the handler returns, closing it explicitly in your handler is the safe habit because it generalizes to the HTTP client where you must close it yourself.
The http.ResponseWriter parameter is how you build the response. It has three methods that matter at this level.
| Method | Purpose |
|---|---|
w.Header() | Returns the response header map you can edit before writing the body. |
w.WriteHeader(statusCode) | Sends the status line (200 OK, 404 Not Found, etc.). Optional, defaults to 200. |
w.Write(b []byte) | Writes bytes into the response body. |
The catch is the order. HTTP responses have a fixed structure: status line first, then headers, then body. The ResponseWriter enforces this implicitly. Once you call Write, the server flushes the status line and headers, and any later WriteHeader call is too late.
The following demonstrates the right order: set headers, set status, write body.
If you don't set Content-Type yourself, Go sniffs the first 512 bytes of your write and guesses, which is convenient but unpredictable. Setting it explicitly is the safe call. If you don't call WriteHeader, the first Write triggers an implicit WriteHeader(200). That's why the very first example in this chapter worked without an explicit status code.
http.StatusOK, http.StatusNotFound, and a few dozen others are named constants in the net/http package. They're more readable than the raw numbers and they're spelled consistently across the standard library.
For error responses, http.Error is a one-call shortcut that sets the content type, writes the status, and prints a message.
The return after each http.Error call is the part beginners miss. Without it, the handler keeps running and writes more bytes after the error response, which we'll look at in the next section.
The server inside http.ListenAndServe doesn't process requests one at a time. Every accepted TCP connection runs in its own goroutine, and each request on that connection runs synchronously within the goroutine. With HTTP/1.1 keep-alive that's one goroutine per connection; with HTTP/2 the server can handle multiple concurrent streams over a single connection.
This is the trade-off that makes Go a popular language for HTTP services. You write a handler that looks blocking, and the runtime handles the concurrency for you. Two clients hitting your /greet endpoint at the same time don't queue behind each other.
Both requests take about two seconds, not four. The server doesn't serialize them. Each handler runs in its own goroutine.
The practical implication is that any state your handlers share has to be safe to access from many goroutines at once. A map[string]int storing visit counts will race; a sync.Map or a sync.Mutex-guarded map won't.
Cost: every connection starts a new goroutine. Goroutines are cheap (a few KB each), but a server taking 100,000 concurrent connections still holds 100,000 goroutines worth of memory and scheduler state. That's fine on a normal server; just don't think of goroutines as free.
The default ListenAndServe setup has no read timeout, no write timeout, and no idle timeout, which means a slow or malicious client can hold a connection open forever. For anything beyond local development you'd construct an http.Server explicitly and set those fields.
Three errors come up over and over in code from people new to net/http. Knowing the shape of each one saves hours of debugging.
Forgetting to close the request body. r.Body is an io.ReadCloser. The server is allowed to close it after the handler returns, but the convention is to close it yourself, and it becomes mandatory when you start using the HTTP client. A handler that leaves the body open works fine in tests and starts leaking resources in production once it's wrapped with middleware or proxied through an httputil.ReverseProxy.
defer r.Body.Close() costs nothing and prevents a class of resource leak.
Calling `WriteHeader` twice. The first call sends the status line. The second one is ignored, and the runtime prints "http: superfluous response.WriteHeader call" to the server's log. The bug usually looks like this:
http.Error calls WriteHeader(405) internally. Without the return, the code falls through to w.WriteHeader(http.StatusOK), which logs a warning and gets dropped. The client sees a 405 response with an ok body appended after the error message, which is almost never what you want.
The fix is one keyword:
Continuing to write after an error response. Even when you don't call WriteHeader twice, you can still write more body bytes after http.Error has already written its message. The client sees a garbled response: the error string concatenated with whatever you wrote next.
A GET with no id here writes both id required and looking up order into the response body. The status code is 400, but the body is meaningless. Always return after writing an error.
The rule that sorts out all three mistakes is "every write into the response is a one-way trip". Once you've started, you can't go back, and once you've gone down the error branch, you can't pretend you didn't.