An HTTP Server in Nine Lines and No Framework

Building a real HTTP server with only the standard library: the Handler interface, ResponseWriter and Request, why writing a body auto-sends a 200, and the WriteHeader ordering rule that silently drops your headers. Compiled and run against Go 1.26.5.

Most languages send you shopping for a web framework before you can serve a single request. Go doesn’t. The net/http package in the standard library is a production-grade HTTP server and client, and for a great many services it is the only HTTP dependency you will ever add. This chapter builds a server with nothing else, so you understand the machinery that every Go framework is a thin wrapper over. Everything below was compiled and run against Go 1.26.5.

The whole server

Here is a complete web server:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, Go")
	})
	http.ListenAndServe(":8080", nil)
}

http.HandleFunc registers a function to run when a request path matches /hello. http.ListenAndServe(":8080", nil) binds to port 8080 and blocks, serving requests until the process dies. The nil second argument says “use the default router,” which is where HandleFunc quietly registered your function. That is the entire program. No app object to construct, no server to configure, no middleware stack to assemble first.

The two arguments to your function are the two halves of every HTTP exchange, and you will type their names thousands of times. w http.ResponseWriter is where you write the response: headers, status, body. r *http.Request is everything the client sent: method, URL, headers, body. Write to w, read from r. That is the shape of every handler you will ever write in Go.

The Handler interface

HandleFunc is a convenience. The real abstraction underneath is an interface, and it is worth seeing because it explains why Go web code composes so cleanly:

type Handler interface {
	ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Anything with a ServeHTTP(w, r) method is a Handler, and a Handler is the only thing the server actually knows how to run. Your plain function isn’t a Handler on its own, so the standard library gives you an adapter: http.HandlerFunc is a named function type whose own ServeHTTP method just calls the function. So http.HandlerFunc(myFunc) turns a function into a Handler, and HandleFunc does that wrap for you. The payoff is that a handler, a router, and a piece of middleware are all the same type. Each is a Handler, so each can wrap another, which is exactly how the next chapters build routing and middleware out of nothing but this interface.

ResponseWriter, and the 200 you never asked for

The ResponseWriter has three things you can do, and the order you do them in is load-bearing. You set headers with w.Header().Set(...), you set the status code with w.WriteHeader(code), and you write the body with w.Write(...) (or any of the fmt.Fprint family, since w is an io.Writer).

Here is the part that trips everyone up on day one: you almost never call WriteHeader. The first time you write any body bytes, the server sends a 200 OK for you, along with whatever headers you have set so far. Writing the body is committing the response. Running the nine-line server above and hitting it confirms it:

GET /hello -> 200 "Hello, Go\n"
Content-Type: text/plain; charset=utf-8

A 200, and a Content-Type you never set. The server sniffs the first bytes of your body and guesses the type. That is convenient for text/plain, and a trap for everything else: serve JSON without setting the header and Go may sniff it as text/plain and browsers will refuse to parse it. Set Content-Type yourself for anything but casual output.

The ordering rule that eats your headers

Because writing the body flushes the status and headers, everything you want in the response must happen before the first write. That gives one hard rule: set your headers, then call WriteHeader, then write the body — in that order. A header set after WriteHeader is silently ignored, because the header block has already gone out on the wire.

This is not a warning-you-can-ignore situation; it fails silently. To prove it, here are two handlers that differ only in ordering. The good one sets the content type, then the status; the bad one reverses them:

func goodOrder(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusTeapot) // 418
	fmt.Fprintln(w, "short and stout")
}

func badOrder(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusTeapot)
	w.Header().Set("Content-Type", "application/json") // too late, ignored
	fmt.Fprintln(w, "short and stout")
}

Hitting both:

/good -> 418  Content-Type="text/plain; charset=utf-8"  body="short and stout\n"
/bad  -> 418  Content-Type="text/plain; charset=utf-8"  body="short and stout\n"

Both return 418. But /bad does not return application/json. Its Set call ran after WriteHeader, so it changed a map nobody was going to read again, and the server fell back to sniffing text/plain. No error, no log line, no panic. Your API just serves the wrong content type forever and you find out from a confused client. Get the ordering into your fingers now: headers, status, body.

There is a second consequence of the same rule. Once you have written anything, calling WriteHeader again does nothing useful and logs a superfluous response.WriteHeader call complaint. A response gets exactly one status code, set once, before the body.

Errors and the missing route

You will not always return 200, and the standard library has a one-liner for the failure case. http.Error(w, message, code) sets Content-Type: text/plain, calls WriteHeader(code), and writes the message, all at once:

func guard(w http.ResponseWriter, r *http.Request) {
	http.Error(w, "you shall not pass", http.StatusForbidden)
	return
}

The return is deliberate, and it is the second footgun of the chapter. http.Error does not stop your handler. It writes the response and returns normally, so any code after it keeps running and keeps appending to the body. A handler that calls http.Error and then forgets to return produces exactly this:

/guard  -> 403  body="you shall not pass\n"
/forgot -> 403  body="you shall not pass\n ...and then it kept talking"

The /forgot handler committed a 403 and then bled more text into the response. Treat http.Error like a return statement’s noisy cousin: send it, then return.

Finally, what happens when a request matches no registered route? The default router answers for you:

/nope -> 404  body="404 page not found\n"

A real 404, with a plain-text body, generated by the router with no handler of yours involved. You get sane defaults for the paths you didn’t write.

One goroutine per request, and the error you throw away

Two facts about ListenAndServe are easy to miss and both matter in real code. The first: the server runs each request in its own goroutine. You do not manage a thread pool or an event loop; the server accepts a connection, spawns a goroutine, and calls your handler in it. Firing five concurrent requests at a handler and watching all five get served confirms it:

5 concurrent requests all served; distinct remote conns=5

This is why Go web services scale so casually, and it is also a warning: two requests can run your handler at the same moment, so any state a handler shares — a map, a counter, a cache — is subject to exactly the data races the concurrency series was about. A handler that reads and writes package-level state without a mutex is a bug waiting for load. Keep per-request state on the stack, and guard anything shared.

The second fact: ListenAndServe always returns a non-nil error. It blocks while serving, and the only way it returns at all is that something went wrong — the port was taken, the socket closed. So the idiomatic call wraps it in a log-and-die, because a silent http.ListenAndServe(":8080", nil) on its own line will exit your program with status 0 the instant the bind fails, telling you nothing:

log.Fatal(http.ListenAndServe(":8080", nil))

Never discard that return value. It is the only signal you get that your server never actually started.

Final thoughts

A Go HTTP server is net/http and nothing else: register a handler, call ListenAndServe, block. Every handler is func(w http.ResponseWriter, r *http.Request) — write to w, read from r — and every handler is really a Handler, an interface with one ServeHTTP method, which is why handlers, routers, and middleware are all the same composable type. Writing a body auto-sends a 200 and sniffs a content type, so set your own for anything structured. The rule that will bite you is ordering: headers, then WriteHeader, then body, because the first write commits the response and anything set afterward is dropped in silence. Use http.Error for failures, and remember it doesn’t return for you. That is a real server. Next we make it route like a grown-up.

Next: routing: the router grew up in 1.22 — method and wildcard patterns, path values, and the automatic 405.

Comments