Skip to content
gohacks.dev
Web

HTTP Server Mistakes in Go That Hurt in Production

Twelve mistakes in Go HTTP servers that pass code review and fail under load or attack, each with the symptom, a reproduction where one fits, and the fix.

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

Production Go HTTP servers rarely fail because of something clever. They fail because of a default nobody changed, a branch nobody tested, or a pattern copied from a tutorial that was written for a demo. The list below is the set I keep seeing, in roughly the order they cost people sleep. Some are one-line fixes. A few need a design decision, and for those I point at the article that covers it in depth.

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

Each item says what goes wrong, how you would notice, and what to do. Where a reproduction fits in a screen, there is one, and its output is what the server did on Go 1.25.6. This roundup is part of the web cluster that ends in a production HTTP server, the cornerstone that puts the fixes together. For the errors these mistakes produce, see common Go web server errors.

Mistakes in how a response is written

1. Forgetting to return after an error response

http.Error writes a status and a message, and then your function keeps running. Without a return, the success path writes too. The classic form is a validation check that sends the error and falls through to the code that saves the record. You notice it as a 400 that still created a row, or a body that contains an error message glued to a valid JSON document.

2. Setting headers after the body has started

The first call to Write or WriteHeader sends the status line and the headers. Anything you set on w.Header() afterwards goes nowhere, and a second WriteHeader is ignored with a log line. The usual victim is Content-Type or Set-Cookie placed after an Encode call, or a WriteHeader(404) after the handler already wrote something.

3. Reading the request body twice

r.Body is a stream. After a first read to the end, a second io.ReadAll returns nothing, with no error. It happens when a logging middleware reads the body for the log and passes the same request on. If you must inspect the body in middleware, read it into memory with a limit, then replace r.Body with io.NopCloser(bytes.NewReader(data)).

Items one to three are easy to reproduce in a few lines, so here they are in one program that also captures the server's own error log.

main.goGo
package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/no-return", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("name") == "" {
			http.Error(w, "name is required", http.StatusBadRequest)
			// return is missing
		}
		fmt.Fprint(w, `{"saved":true}`)
	})

	mux.HandleFunc("/late-header", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "body first")
		w.Header().Set("X-Too-Late", "yes") // ignored: the headers were sent with the first Write
		w.WriteHeader(http.StatusAccepted)  // superfluous
	})

	mux.HandleFunc("/read-twice", func(w http.ResponseWriter, r *http.Request) {
		first, _ := io.ReadAll(r.Body) // say, a logging middleware
		second, _ := io.ReadAll(r.Body)
		fmt.Fprintf(w, "first read %d bytes, second read %d bytes", len(first), len(second))
	})

	background := make(chan string, 1)
	mux.HandleFunc("/background", func(w http.ResponseWriter, r *http.Request) {
		go func() {
			<-r.Context().Done() // the context ends when the handler returns
			background <- "request context was cancelled: " + r.Context().Err().Error()
		}()
		fmt.Fprint(w, "accepted")
	})

	var logs bytes.Buffer
	srv := httptest.NewUnstartedServer(mux)
	srv.Config.ErrorLog = log.New(&logs, "", 0)
	srv.Start()
	defer srv.Close()

	show := func(method, path, body string) {
		req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		fmt.Printf("%-9s %d  x-too-late=%q  body=%q\n", path, res.StatusCode, res.Header.Get("X-Too-Late"), b)
	}
	show("GET", "/no-return", "")
	show("GET", "/late-header", "")
	show("POST", "/read-twice", "payload")
	show("GET", "/background", "")
	fmt.Println("background goroutine:", <-background)
	fmt.Println("server error log:", strings.TrimSpace(logs.String()))
}
Text
/no-return 400  x-too-late=""  body="name is required\n{\"saved\":true}"
/late-header 200  x-too-late=""  body="body first"
/read-twice 200  x-too-late=""  body="first read 7 bytes, second read 0 bytes"
/background 200  x-too-late=""  body="accepted"
background goroutine: request context was cancelled: context canceled
server error log: http: superfluous response.WriteHeader call from main.main.func2 (main.go:27)

The first request got a 400, but the body is the error text immediately followed by {"saved":true}: the handler carried on. The second shows the ignored header, an empty X-Too-Late, and the server's error log contains the superfluous response.WriteHeader call message with the file and line, a message worth searching your logs for. The third read 7 bytes the first time and 0 the second. The fourth, which belongs to the next section, shows a goroutine that saw its request context cancelled the moment the handler returned.

Mistakes about time and lifetime

4. Running a server with no timeouts

http.ListenAndServe(addr, handler) builds a server with every timeout at zero. A client that connects and sends one byte a minute holds a goroutine and a socket as long as it likes. Use an explicit http.Server with ReadHeaderTimeout at minimum, plus IdleTimeout, and think about ReadTimeout and WriteTimeout for your endpoints; server timeouts and slowloris cover the numbers.

5. Using the request context after the handler returns

The last line of the output above is the lesson. r.Context() is cancelled when ServeHTTP returns. A goroutine started to send an email or write an audit row, and given r.Context(), is cancelled the moment the response is written, and the work silently fails with context canceled. If the work should outlive the request, derive from context.WithoutCancel(r.Context()) (which keeps the values, such as a trace ID, and drops the cancellation) and add your own timeout. Better still, hand the job to a queue.

6. Starting goroutines you cannot stop or recover

A handler that spawns work needs to answer who owns that goroutine, how it ends, and what happens if it panics. The recover middleware does not help: it only catches panics on the handler's own goroutine, and a panic anywhere else kills the process. Wrap goroutine bodies in their own recovery, and use errgroup when the handler waits for its children, as in recovering from panics in goroutines.

7. Treating http.ErrServerClosed as a failure

Serve and ListenAndServe return http.ErrServerClosed after Shutdown. Code like log.Fatal(srv.ListenAndServe()) exits with a failure status on every graceful shutdown, and the deploy looks broken. Compare with errors.Is(err, http.ErrServerClosed) and treat it as success. The full sequence is in graceful shutdown.

Mistakes about input and exposure

8. Unbounded request bodies and uploads

io.ReadAll(r.Body) and json.NewDecoder(r.Body).Decode read whatever the client sends. One 4 GB POST takes down a small instance. Wrap the body in http.MaxBytesReader before reading, and return 413 when the limit is hit; see limiting request body size.

9. Serving DefaultServeMux

net/http/pprof and expvar register themselves on http.DefaultServeMux from their init functions. A server created with a nil handler serves them to the internet. Build your own ServeMux and keep diagnostics on a private port, as in pprof without exposing it.

10. Trusting headers the client controls

X-Forwarded-For, X-Forwarded-Host, X-Real-IP and Host are input. Taking the first address of X-Forwarded-For as the client IP lets anyone choose the address your rate limiter and audit log record. Trust forwarded headers only from your own proxies, and take the rightmost untrusted entry, as explained in the reverse proxy article.

Mistakes about shared state and errors

11. Shared mutable state in handlers

Handlers run concurrently. A package-level map that a handler writes, a counter incremented with n++, a cache without a lock: all are data races that pass every single-request test. The runtime detects concurrent map writes and kills the process with fatal error: concurrent map writes, which is the polite outcome; silent corruption of a counter is worse. Run tests with -race, protect state with a mutex or sync/atomic, and see the race detector article.

12. Returning 200 for failures, and swallowing errors

A handler that logs an error and then returns normally sends 200 OK with an empty body, and the client believes it worked. Map every failure to a status on purpose (error handling in HTTP handlers), and make the unexpected case a 500 with a request ID, not a silent success. The mirror image is leaking: returning err.Error() to the client exposes internal details such as SQL text and file paths. Log the detail, send the ID. A cheap way to catch the first half is a test helper that runs every handler with a deliberately failing dependency and asserts the status is never 200; it takes ten lines and finds the handlers where a returned error was logged and forgotten.

A short pre-launch list

These checks are deliberately boring. None needs a tool beyond curl and a test file.

Before a service takes real traffic, I check six things, and each takes under a minute:

  1. The http.Server is explicit, with ReadHeaderTimeout and IdleTimeout set.
  2. Every handler path that sends an error ends in return; a test sends bad input to each handler and asserts that nothing downstream ran.
  3. A middleware recovers panics, and every goroutine started in a handler has its own recovery.
  4. Request bodies pass through MaxBytesReader.
  5. /debug/pprof/ on the public port returns 404.
  6. A SIGTERM makes the process finish in-flight requests and exit with status 0.

None of that is exotic. The net/http package documentation states most of these behaviors in plain sentences, and the Go team's guidance on cancellation explains item five better than any summary here. The mistakes survive for a plain reason: the code that contains them compiles, passes a happy-path test and works on a laptop.

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.