Go’s net/http package is a complete HTTP/1.1 and HTTP/2 implementation. Every request the server handles runs in its own goroutine. The client has built-in connection pooling and keep-alive. There’s no external dependency needed for most web services — the standard library is production-capable.
That said, http.ListenAndServe(":8080", nil) without timeouts is a denial-of-service waiting to happen, and http.Get(url) without a client timeout will hang indefinitely on a slow server. This guide covers both server and client with the configuration you actually need in production.
For framework-level routing see Go Gin framework and for REST API design see Go building REST APIs.
HTTP Server: The Right Setup
The default http.ListenAndServe uses http.DefaultServeMux and has no timeouts. Never use it in production — a slow client can hold connections open forever, eventually exhausting your goroutines and memory.
Always configure an http.Server with explicit timeouts:
func main() {
mux := http.NewServeMux()
registerRoutes(mux)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
// ReadHeaderTimeout: time to read the request headers
// Prevents Slowloris attacks where clients send headers very slowly
ReadHeaderTimeout: 5 * time.Second,
// ReadTimeout: time to read the entire request (headers + body)
ReadTimeout: 15 * time.Second,
// WriteTimeout: time to write the response
// Set >= ReadTimeout so slow request bodies don't cut off response writing
WriteTimeout: 15 * time.Second,
// IdleTimeout: keep-alive connections are closed after this duration
IdleTimeout: 60 * time.Second,
}
log.Println("listening on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}
Routing with http.ServeMux
Go 1.22 significantly upgraded http.ServeMux with method-qualified patterns and path parameters. You no longer need a third-party router for most APIs:
mux := http.NewServeMux()
// Method-qualified routes (Go 1.22+)
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser) // {id} is a path parameter
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
// Extract path parameters
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22+
// ...
}
For Go versions before 1.22, or for more complex routing (regex patterns, groups), use a router like Gin or chi.
Handlers: Writing Correct Responses
An http.Handler writes to http.ResponseWriter and reads from *http.Request. The response writer is buffered — headers are not sent until you call WriteHeader or start writing the body:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
http.Error(w, "missing user id", http.StatusBadRequest)
return
}
user, err := db.GetUser(r.Context(), id)
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("getUser %s: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// WriteHeader must come after setting headers, before writing the body
// Omitting it defaults to 200 OK on the first Write
if err := json.NewEncoder(w).Encode(user); err != nil {
log.Printf("encode user: %v", err)
}
}
Two common mistakes: calling w.Header().Set(...) after w.WriteHeader(code) (headers are already sent, the Set is silently ignored), and calling w.WriteHeader twice (second call is a no-op but logs a warning).
Decoding Request Bodies
Always use json.NewDecoder for request bodies rather than io.ReadAll + json.Unmarshal — it streams without buffering the full body:
func createUser(w http.ResponseWriter, r *http.Request) {
// Enforce a maximum request size to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject typos in field names
if err := dec.Decode(&input); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if input.Name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
// process...
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": "new-id"})
}
Middleware
Middleware wraps http.Handler with additional behavior. The pattern is a function that takes a handler and returns a handler:
// Logger middleware — logs every request after it completes
func logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap the writer to capture the status code
rw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rw, r)
slog.Info("request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rw.code),
slog.Duration("duration", time.Since(start)),
)
})
}
type statusWriter struct {
http.ResponseWriter
code int
}
func (sw *statusWriter) WriteHeader(code int) {
sw.code = code
sw.ResponseWriter.WriteHeader(code)
}
// Recovery middleware — catch panics, return 500
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Chain middleware — apply outermost first
func chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
// Usage: logger runs first, recovery second
handler := chain(mux, logger, recovery)
HTTP Client: The Right Configuration
http.DefaultClient has no timeout — it will hang indefinitely on a slow or unresponsive server. Always create a client with explicit timeouts:
var httpClient = &http.Client{
Timeout: 30 * time.Second, // total request timeout (connect + send + read)
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // TCP connection timeout
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second, // time to first response byte
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false,
},
}
Create this once as a package-level variable and reuse it. Each &http.Client{} creates a new transport and connection pool — creating one per request defeats keep-alive and leaks file descriptors.
Making Requests with Context
Always use http.NewRequestWithContext rather than http.Get or http.Post — it threads the caller’s context into the request, enabling cancellation and timeout propagation:
func fetchUser(ctx context.Context, id string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.example.com/users/"+id, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+getToken())
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching user %s: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Read and discard body to enable connection reuse
io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("unexpected status %d for user %s", resp.StatusCode, id)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decoding user: %w", err)
}
return &user, nil
}
Always defer resp.Body.Close() and always drain the body before closing (or io.Copy(io.Discard, resp.Body)) — this lets the connection return to the pool for reuse. If you close without draining, the connection is discarded.
POST Requests with JSON Body
func createUser(ctx context.Context, name, email string) (*User, error) {
payload, err := json.Marshal(map[string]string{
"name": name,
"email": email,
})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.example.com/users",
bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("creating user: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
var created User
json.NewDecoder(resp.Body).Decode(&created)
return &created, nil
}
Testing Servers and Clients
net/http/httptest lets you test handlers without starting a real server and test clients against a real server without external dependencies:
// Test a handler directly
func TestGetUser(t *testing.T) {
handler := http.HandlerFunc(getUser)
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
}
// Test a client against a real test server
func TestFetchUser(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(User{ID: 1, Name: "Alice"})
}))
defer ts.Close()
user, err := fetchUserFromURL(context.Background(), ts.URL+"/users/1")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("name = %s; want Alice", user.Name)
}
}
Summary
- Always configure
http.ServerwithReadHeaderTimeout,ReadTimeout,WriteTimeout,IdleTimeout— no timeouts means denial-of-service vulnerability - Use
http.NewServeMux()with method-qualified patterns (Go 1.22+) for most routing needs - Set headers before
WriteHeader; once written, headers cannot be changed - Wrap
r.Bodywithhttp.MaxBytesReaderto enforce request size limits - Create one
*http.Clientwith a configuredTransportand reuse it everywhere — never create one per request - Use
http.NewRequestWithContextfor all outgoing calls; always drain and close the response body
Resources
- net/http package documentation
- net/http/httptest documentation
- Go Blog: HTTP/2
- Go by Example: HTTP Servers
Comments