A panic in an HTTP handler crashes the goroutine handling the request, which in net/http does not crash the whole server but does log a default message that leaks internal details into access logs. I always wrap handlers with a recover middleware that produces a clean 500 and logs a structured stack trace.

func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			v := recover()
			if v == nil {
				return
			}
			// Do not handle http.ErrAbortHandler: it is the intended way to abort.
			if err, ok := v.(error); ok && errors.Is(err, http.ErrAbortHandler) {
				panic(v)
			}
			buf := make([]byte, 16<<10)
			n := runtime.Stack(buf, false)
			slog.ErrorContext(r.Context(),
				"panic in handler",
				"recovered", fmt.Sprint(v),
				"stack", string(buf[:n]),
				"method", r.Method,
				"path", r.URL.Path,
			)
			http.Error(w, "internal server error", http.StatusInternalServerError)
		}()
		next.ServeHTTP(w, r)
	})
}

Two things people miss:

  1. Re-panic on http.ErrAbortHandler. The server uses it as a signal to tear down the connection without writing a response; swallowing it breaks that contract.
  2. Pass runtime.Stack(..., false) for the current goroutine only. true dumps every goroutine in the process, which is rarely what you want in a per-request log.

See also /posts/the-panic-in-a-goroutine-that-took-down-prod/.