Skip to content
gohacks.dev
Web

Your First HTTP Server in Go and What Happens per Request

Write a first HTTP server in Go with net/http, then follow one request through the accept loop, a goroutine per connection, keep-alive reuse and the handler.

6 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

The first Go web server is small enough to fit in a tweet, which is both its charm and the reason it misleads. Five lines serve requests, and nothing in those five lines tells you what the server does when a hundred clients arrive at once, when one of them stops sending halfway through a request, or when a handler panics. The standard library net/http package handles all of that sensibly, but it helps to know what it is doing.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article writes the first server, then follows a request through the machinery: the accept loop, the goroutine per connection, the request parser, the handler call, keep-alive. The demos measure each claim. It is the entry point to the web cluster that leads to building a production HTTP server, it sits next to the Handler interface and middleware, and its concurrency side relates to the concurrency patterns cluster. Everything ran on Go 1.25.6.

The smallest server

main.goGo
package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "hello from %s %s\n", r.Method, r.URL.Path)
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

http.HandleFunc registers a function on the default router, http.DefaultServeMux. The pattern "GET /hello" includes the method, a feature of the router since Go 1.22 (the router patterns). ListenAndServe binds the port, runs the accept loop and does not return unless something fails, so it is wrapped in log.Fatal. The nil handler means "use the default mux", which is a global. That is convenient in a demo and a trap in a program, since any imported package can register routes on it; real servers create their own mux, as below.

Start it, run curl localhost:8080/hello, and you get a line of text. Everything after that is what happens in between.

One request, step by step

  1. The accept loop. Serve calls Accept on the listener in a loop. Each accepted TCP connection becomes a conn value.
  2. A goroutine per connection. The server starts go c.serve(...) for it and immediately goes back to accepting. This is the core of Go's server concurrency: there is no thread pool and no event loop, only goroutines, which are cheap.
  3. The request loop. The connection goroutine reads and parses a request (request line, headers), builds an *http.Request and a ResponseWriter, and calls the handler.
  4. The handler. Your code runs on the connection's goroutine. The goroutine is busy until it returns, so a handler that blocks holds that connection.
  5. Finishing the response. After the handler returns, the server flushes buffered output, finishes the body framing (content length or chunked) and decides whether to keep the connection.
  6. Keep-alive. With HTTP/1.1, the connection stays open and the goroutine loops back to read the next request on the same connection. The goroutine ends when the client closes, a timeout fires or an error occurs.
  7. HTTP/2. With TLS, the server speaks HTTP/2 when the client offers it. One connection then carries many concurrent requests, and each request gets its own goroutine instead of the connection getting one.

Two properties follow, and both matter. Handlers run concurrently with each other: ten clients means up to ten handlers executing at once, on different cores. And each handler's goroutine is the unit of failure: a panic is recovered by the server per connection, logged with the stack, and that connection closed (recovery middleware).

Seeing the concurrency

This program starts a test server whose handler sleeps for 100 ms, and sends four requests at once. If requests were processed one at a time, the total would be 400 ms:

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
	"time"
)

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(100 * time.Millisecond) // a slow handler
		fmt.Fprintln(w, "done")
	}))
	defer srv.Close()

	start := time.Now()
	var wg sync.WaitGroup
	for range 4 {
		wg.Go(func() {
			resp, err := http.Get(srv.URL)
			if err != nil {
				return
			}
			io.Copy(io.Discard, resp.Body)
			resp.Body.Close()
		})
	}
	wg.Wait()
	fmt.Println("4 concurrent 100 ms requests took", time.Since(start).Round(50*time.Millisecond))
}
Text
4 concurrent 100 ms requests took 100ms

Four requests took about 100 ms, not 400: each ran on its own connection goroutine at the same time. This is why a Go server needs no explicit pool for I/O-bound handlers, and also why handlers that share data must protect it (data races). A plain map in a package variable, written by a handler, is a bug that the race detector reports on the first concurrent test.

Keep-alive in numbers

HTTP/1.1 connections are reused, which saves a TCP handshake per request. The server's ConnState hook reports each connection's transitions, which makes reuse countable. Five sequential requests from the default client:

main.goGo
package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
)

func main() {
	var opened atomic.Int32
	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "hi")
	}))
	srv.Config.ConnState = func(c net.Conn, s http.ConnState) {
		if s == http.StateNew {
			opened.Add(1)
		}
	}
	srv.Start()
	defer srv.Close()

	for range 5 {
		resp, err := http.Get(srv.URL)
		if err != nil {
			panic(err)
		}
		io.Copy(io.Discard, resp.Body) // read to the end, or the connection cannot be reused
		resp.Body.Close()
	}
	fmt.Println("5 sequential requests used", opened.Load(), "connection(s)")
}
Text
5 sequential requests used 1 connection(s)

One connection served all five requests. The comment in the loop is the detail that matters: the client can only reuse the connection if it has read the previous response body to the end and closed it. Code that skips the read (resp.Body.Close() alone with unread data) forces the transport to discard the connection, and every request pays a new handshake. The same applies to servers calling other servers (parallel HTTP requests).

From ListenAndServe to http.Server

http.ListenAndServe is a convenience wrapper that creates an http.Server with all fields zero. The zero values mean no timeouts: a client can connect and send one byte per minute, holding a goroutine and a file descriptor for as long as it likes. That is the slow-client attack, and the zero-value server is exposed to it. Anything that listens on a real network should build the server explicitly:

Go
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", hello)

srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       15 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       60 * time.Second,
	MaxHeaderBytes:    1 << 20,
}
log.Fatal(srv.ListenAndServe())

A private mux avoids the shared global. ReadHeaderTimeout is the single most important of the timeouts and the one most often missing, because it bounds how long a client may take to send the request headers (timeouts on a Go HTTP server, Slowloris protection). Shutting the server down cleanly is a separate step with its own method (graceful shutdown).

What the handler gets

The *http.Request carries the method, URL, headers, body and a context that is cancelled when the client disconnects (request context). The http.ResponseWriter is an interface you write to: headers first, then the status line, then the body. Calling w.Write without a prior WriteHeader sends a 200 implicitly. Once the first byte of the body is written, headers are locked; changing them afterwards is silently ignored, which is the cause of many "my header does not appear" bugs. The request body is a stream that the server closes after the handler returns, but you should read and limit it yourself (limiting request bodies).

For HTTP semantics and status codes, the useful references are RFC 9110 (HTTP semantics) and the status-code lookups in this site's tools: the HTTP status codes reference lists every code with its meaning. The package documentation for net/http is long and worth reading once.

Where to go next

The pieces of a server are small and composable: a Handler is one interface; middleware wraps handlers; the 1.22 router patterns pick which handler gets the request; the rest of the cluster covers reading requests, writing responses, and hardening the server. The full assembly is in building a production HTTP server in Go.

Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.